@octabits-io/data-subject

A person asks what you hold about them, or asks you to erase it. What you hold is spread over tables only your application knows, so this package owns none of the queries. It owns the protocol around them: a source per category of data that collects the person’s rows (Art. 15 access, Art. 20 portability), an erasure step per source that anonymises, deletes, or retains on a stated legal basis (Art. 17), a coverage check that fails the build when a source has no step, an export bundle with a cover sheet — the categories and their counts, the withholdings, and the Art. 15(1) statements the controller provides (disclosures: purposes, recipients, retention, rights, source, automated decisions) — an erasure runner whose outcome is honest — partial whenever anything stays standing — and the request log that is your Art. 5(2) accountability evidence, as a contract with an in-memory reference, a plain-SQL store over a structural executor, and a Drizzle adapter. One dependency, @octabits-io/result.

npm i @octabits-io/data-subject          # bring the executor or the ORM you already have
import { checkErasureCoverage, collectExport, runErasure, type DataSubjectSource, type ErasureStep } from '@octabits-io/data-subject';
import { createPostgresDataSubjectRequestStore } from '@octabits-io/data-subject/postgres';

// Your context: whatever the queries need — the subject, a transaction, the key that decrypts.
interface Ctx { subject: { id: string; emailIndex: Buffer }; tx: Tx }

const sources: DataSubjectSource<Ctx>[] = [
  { key: 'profile', label: 'Customer master record', collect: ({ subject, tx }) => tx.query(sql`SELECT … WHERE id = ${subject.id}`) },
  { key: 'orders',  label: 'Orders',                 collect: ({ subject, tx }) => tx.query(sql`SELECT … WHERE customer_id = ${subject.id}`) },
  { key: 'invoices', label: 'Invoices',              collect: ({ subject, tx }) => tx.query(sql`SELECT … WHERE customer_id = ${subject.id}`) },
];
const steps: ErasureStep<Ctx>[] = [
  { key: 'profile',  mode: 'anonymize', run: ({ subject, tx }) => tx.update(sql`UPDATE customer SET name = '[erased]', email = NULL WHERE id = ${subject.id}`) },
  { key: 'orders',   mode: 'delete',    run: ({ subject, tx }) => tx.delete(sql`DELETE FROM "order" WHERE customer_id = ${subject.id}`) },
  { key: 'invoices', mode: 'retain',    retentionBasis: 'Invoices are kept ten years under § 147 AO.' },
];

// in a test beside your schema — a source the erasure forgot fails here, not in front of the authority
expect(checkErasureCoverage({ sources, steps })).toEqual([]);

// Art. 15 / Art. 20
const bundle = await collectExport({ sources, context, controller: 'Example GmbH', subject: { kind: 'customer', id, displayName } });

// Art. 17
const report = await runErasure({
  steps,
  blockers: await findBlockers(subject),                       // an open contract, an unsettled payment → refused
  inTransaction: (work) => db.transaction((tx) => work({ subject, tx })),
  afterCommit: async () => deleteAttachmentBlobs(subject),     // what cannot join the transaction; anything left → partial
});

// Art. 5(2): the log
const requests = createPostgresDataSubjectRequestStore({ db: pool, scope: { column: 'org_id', value: orgId } });
await requests.record({ subjectKind: 'customer', subjectId: id, subjectRef: emailIndexBase64, requestKind: 'erasure', ...report.value, actorId });
Subpath Deps Contents
. @octabits-io/result DataSubjectSource / ErasureStep and checkErasureCoverage; collectExport and the ExportBundle / ExportCoverSheet; runErasure and the ErasureReport; the DataSubjectRequestStore contract, createInMemoryDataSubjectRequestStore(), and the page and subject-filter semantics every store shares
./postgres @octabits-io/result (structural executor) createPostgresDataSubjectRequestStore({ db, table?, scope? }) and dataSubjectRequestDdl({ table?, scope? }) over any query(text, params) → { rows } — a pg Pool or Client, PGlite, a postgres.js wrapper
./drizzle drizzle-orm (optional peer) dataSubjectRequestColumns to spread into your pgTable beside your scope column, and createDrizzleDataSubjectRequestStore({ db, table, scope? })

A boundary lint keeps the root and ./postgres on @octabits-io/result alone and drizzle-orm confined to ./drizzle, and the whole package type-checks with no Node typings.

What the package decides, and what you do

The subject is yours. Resolving “this e-mail address” to a customer row, an owner row, or a blind index that reaches people who never had a row, is a query against your schema, so it is your code. The contexts carry whatever it produced; the log keeps only an opaque subjectRef — a blind index, encoded — that survives the erasure it records, so the same person’s next request can be matched to it. Never the identifier itself.

A category is a source. One key, one label for the cover sheet, one collect. Collectors return rows already decrypted and shaped for the person who will read them; a collector that throws fails the whole export, because a bundle with a category silently missing is worse than none.

Every source has an erasure step, and the check says so. anonymize overwrites, delete removes, retain keeps rows and states the legal basis that will appear in the outcome. checkErasureCoverage finds the source with no step, the step with no source, the retention without a basis, and the mutating step without a run. It is a pure function over your two lists — put it in a test.

The outcome cannot flatter you. runErasure refuses on blockers before any step runs. Otherwise the mutating steps run in order inside your transaction (a throw rolls it back and comes back as an error naming the step), then the work that cannot join a transaction — deleting blobs — runs after commit, never before: a rollback then leaves the attachments intact, whereas deleting first destroys them for an erasure that never happened. A retained step (with its basis as the note, or the lack of one), anything afterCommit reports as left standing, and an afterCommit that throws — the rows are gone by then, so the report stands and says what did not happen — make the outcome partial with a note saying why. completed means nothing about the person remains. A step that throws is not a report but an error value; the log’s word for it is failed, so a request that did not go through is evidence too.

The log is the evidence. One row per request: the subject’s kind, id and ref, the request’s kind, the outcome and its note, the per-source counts, who performed it, when. Scoping is the store’s construction-time concern: scope: { column, value } stamps and filters; scope: { column } alone stamps from the entry and reads across scopes, for a system-mode caller; no scope in a single-scope deployment. list is newest first, 50 a page, 200 at most, filtered by subject kind and id or ref.

Where it comes from

Extracted from a multi-tenant booking platform’s Art. 15 / Art. 17 service, in production, where the sources are Drizzle queries over encrypted columns and the steps crypto-shred access tokens, anonymise guest names to a placeholder, and retain invoices and guest registrations inside their statutory windows. That service resolves subjects by blind index, decides blockers from future bookings and unsettled payments, and deletes message attachments after commit — every one of those is the host’s code, and every one of them fits the contract above without a change to it.

From @octabits-io/data-subject@0.2.1 in the platform repository.