| Right | Module | Keyed on |
|---|---|---|
| access, portability | DataPortabilityExporter | the subject: true field |
| erasure | RightToErasure | the same |
| all of them, as a tracked request | DSARWorkflow | a request id |
| the basis you rely on | ConsentRecord / ConsentVersioning | subject + purpose |
| the assessment before you start | PrivacyImpactAssessment | an assessment id |
DSARWorkflow is the state machine around a request — created, in progress,import { DSARWorkflow } from 'ts-prorm-orm';
const request = DSARWorkflow.createRequest('user-42', 'access'); // 'access' | 'rectification' | 'erasure' | 'portability' | 'restriction' | 'objection' | 'withdrawal'
DSARWorkflow.startProcessing(request.id);
const fulfilled = await DSARWorkflow.fulfillRequest(request.id, {
models: [{ model: User }, { model: Order, where: { userId: 'user-42' } }],
format: 'json',
});
DSARWorkflow.markDelivered(request.id);
DSARWorkflow.completeRequest(request.id);
DSARWorkflow.getRequestsForDataSubject('user-42');
DSARWorkflow.getRequestsByStatus('pending');
DSARWorkflow.getExpiredRequests(); // open and past their deadline — the ones a regulator asks about
DSARWorkflow.expireOverdue(); // move those to 'expired' (run it on a schedule)
DSARWorkflow.rejectRequest(request.id, 'identity not verified');Map. It is the shape ofrequestDeadlineDays.canTransition() insrc/compliance/dsar-workflow.ts); a call that does not match one throwsCannot … request in status: …:fulfillRequest() only runs from in_progress, so call startProcessing()rejected with the error as rejectionReason, and aDSARWorkflow.expireOverdue() moves every open request (pending,in_progress, pending_fulfillment) whose deadline has passed to expiredgetRequestsByStatus('expired') runs it first. A requestgetExpiredRequests() still lists the overdue requests without changing them.DSAR_DEADLINE_DAYS). The regimes default by request type (an erasure isDSARWorkflow.createRequest('user-42', 'access', { frameworks: ['GDPR', 'CCPA'] }); // 30 days
DSARWorkflow.createRequest('user-42', 'access', { receivedAt: new Date('2026-09-01'), deadlineDays: 30 });[!NOTE]
Before 4.9.2,createRequest()set every deadline to 60 days, and nothing
ever moved a request toexpired. Requests created by an older release keep
their 60-day deadline.
EU andUS-CA, answers inside the regimes' deadline, and keeps what the workflowimport { DSARWorkflow, RightToErasure, complianceOf } from 'ts-prorm-orm';
declare const User: any, Order: any, Comment: any;
declare const dsarStore: { save(row: Record<string, unknown>): Promise<void> };
export async function eraseSubject(userId: string) {
// resolve(['EU', 'US-CA']).requestDeadlineDays is 30: the shortest clock wins.
const days = complianceOf(User)?.resolved.requestDeadlineDays ?? 30;
const request = DSARWorkflow.createRequest(userId, 'erasure', { deadlineDays: days });
const dueBy = request.deadline!;
await dsarStore.save({ id: request.id, userId, type: 'erasure', dueBy }); // the durable copy
if (!(await RightToErasure.exists(User, { where: { id: userId } }))) {
DSARWorkflow.rejectRequest(request.id, 'no data held for this subject');
return DSARWorkflow.getRequestStatus(request.id);
}
DSARWorkflow.startProcessing(request.id);
await DSARWorkflow.fulfillRequest(request.id, {
models: [{ model: User, where: { id: userId } }],
erasureOptions: {
mode: 'anonymize', // orders must still add up
piiFields: ['name', 'email', 'phone'],
cascade: [
{ model: Order, foreignKey: 'userId', piiFields: ['shippingName', 'shippingAddress'] },
{ model: Comment, foreignKey: 'userId', piiFields: ['body'] },
],
},
notes: `due ${dueBy.toISOString().slice(0, 10)}`,
});
return DSARWorkflow.completeRequest(request.id); // fulfilled → delivered → completed
}fulfillRequest() calls RightToErasure.erase() once permodels, with the where you give; without a mode inerasureOptions it hard-deletes. An access or portability request withmodels list produces request.exportData instead, as{ content, format, recordCount }.import { DataPortabilityExporter } from 'ts-prorm-orm';
const result = await DataPortabilityExporter.export(User, {
where: { id: subjectId },
include: [Order, Address],
format: 'json', // 'json' | 'csv' | 'ndjson'
excludeForRole: 'export', // fields hidden from this role are left out
});
// Every model that holds something about the subject, in one response:
const bundle = await DataPortabilityExporter.createDSARResponse(subjectId, [
{ model: User },
{ model: Order, where: { userId: subjectId } },
{ model: SupportTicket, where: { requesterId: subjectId } },
], { format: 'json' });
// Large sets: stream to a writer rather than build in memory.
await DataPortabilityExporter.exportStream(Event, {
where: { userId: subjectId },
format: 'ndjson',
onChunk: (chunk) => res.write(chunk),
});createDSARResponse keys each model on id — pass a where for models whereimport { RightToErasure } from 'ts-prorm-orm';
await RightToErasure.exists(User, { where: { id: subjectId } }); // before promising an outcome
const result = await RightToErasure.erase(User, { where: { id: subjectId } }, {
mode: 'anonymize', // or 'delete'
piiFields: ['name', 'email', 'phone'], // what 'anonymize' overwrites
redactedValue: '[REDACTED]',
cascade: [
{ model: Post, foreignKey: 'userId', piiFields: ['authorName'] },
{ model: Comment, foreignKey: 'userId' },
],
});'delete' hard-destroys the rows; 'anonymize' overwrites the PII fields incascade walks associated models recursively.'delete' mode the eraser reads the subject's keys first and erases the'anonymize'piiFields overwrites the model's classified PII fields, or, whenreferences columns and the cascade'sforeignKey). Naming piiFields on every target is still the clearest.[!NOTE]
Before 4.9.2 a'delete'cascade looked up the subject's ids after deleting
them, found nothing, and left the child rows in place; and'anonymize'
withoutpiiFieldsoverwrote every column exceptid,createdAtandupdatedAt, foreign keys and numbers included.
import { ConsentRecord, LegalBasisEnum } from 'ts-prorm-orm';
await ConsentRecord.initialize(prorm); // creates consent_records
await ConsentRecord.grant({
subjectId: 'user-42',
purpose: 'marketing_email',
legalBasis: LegalBasisEnum.Consent, // Consent | Contract | LegalObligation | VitalInterests | PublicTask | LegitimateInterests
version: '2026-09', // the wording they agreed to
ipAddress: req.ip,
});
await ConsentRecord.isActive('user-42', 'marketing_email'); // boolean
await ConsentRecord.withdraw('user-42', 'marketing_email');
await ConsentRecord.getHistory('user-42');
await ConsentRecord.getActiveConsents('user-42');
await ConsentRecord.getConsented('marketing_email'); // subject ids, for a sendConsentVersioning is a second implementation of an overlapping idea, withconsent_versions) and an emphasis on the version of theimport { ConsentVersioning } from 'ts-prorm-orm';
await ConsentVersioning.initialize(prorm);
await ConsentVersioning.recordConsent('user-42', 'analytics', true, '1.0');
await ConsentVersioning.reGrantConsent('user-42', 'analytics', '2.0'); // supersedes 1.0
await ConsentVersioning.validateConsent('user-42', 'analytics'); // true
await ConsentVersioning.revokeConsent('user-42', 'analytics');
await ConsentVersioning.getConsentHistory('user-42', 'analytics');Pick one and use it consistently. The two tables are not coordinated, and
a subject who withdrew in one is still consenting in the other.
IN that is not on@DataSubjectAge() and @ParentalConsent();ConsentRecord like anyimport { PrivacyImpactAssessment } from 'ts-prorm-orm';
await PrivacyImpactAssessment.initialize(prorm);
const pia = await PrivacyImpactAssessment.createAssessment(
'Behavioural analytics',
['email', 'ip_address', 'page_views'],
'improve product recommendations',
);
await PrivacyImpactAssessment.recordRisk(pia.id, 'Re-identification from page views', 'high', 'Aggregate to daily counts; drop IPs after 24h');
await PrivacyImpactAssessment.linkToModel(pia.id, PageView);
await PrivacyImpactAssessment.submitForReview(pia.id);
await PrivacyImpactAssessment.hasUnresolvedHighRisks(pia.id); // gate the approval on this
await PrivacyImpactAssessment.approveAssessment(pia.id, 'dpo@example.com');
// or PrivacyImpactAssessment.rejectAssessment(pia.id, 'mitigation insufficient')
await PrivacyImpactAssessment.getByStatus('approved'); // 'draft' | 'in_review' | 'approved' | 'rejected'
await PrivacyImpactAssessment.getRisksBySeverity(pia.id); // { low, medium, high, critical }