| Need | Control |
|---|---|
| drop or anonymise rows past a window | Retention |
| rows that cannot be edited or deleted | Immutability |
| write-once storage with a tamper log | WORM |
| who changed what, when, from where | Audit trail |
| where a value came from | Lineage |
| the backup would actually restore | Backup verification |
| somebody is taking too much | Breach detection |
import { DataRetentionPolicy } from 'ts-prorm-orm';
DataRetentionPolicy.set(AuditLog, {
maxAge: { years: 7 }, // years / months / days / hours, or { ms }
field: 'createdAt', // the column the age is measured from (default)
onExpiry: 'delete', // or 'anonymize'
preserveFields: ['id'], // kept when anonymising
});
const { affected, mode } = await DataRetentionPolicy.purge(AuditLog, prorm);
await DataRetentionPolicy.purgeAll(prorm); // every model with a policy
DataRetentionPolicy.get(AuditLog);
DataRetentionPolicy.listPolicies();
DataRetentionPolicy.cutoffDate({ years: 7 }); // the date the policy implies, today@Personal(…, { retention }) and each ceiling the regimes state, sopurgeAll() honours the law and your own numbers together.purge() runs when you call it — putcompliance.retention-purge.7d policy runs it{ ms } when a boundary has to be exact. And a purge here does not reachpurgeAll() does not throw when one model fails: it records{ affected: -1, mode: 'error: …' } for that model and moves on. A job thatimport { DataRetentionPolicy, Prorm } from 'ts-prorm-orm';
declare const prorm: Prorm;
declare const pager: { notify(msg: string): Promise<void> };
export async function nightlyPurge() {
const results = await DataRetentionPolicy.purgeAll(prorm);
const failed: string[] = [];
for (const [model, { affected, mode }] of results) {
if (affected < 0) failed.push(`${model}: ${mode}`);
else console.log(`retention: ${model} ${mode} ${affected} row(s)`);
}
if (failed.length) await pager.notify(`retention purge failed:\n${failed.join('\n')}`);
}import { ImmutableRecord } from 'ts-prorm-orm';
ImmutableRecord.makeImmutable(LedgerEntry); // update and destroy now throw
ImmutableRecord.makeImmutable(Receipt, { silentDelete: true }); // destroy is ignored rather than refused
ImmutableRecord.enableVersioning(Contract, {
versionField: 'version', // defaults
parentField: 'previousId',
});
// An "edit" now appends a new row linked to the one it supersedes; nothing is overwritten.@Security({ immutable: true, versioning: true }) and any framework thatimmutable (SOX, SEC 17a-4, 21 CFR Part 11, EU Annex 11, ALCOA+,setupSecurity().REVOKE UPDATE, DELETE on the table binds everybody — do that as well; seeactive → archived → frozen, and eachimport { WORMStorage, WORM } from 'ts-prorm-orm';
WORMStorage.enable(Filing, prorm, { retentionPeriod: { years: 7 }, tamperEvident: true });
// or @WORM({ retentionPeriod: { years: 7 }, tamperEvident: true }) on the class
await WORMStorage.archive(filing);
await WORMStorage.freeze(filing);
await WORMStorage.getState(filing.id, 'filings');
await WORMStorage.getTamperLog(filing.id, 'filings');
await WORMStorage.verifyIntegrity(filing.id, 'filings');
await WORMStorage.enforceRetention(Filing, prorm); // release what is past its periodassertUpdatable() and assertDeletable() insrc/compliance/worm-storage.ts, the two checks the beforeUpdate andbeforeDestroy hooks run; archive() and freeze() are the only writesverifyIntegrity() walks the create,archive, freeze and update entries in order, requires eachpreviousHash to equal the hash before it, and then re-hashes the row as ittamper_detected entries are flags outsideverifyIntegrity() checks both the chain linkage and that the current recordimport { ComplianceAuditTrail } from 'ts-prorm-orm';
ComplianceAuditTrail.enable(prorm, Patient, {
getUserId: () => currentUser()?.id,
getIpAddress: () => currentRequest()?.ip,
tableName: 'ComplianceAuditLogs', // default
});AuditLogger,@Security({ audit: true }) — or any framework that requiresaudit, which is almost all of them — calls this at setupSecurity().psql session is not inimport { DataLineage, LineageTrack } from 'ts-prorm-orm';
@LineageTrack()
class Invoice extends Model { /* … */ }
DataLineage.enable(Invoice, prorm);
// source: 'import' | 'user_input' | 'computed' | 'derived'
await DataLineage.trackSource(Invoice, 'import', { sourceDetails: 'stripe in_123', metadata: { account: 'acct_1' } });
await DataLineage.trackTransformation(Invoice, invoice.id, 'currency-normalised', { description: 'EUR → USD', performedBy: 'fx-job' });
await DataLineage.getLineage(invoice.id, 'Invoice'); // the whole chain
await DataLineage.getProvenance(invoice.id, 'Invoice'); // just the origin
await DataLineage.getTransformations(invoice.id, 'Invoice');applyLineageHooks.import { BackupVerification } from 'ts-prorm-orm';
BackupVerification.configure({
primary: { type: 'filesystem', path: '/var/backups/db' }, // or 's3' | 'azure' | 'gcp' | 'nfs' with a bucket
staging: { database, host, username, password },
});
BackupVerification.registerBackup({
backupId: 'nightly-2026-09-12', source: 'primary', timestamp: new Date(),
size: 4_200_000_000, checksum: 'sha256:…', encrypted: true, retentionDays: 35,
});
await BackupVerification.verifyBackup('nightly-2026-09-12');
await BackupVerification.testRestore('nightly-2026-09-12');
BackupVerification.scheduleVerification('nightly-2026-09-12', { frequency: 'monthly', dayOfMonth: 1, time: '03:00', active: true });
BackupVerification.getVerificationHistory('nightly-2026-09-12');verifyBackup() reads a filesystem backup andcomputeChecksum callback.testRestore() does not restore: it confirms the staging database isdata.restore-test.90d policy nags whenRightToErasure.erase() again with the same where and mode.DataRetentionPolicy.purgeAll(prorm) against the restored database, so rowsimport { BreachDetector, SecurityMonitor } from 'ts-prorm-orm';
const detector = BreachDetector.create(prorm, {
massExportThreshold: 10_000, // rows in one query
rateLimitWindow: 60_000, // ms
rateLimitMax: 500, // queries per window per user
failedAuthThreshold: 10,
alertCallback: (alert) => pager.notify(alert),
});
detector.recordAuthFailure(userId, ip); // feed it what the ORM cannot see
detector.getAlerts({ severity: 'high' });
detector.getStats();
detector.stop();SecurityMonitor is the same idea with more knobs and a longer memory.auth.failed.*,access.bulk-read.*, auth.new-country.* — watch the same signals at the