@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.