import {
Jurisdiction, Personal, DataSubjectAge, ParentalConsent,
Table, Column, DataTypes, Model,
} from 'ts-prorm-orm';
@Jurisdiction('EU', 'US-CA') // ABOVE @Table — see below
@Table({ tableName: 'patients' })
class Patient extends Model {
@Personal('identifier', { basis: 'contract', subject: true })
@Column(DataTypes.STRING) declare name: string;
@Personal('contact', { basis: 'contract' })
@Column(DataTypes.STRING) declare email: string;
// A special category: it needs a basis AND a purpose, or the class does not load.
@Personal('health', { basis: 'consent', purpose: 'treatment', retention: 'P5Y' })
@Column(DataTypes.TEXT) declare notes: string;
// A credential must be stored protected — hashed or encrypted — and say so.
@Personal('credential', { protected: true })
@Column(DataTypes.STRING) declare passwordHash: string;
@DataSubjectAge() @Column(DataTypes.DATEONLY) declare birthday: string;
@ParentalConsent() @Column(DataTypes.BOOLEAN) declare guardianConsented: boolean;
}| Decorator | On | Says |
|---|---|---|
@Jurisdiction(...codes, options?) | the class | which regimes the model is held to; runs the check. A trailing object carries region, advisory and retentionField — the facts no column can |
@Personal(category, options) | a column | this is personal data of this category, held on this basis for this purpose, for at most this long |
@DataSubjectAge() | a column | this is the subject's date of birth (YYYY-MM-DD) or integer age |
@ParentalConsent() | a column | this boolean is true when a parent or guardian has consented |
@Personal optionsinterface PersonalField {
category: PersonalCategory; // 'identifier' | 'contact' | 'health' | … (see Jurisdictions)
basis?: LawfulBasis; // required for special categories, and for everything under 'always' regimes
purpose?: string; // required alongside a basis for special categories
retention?: Duration; // 'P5Y', 'P18M' — overrides a regime ceiling only if shorter
subject?: boolean; // the field that identifies the person; erasure and export key on it
protected?: boolean; // stored hashed or encrypted; 'credential' requires it
}@Jurisdiction must sit above @Table. TypeScript applies class@Table writes the@Jurisdiction reads it to find the columns and installPatient: cannot install the age gate — the class has no @Table metadata yet.
Put @Jurisdiction ABOVE @Table (decorators apply bottom-up), or call
applyCompliance() after prorm.addModel().prorm.define() oraddModel(), or for one you do not own:import { applyCompliance } from 'ts-prorm-orm';
applyCompliance(Patient, {
jurisdictions: ['EU', 'US-CA'],
fields: {
name: { category: 'identifier', basis: 'contract', subject: true },
email: { category: 'contact', basis: 'contract' },
notes: { category: 'health', basis: 'consent', purpose: 'treatment', retention: 'P5Y' },
passwordHash: { category: 'credential', protected: true },
},
ageField: 'birthday',
parentalConsentField: 'guardianConsented',
region: 'eu', // where the database lives, for the residency check
advisory: false, // true: record findings, refuse nothing
});applyCompliance() returns what it applied — { options, resolved, findings }complianceOf(Model) returns the same later (or null for a modelinterface Finding {
severity: 'refused' | 'warning' | 'info';
field?: string;
code: string;
message: string;
regimes: string[];
}refused finding stops the class loading — the error carries every finding,| Code | Refused when |
|---|---|
prohibited-category | a field's category is one a named regime forbids holding without a statutory exemption |
special-category-without-basis | a special-category field (health, biometric, genetic, protected, and whatever else the regimes single out) declares no basis |
special-category-without-purpose | a special-category field has a basis but no purpose |
personal-without-basis | any personal field has no basis and a named regime requiresBasis: 'always' (GDPR does) |
basis-not-recognised | the basis is not one every named regime recognises — India does not accept legitimate interests, so a model held to EU and IN cannot rely on it |
credential-in-clear | a credential field is not protected: true |
retention-over-ceiling | the declared retention is longer than the regime's ceiling for that category |
age-without-consent-field | an age field is declared but no parental-consent field — below the consent age a parent consents, and there has to be a column that says they did |
residency-unknown | a named regime localizes (CN, RU primary copy; KE, VN a copy) and no region is declared — @Jurisdiction('CN', { region: 'cn' }) |
residency-violated | a localizing regime and the declared region is outside its regions |
| Code | Severity | Says |
|---|---|---|
child-data-without-age | warning | a field is declared child but there is no age field, so the gate cannot run |
no-subject-field | warning | the regimes grant erasure or portability but no field is subject: true, so a request cannot be keyed to a person |
no-age-gate | info | no age field is declared; if the model can hold data about people under the consent age, declare one |
transfer-outside | info | the database is outside the regimes' regions; transfers need adequacy / safeguards / consent / approval |
ComplianceError; its detail.findings holds the refusedimport { applyCompliance, ComplianceError } from 'ts-prorm-orm';
declare const Customer: any;
try {
applyCompliance(Customer, {
jurisdictions: ['EU', 'IN'],
fields: {
email: { category: 'contact', basis: 'legitimate-interests' }, // India does not recognise it
notes: { category: 'health' }, // special, and no basis
},
});
} catch (err) {
if (!(err instanceof ComplianceError)) throw err;
for (const f of err.detail.findings as Array<{ code: string; field?: string }>) {
console.error(`${f.field ?? '(model)'}: ${f.code}`);
}
// email: basis-not-recognised
// notes: special-category-without-basis
}advisory: true — in applyCompliance()'s options or in @Jurisdiction'scomplianceOf(Model).findings, fix, remove the flag.import { complianceOf } from 'ts-prorm-orm';
declare const Patient: any;
declare const test: (name: string, fn: () => void) => void;
test('Patient has no refused findings left', () => {
const refused = complianceOf(Patient)!.findings.filter((f) => f.severity === 'refused');
if (refused.length) {
throw new Error(refused.map((f) => `${f.field}: ${f.code} (${f.regimes.join(', ')})`).join('\n'));
}
});@DataSubjectAge() declared, every create and updateYYYY-MM-DD date of birth by the calendarageOn()), or from an integer age column — and below the resolved consent@ParentalConsent() column must be true, or the write is refused:Patient: the data subject is 14, under the age of digital consent (16) for
EU, US-CA, and guardianConsented is not set. A parent or guardian has to
consent first.minimumAge), a subject below it is@Jurisdiction('EU-FR') model gates at 15; @Jurisdiction('EU-FR',
'EU-DE') at 16; add IN and it is 18. A row with no age (null birthday) isbeforeCreate / beforeUpdate hook thegetModelMetadata(name).hooks) or with options (Model.options.hooks).retention a fieldDataRetentionPolicy for that model,purge() honours it. Nothing runs on its own — there is no scheduler in theimport { complianceOf, complianceSheet, renderComplianceSheet } from 'ts-prorm-orm';
complianceOf(Patient); // { options, resolved, findings } — or null
complianceSheet(Patient); // the structured sheet — see Evidence
renderComplianceSheet(Patient); // the same as Markdown@Security@Jurisdiction says what may be held; @Security says@Jurisdiction('EU')
@Security({ frameworks: [SecurityFramework.GDPR], encrypt: ['notes'], mask: { email: 'email' } })
@Table({ tableName: 'patients' })
class Patient extends Model { /* … */ }@Personal('credential', { protected: true }) should also beencrypt or hashed by your own hook — protected: true is your statement