@ultimat3/jobs 29.3.2 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CLAUDE.md +68 -89
  2. package/README.md +304 -10
  3. package/package.json +18 -6
  4. package/src/backfill-definition.ts +184 -0
  5. package/src/backfill-errors.ts +18 -2
  6. package/src/backfill-pass.ts +43 -40
  7. package/src/backfill-pending.ts +40 -17
  8. package/src/backfill-reader.ts +94 -0
  9. package/src/backfill-registry.ts +5 -0
  10. package/src/backfill-scope.ts +1 -2
  11. package/src/backfill-source.ts +113 -0
  12. package/src/backfill.ts +54 -135
  13. package/src/consumer-errors.ts +42 -0
  14. package/src/consumer-guard.ts +19 -0
  15. package/src/consumer-loop.ts +171 -0
  16. package/src/consumer-registry.ts +103 -0
  17. package/src/consumer-types.ts +81 -0
  18. package/src/consumer.ts +147 -0
  19. package/src/driver-memory-operator.ts +25 -2
  20. package/src/driver-memory.ts +35 -1
  21. package/src/driver-pg-claim-sql.ts +128 -0
  22. package/src/driver-pg-ddl.ts +11 -2
  23. package/src/driver-pg-demand-sql.ts +27 -0
  24. package/src/driver-pg-enqueue-many.ts +153 -0
  25. package/src/driver-pg-event-wake-sql.ts +51 -0
  26. package/src/driver-pg-operator-sql.ts +34 -6
  27. package/src/driver-pg-operator.ts +36 -2
  28. package/src/driver-pg-outbox-sql.ts +34 -0
  29. package/src/driver-pg-sql.ts +24 -91
  30. package/src/driver-pg.ts +17 -1
  31. package/src/driver-redis-refusal.ts +25 -0
  32. package/src/driver-redis-scripts.ts +79 -30
  33. package/src/driver-redis.ts +70 -42
  34. package/src/driver.ts +38 -1
  35. package/src/enqueue-record.ts +137 -0
  36. package/src/error-codes.ts +233 -0
  37. package/src/errors-concurrency.ts +2 -2
  38. package/src/errors-declaration.ts +15 -2
  39. package/src/errors-operator.ts +1 -1
  40. package/src/errors-requeue.ts +1 -1
  41. package/src/errors-saga.ts +87 -0
  42. package/src/errors-steps.ts +35 -0
  43. package/src/errors-tenant.ts +1 -1
  44. package/src/errors.ts +25 -165
  45. package/src/events-pg.ts +20 -8
  46. package/src/events.ts +14 -3
  47. package/src/execute-ctx.ts +51 -0
  48. package/src/execute-unwind.ts +191 -0
  49. package/src/execute.ts +50 -51
  50. package/src/export-errors.ts +1 -1
  51. package/src/failure-row.ts +43 -0
  52. package/src/import-errors.ts +102 -0
  53. package/src/import-format.ts +232 -0
  54. package/src/import-pass.ts +255 -0
  55. package/src/import-source.ts +49 -0
  56. package/src/import.ts +175 -0
  57. package/src/index.ts +163 -7
  58. package/src/inspect-operator.ts +24 -3
  59. package/src/inspect.ts +17 -4
  60. package/src/introspection.ts +24 -1
  61. package/src/job-enqueue-many.ts +108 -0
  62. package/src/job-types.ts +229 -0
  63. package/src/job.ts +19 -208
  64. package/src/metrics.ts +10 -0
  65. package/src/outbox-pg.ts +35 -0
  66. package/src/outbox-relay.ts +58 -19
  67. package/src/outbox.ts +65 -73
  68. package/src/purge-entities.ts +92 -0
  69. package/src/purge.ts +10 -2
  70. package/src/queue-wake.ts +1 -1
  71. package/src/refresh-view.ts +76 -0
  72. package/src/retry-classification.ts +27 -8
  73. package/src/retry.ts +26 -0
  74. package/src/rollup-errors.ts +27 -0
  75. package/src/rollup.ts +190 -0
  76. package/src/run-deadline.ts +11 -7
  77. package/src/scheduler.ts +2 -1
  78. package/src/settled.ts +56 -15
  79. package/src/step-resolve.ts +37 -0
  80. package/src/steps-api.ts +111 -0
  81. package/src/steps-core.ts +26 -0
  82. package/src/steps-output.ts +142 -0
  83. package/src/steps-store.ts +68 -0
  84. package/src/steps-suspension.ts +51 -0
  85. package/src/steps-timeout.ts +10 -5
  86. package/src/steps-unwind.ts +94 -0
  87. package/src/steps-wait.ts +163 -0
  88. package/src/steps.ts +174 -210
  89. package/src/subject-erase-pass.ts +134 -0
  90. package/src/subject-errors.ts +48 -0
  91. package/src/subject-export-pass.ts +228 -0
  92. package/src/subject-jobs.ts +121 -0
  93. package/src/subject-plan.ts +287 -0
  94. package/src/subject-select.ts +106 -0
  95. package/src/subject.ts +119 -0
  96. package/src/tenant.ts +69 -1
  97. package/src/type-pins.ts +90 -0
  98. package/src/webhook-errors.ts +1 -1
  99. package/src/webhook-receiver-errors.ts +59 -0
  100. package/src/webhook-receiver-route.ts +127 -0
  101. package/src/webhook-receiver-types.ts +97 -0
  102. package/src/webhook-receiver.ts +132 -0
  103. package/src/webhook-target.ts +20 -43
  104. package/src/webhook.ts +7 -4
  105. package/src/worker-queue-demand.ts +57 -0
  106. package/src/worker-queue-depth.ts +15 -3
  107. package/src/worker-registry.ts +4 -1
  108. package/src/worker-types.ts +2 -0
  109. package/src/worker.ts +2 -2
@@ -0,0 +1,228 @@
1
+ // One `subjectExport()` pass: every table the subject reaches and every target, one object per
2
+ // page (`<prefix>/<table>/part-NNNNN.<ext>`), then `<prefix>/index.json` naming each table, its
3
+ // count and its treatment. Sealed columns never leave; a page is a step, so a killed run resumes.
4
+
5
+ import { assert } from '@ultimat3/core';
6
+ import { sealedFields } from '@ultimat3/entity';
7
+ import { JobAbortedError } from './errors';
8
+ import { DEFAULT_EXPORT_MAX_PART_BYTES } from './export';
9
+ import { ExportPartTooLargeError } from './export-errors';
10
+ import type { ExportFormat, ExportRecord, ExportValue } from './export-format';
11
+ import { encodeExportPage } from './export-format';
12
+ import type { ExportSink } from './export-sink';
13
+ import { exportPartKey } from './export-sink';
14
+ import type { StepApi } from './steps-api';
15
+ import type { SubjectTargetEntry, SubjectTreatment } from './subject';
16
+ import { isRetainedTarget, targetOf } from './subject';
17
+ import type { SubjectReader, SubjectRow } from './subject-select';
18
+ import { rowIdentity } from './subject-select';
19
+
20
+ /** One line of the index: what the subject's data in one table is, and what the erase does to it. */
21
+ export interface SubjectIndexEntry {
22
+ readonly name: string;
23
+ readonly kind: 'table' | 'target';
24
+ readonly treatment: 'delete' | 'anonymize' | 'retain' | 'erase';
25
+ /** The declared `retain` reason, printed so the person reads why a row is kept. */
26
+ readonly reason?: string;
27
+ readonly rows: number;
28
+ readonly parts: number;
29
+ }
30
+
31
+ export interface SubjectExportReport {
32
+ readonly name: string;
33
+ readonly prefix: string;
34
+ readonly indexKey: string;
35
+ readonly tables: readonly SubjectIndexEntry[];
36
+ }
37
+
38
+ export interface SubjectExportRun {
39
+ readonly name: string;
40
+ readonly runId: string;
41
+ readonly prefix: string;
42
+ readonly format: ExportFormat;
43
+ readonly sink: ExportSink;
44
+ readonly nowMs: () => number;
45
+ }
46
+
47
+ const hex = (bytes: Uint8Array): string =>
48
+ Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
49
+
50
+ /** One stored value as a cell: an instant as ISO (UTC, zone in the string), structure as JSON. */
51
+ export function subjectCell(value: unknown): ExportValue {
52
+ if (value === null || value === undefined) return null;
53
+ if (typeof value === 'string' || typeof value === 'boolean') return value;
54
+ if (typeof value === 'number') return Number.isFinite(value) ? value : String(value);
55
+ if (typeof value === 'bigint') return value.toString();
56
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString();
57
+ if (value instanceof Uint8Array) return hex(value);
58
+ return JSON.stringify(value, (_key, inner: unknown) =>
59
+ typeof inner === 'bigint' ? inner.toString() : inner,
60
+ );
61
+ }
62
+
63
+ const treatmentName = (treatment: SubjectTreatment): SubjectIndexEntry['treatment'] =>
64
+ treatment === 'delete' ? 'delete' : 'anonymize' in treatment ? 'anonymize' : 'retain';
65
+
66
+ interface Part {
67
+ readonly rows: number;
68
+ readonly bytes: number;
69
+ }
70
+
71
+ async function writePart(
72
+ step: StepApi,
73
+ run: SubjectExportRun,
74
+ table: string,
75
+ index: number,
76
+ columns: readonly string[],
77
+ records: readonly ExportRecord[],
78
+ ): Promise<Part> {
79
+ const stepName = `export:${table}:${index}`;
80
+ return step.run(stepName, async (signal): Promise<Part> => {
81
+ const body = encodeExportPage({
82
+ subject: run.name,
83
+ format: run.format,
84
+ columns,
85
+ records,
86
+ header: index === 0,
87
+ });
88
+ if (body.byteLength > DEFAULT_EXPORT_MAX_PART_BYTES) {
89
+ throw new ExportPartTooLargeError({
90
+ export: run.name,
91
+ part: index,
92
+ bytes: body.byteLength,
93
+ maxBytes: DEFAULT_EXPORT_MAX_PART_BYTES,
94
+ });
95
+ }
96
+ if (signal.aborted) throw new JobAbortedError({ job: run.name, step: stepName });
97
+ await run.sink.put(exportPartKey(`${run.prefix}/${table}`, index, run.format), body);
98
+ return { rows: records.length, bytes: body.byteLength };
99
+ });
100
+ }
101
+
102
+ async function exportTable(
103
+ step: StepApi,
104
+ run: SubjectExportRun,
105
+ reader: SubjectReader,
106
+ entity: string,
107
+ ) {
108
+ const core = reader.core(entity);
109
+ const sealed = new Set(sealedFields(core).map((field) => field.property));
110
+ const columns = core
111
+ .$describe()
112
+ .columns.map((c) => c.property)
113
+ .filter((p) => !sealed.has(p));
114
+ const seen = new Set<string>();
115
+ let rows = 0;
116
+ let parts = 0;
117
+ for await (const page of reader.pages(entity)) {
118
+ // Deduplicated before the step, so a replay rebuilds exactly the page it checkpointed.
119
+ const fresh = page.filter((row: SubjectRow) => {
120
+ const id = rowIdentity(core, row);
121
+ if (seen.has(id)) return false;
122
+ seen.add(id);
123
+ return true;
124
+ });
125
+ if (fresh.length === 0) continue;
126
+ const records = fresh.map((row) =>
127
+ Object.fromEntries(columns.map((c) => [c, subjectCell(row[c])])),
128
+ );
129
+ rows += (await writePart(step, run, entity, parts, columns, records)).rows;
130
+ parts += 1;
131
+ }
132
+ return { rows, parts };
133
+ }
134
+
135
+ async function exportTarget(
136
+ step: StepApi,
137
+ run: SubjectExportRun,
138
+ reader: SubjectReader,
139
+ entry: SubjectTargetEntry,
140
+ ) {
141
+ const target = targetOf(entry);
142
+ let cursor: string | null = null;
143
+ let rows = 0;
144
+ let parts = 0;
145
+ for (let n = 0; ; n += 1) {
146
+ const at = cursor;
147
+ const page: { rows: number; cursor: string | null } = await step.run(
148
+ `export:target:${target.name}:${n}`,
149
+ async (signal) => {
150
+ const read = await target.exportPage(
151
+ { key: reader.key, row: (await reader.rootRow()) ?? {} },
152
+ at,
153
+ reader.size,
154
+ );
155
+ if (read.rows.length === 0) return { rows: 0, cursor: null };
156
+ if (signal.aborted) throw new JobAbortedError({ job: run.name, step: target.name });
157
+ const body = encodeExportPage({
158
+ subject: run.name,
159
+ format: run.format,
160
+ columns: target.columns,
161
+ records: read.rows,
162
+ header: n === 0,
163
+ });
164
+ await run.sink.put(exportPartKey(`${run.prefix}/${target.name}`, n, run.format), body);
165
+ return { rows: read.rows.length, cursor: read.cursor };
166
+ },
167
+ );
168
+ rows += page.rows;
169
+ if (page.rows > 0) parts += 1;
170
+ cursor = page.cursor;
171
+ if (cursor === null) return { rows, parts };
172
+ }
173
+ }
174
+
175
+ export async function subjectExportPass(
176
+ step: StepApi,
177
+ run: SubjectExportRun,
178
+ reader: SubjectReader,
179
+ targets: readonly SubjectTargetEntry[],
180
+ ): Promise<SubjectExportReport> {
181
+ assert(
182
+ run.prefix.length > 0 && !run.prefix.startsWith('/') && !run.prefix.includes('..'),
183
+ `subject export "${run.name}" prefix() answered "${run.prefix}", which is not a key prefix`,
184
+ `prefix: (input) => \`exports/\${input.orgId}/\${input.requestId}\` # on subjectExport("${run.name}"): relative, no leading slash, no ".."`,
185
+ );
186
+ const tables: SubjectIndexEntry[] = [];
187
+ // The subject first: the index reads as the person, then what hangs off them.
188
+ for (const planned of [...reader.plan.steps].reverse()) {
189
+ const { rows, parts } = await exportTable(step, run, reader, planned.entity);
190
+ const treatment = planned.treatment;
191
+ tables.push({
192
+ name: planned.entity,
193
+ kind: 'table',
194
+ treatment: treatmentName(treatment),
195
+ ...(typeof treatment === 'object' && 'retain' in treatment
196
+ ? { reason: treatment.retain }
197
+ : {}),
198
+ rows,
199
+ parts,
200
+ });
201
+ }
202
+ for (const entry of targets) {
203
+ const { rows, parts } = await exportTarget(step, run, reader, entry);
204
+ tables.push({
205
+ name: targetOf(entry).name,
206
+ kind: 'target',
207
+ treatment: isRetainedTarget(entry) ? 'retain' : 'erase',
208
+ ...(isRetainedTarget(entry) ? { reason: entry.retain } : {}),
209
+ rows,
210
+ parts,
211
+ });
212
+ }
213
+ const indexKey = `${run.prefix}/index.json`;
214
+ await step.run('export:index', async (): Promise<null> => {
215
+ const index = {
216
+ export: run.name,
217
+ runId: run.runId,
218
+ subject: reader.plan.subject,
219
+ format: run.format,
220
+ tables,
221
+ // `toISOString()` is UTC and carries its zone: an instant, not a date anybody formats.
222
+ completedAt: new Date(run.nowMs()).toISOString(),
223
+ };
224
+ await run.sink.put(indexKey, new TextEncoder().encode(JSON.stringify(index)));
225
+ return null;
226
+ });
227
+ return { name: run.name, prefix: run.prefix, indexKey, tables };
228
+ }
@@ -0,0 +1,121 @@
1
+ // `subjectExport()` and `subjectErase()`: one person's data exported or erased, each a FACTORY over
2
+ // `job()`, never a ninth primitive. Both plan the subject when a run starts and refuse an untreated
3
+ // table before a row is touched. Who may call them is the app's action: no route, no MCP tool.
4
+
5
+ import type { Clock } from '@ultimat3/core';
6
+ import { systemClock } from '@ultimat3/core';
7
+ import { registeredEntities } from '@ultimat3/entity';
8
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
9
+ import type { DurationInput } from './clock';
10
+ import { nowMs } from './clock';
11
+ import type { ExportFormat } from './export-format';
12
+ import type { ExportSink } from './export-sink';
13
+ import type { JobHandle } from './job';
14
+ import { job } from './job';
15
+ import type { RetryPolicy } from './retry';
16
+ import { DEFAULT_RETRY } from './retry';
17
+ import type { StepApi } from './steps-api';
18
+ import type { Subject } from './subject';
19
+ import type { SubjectEraseReport } from './subject-erase-pass';
20
+ import { subjectErasePass } from './subject-erase-pass';
21
+ import type { SubjectExportReport } from './subject-export-pass';
22
+ import { subjectExportPass } from './subject-export-pass';
23
+ import type { SubjectPlan } from './subject-plan';
24
+ import { planSubject } from './subject-plan';
25
+ import type { SubjectReader } from './subject-select';
26
+ import { subjectReader } from './subject-select';
27
+ import type { JobTenant } from './tenant';
28
+
29
+ export interface SubjectJobDefinition<I> {
30
+ /** REQUIRED: a durable key, as on `exportRows()`. */
31
+ readonly name: string;
32
+ readonly input: StandardSchemaV1<unknown, I>;
33
+ readonly subject: Subject<I>;
34
+ /** A job per tenant: `(input) => input.orgId`. Crossing tenants is the app's own loop. */
35
+ readonly tenant: JobTenant<I>;
36
+ readonly queue?: string;
37
+ readonly retry?: RetryPolicy;
38
+ readonly timeout?: DurationInput;
39
+ }
40
+
41
+ export interface SubjectExportDefinition<I> extends SubjectJobDefinition<I> {
42
+ /** Where the parts land — a thunk, read per write, as `exportRows()` takes it. */
43
+ readonly sink: () => ExportSink;
44
+ /** The artifact's key prefix: `<prefix>/<table>/part-00000.<ext>`, `<prefix>/index.json`. */
45
+ prefix(input: I): string;
46
+ readonly format: ExportFormat;
47
+ readonly clock?: Clock;
48
+ }
49
+
50
+ /** The subject planned against every registered entity, its first refusal thrown. */
51
+ export function plannedSubject<I>(subject: Subject<I>): SubjectPlan {
52
+ const { plan, problems } = planSubject({
53
+ subject: subject.entity,
54
+ tables: subject.init.tables,
55
+ columns: subject.init.columns,
56
+ entries: registeredEntities(),
57
+ });
58
+ const [first] = problems;
59
+ if (first !== undefined) throw first;
60
+ return plan;
61
+ }
62
+
63
+ const readerFor = <I>(subject: Subject<I>, input: I): SubjectReader =>
64
+ subjectReader(subject, plannedSubject(subject), subject.init.key(input));
65
+
66
+ const common = <I>(definition: SubjectJobDefinition<I>) => ({
67
+ name: definition.name,
68
+ input: definition.input,
69
+ tenant: definition.tenant,
70
+ retry: definition.retry ?? DEFAULT_RETRY,
71
+ ...(definition.queue === undefined ? {} : { queue: definition.queue }),
72
+ ...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
73
+ });
74
+
75
+ export function subjectErase<I>(definition: SubjectJobDefinition<I>): JobHandle<I> {
76
+ const { subject } = definition;
77
+ return job<I>({
78
+ ...common(definition),
79
+ // One live erase per person: a second request while one runs is the same run.
80
+ idempotencyKey: (input) => `${definition.name}:${subject.init.key(input)}`,
81
+ run: ({ step, input }): Promise<SubjectEraseReport> =>
82
+ subjectErasePass(step, readerFor(subject, input), subject.init.targets ?? []),
83
+ });
84
+ }
85
+
86
+ export function subjectExport<I>(definition: SubjectExportDefinition<I>): JobHandle<I> {
87
+ const { subject } = definition;
88
+ const clock = definition.clock ?? systemClock;
89
+ return job<I>({
90
+ ...common(definition),
91
+ // One live pass per artifact, derived as `exportRows()` derives it.
92
+ idempotencyKey: (input) => `${definition.name}:${definition.prefix(input)}`,
93
+ run: ({ step, input, runId }): Promise<SubjectExportReport> =>
94
+ subjectExportPass(
95
+ step,
96
+ {
97
+ name: definition.name,
98
+ runId,
99
+ prefix: definition.prefix(input),
100
+ format: definition.format,
101
+ sink: { put: (key, body) => definition.sink().put(key, body) },
102
+ nowMs: () => nowMs(clock),
103
+ },
104
+ readerFor(subject, input),
105
+ subject.init.targets ?? [],
106
+ ),
107
+ });
108
+ }
109
+
110
+ /**
111
+ * The erase's table passes alone, for one key, under the CALLER's context and step runner — what
112
+ * `subjectProof` (`@ultimat3/testing`) drives on generated rows. Targets are skipped: they hold no
113
+ * entity rows the proof could search, and the job is still what an app enqueues.
114
+ */
115
+ export function subjectEraseTables<I>(
116
+ subject: Subject<I>,
117
+ key: string,
118
+ step: StepApi,
119
+ ): Promise<SubjectEraseReport> {
120
+ return subjectErasePass(step, subjectReader(subject, plannedSubject(subject), key), []);
121
+ }
@@ -0,0 +1,287 @@
1
+ // The subject's plan, pure: which tables reach the subject (a foreign-key walk from it, continued
2
+ // only through tables the erase deletes), how each one's rows are found, the order that deletes
3
+ // children before parents, and every refusal of the declaration. Registry entries in, plan out.
4
+
5
+ import type { UltimateError } from '@ultimat3/core';
6
+ import type { ColumnDescription, EntityDescription, RegistryEntry } from '@ultimat3/entity';
7
+ import type { SubjectTreatment } from './subject';
8
+ import {
9
+ SubjectCycleError,
10
+ SubjectTableUndeclaredError,
11
+ SubjectTreatmentInvalidError,
12
+ } from './subject-errors';
13
+
14
+ /** How one table's rows of the subject are found: `<table>.<property>` equal to `<parent>.<key>`. */
15
+ export interface SubjectVia {
16
+ readonly property: string;
17
+ readonly parent: string;
18
+ readonly parentKey: string;
19
+ }
20
+
21
+ export interface SubjectStep {
22
+ readonly entity: string;
23
+ readonly treatment: SubjectTreatment;
24
+ /** Empty for the subject itself, which is found by its key. */
25
+ readonly via: readonly SubjectVia[];
26
+ }
27
+
28
+ /** A key closing a loop: set to null on the rows naming a parent the erase deletes, before any. */
29
+ export interface SubjectUnlink extends SubjectVia {
30
+ readonly entity: string;
31
+ }
32
+
33
+ export interface SubjectPlan {
34
+ readonly subject: string;
35
+ /** The subject's single primary-key property. */
36
+ readonly key: string;
37
+ readonly unlink: readonly SubjectUnlink[];
38
+ /** Children first, the subject last: the order an erase runs in. */
39
+ readonly steps: readonly SubjectStep[];
40
+ }
41
+
42
+ export interface SubjectPlanInput {
43
+ readonly subject: string;
44
+ readonly tables: Readonly<Record<string, SubjectTreatment>>;
45
+ readonly columns?: Readonly<Record<string, string>> | undefined;
46
+ readonly entries: readonly RegistryEntry[];
47
+ }
48
+
49
+ interface Edge extends SubjectVia {
50
+ readonly from: string;
51
+ readonly nullable: boolean;
52
+ readonly onDelete: string | null;
53
+ }
54
+
55
+ const own = <T>(record: Readonly<Record<string, T>> | undefined, key: string): T | undefined =>
56
+ record !== undefined && Object.hasOwn(record, key) ? record[key] : undefined;
57
+
58
+ const isDelete = (treatment: SubjectTreatment | undefined): boolean => treatment === 'delete';
59
+
60
+ const nulls = (treatment: SubjectTreatment | undefined, property: string): boolean =>
61
+ typeof treatment === 'object' &&
62
+ 'anonymize' in treatment &&
63
+ Object.hasOwn(treatment.anonymize, property) &&
64
+ treatment.anonymize[property] === null;
65
+
66
+ /** The column a property names, or undefined. */
67
+ const columnOf = (d: EntityDescription, property: string): ColumnDescription | undefined =>
68
+ d.columns.find((column) => column.property === property);
69
+
70
+ /** Every key INTO each entity — declared references, then the hand-listed `columns:`. */
71
+ function inboundEdges(input: SubjectPlanInput, byName: ReadonlyMap<string, RegistryEntry>) {
72
+ const inbound = new Map<string, Edge[]>();
73
+ const push = (to: string, edge: Edge): void => {
74
+ inbound.set(to, [...(inbound.get(to) ?? []), edge]);
75
+ };
76
+ for (const entry of byName.values()) {
77
+ for (const ref of entry.references()) {
78
+ push(ref.targetEntity, {
79
+ from: entry.name,
80
+ property: ref.property,
81
+ parent: ref.targetEntity,
82
+ parentKey: ref.targetProperty,
83
+ nullable: ref.nullable,
84
+ onDelete: ref.onDelete,
85
+ });
86
+ }
87
+ }
88
+ const root = byName.get(input.subject)?.describe();
89
+ const rootKey = root?.columns.find((column) => column.primaryKey)?.property ?? 'id';
90
+ for (const [entity, property] of Object.entries(input.columns ?? {})) {
91
+ const column = byName
92
+ .get(entity)
93
+ ?.describe()
94
+ .columns.find((c) => c.property === property);
95
+ push(input.subject, {
96
+ from: entity,
97
+ property,
98
+ parent: input.subject,
99
+ parentKey: rootKey,
100
+ nullable: column?.notNull === false,
101
+ onDelete: null,
102
+ });
103
+ }
104
+ for (const edges of inbound.values()) {
105
+ edges.sort((a, b) => `${a.from}.${a.property}`.localeCompare(`${b.from}.${b.property}`));
106
+ }
107
+ return inbound;
108
+ }
109
+
110
+ /** What is wrong with one declared treatment on its own, before any reach is considered. */
111
+ function treatmentProblems(
112
+ subject: string,
113
+ table: string,
114
+ treatment: SubjectTreatment,
115
+ entry: RegistryEntry | undefined,
116
+ ): readonly UltimateError[] {
117
+ const invalid = (field: string, reason: string) =>
118
+ new SubjectTreatmentInvalidError({ subject, table, field, reason });
119
+ if (entry === undefined) return [invalid(table, 'names no registered entity')];
120
+ const d = entry.describe();
121
+ if (treatment === 'delete') {
122
+ if (d.appendOnly === true) {
123
+ return [invalid("'delete'", 'is refused: the entity is append-only')];
124
+ }
125
+ if (d.softDelete) {
126
+ return [invalid("'delete'", 'only stamps deleted_at: a soft delete keeps every value')];
127
+ }
128
+ return [];
129
+ }
130
+ if (typeof treatment !== 'object' || treatment === null) {
131
+ return [invalid('the treatment', "is none of 'delete', { anonymize } or { retain }")];
132
+ }
133
+ if ('retain' in treatment) {
134
+ const reason = treatment.retain;
135
+ return typeof reason === 'string' && reason.trim().length > 0
136
+ ? []
137
+ : [invalid('retain', 'is blank: the reason is printed in every export and is required')];
138
+ }
139
+ if (!('anonymize' in treatment)) {
140
+ return [invalid('the treatment', "is none of 'delete', { anonymize } or { retain }")];
141
+ }
142
+ if (d.appendOnly === true) return [invalid('anonymize', 'is refused: the entity is append-only')];
143
+ const named = Object.entries(treatment.anonymize);
144
+ if (named.length === 0) return [invalid('anonymize', 'names no column')];
145
+ return named.flatMap(([property, value]) => {
146
+ const column = columnOf(d, property);
147
+ if (column === undefined) return [invalid(`anonymize.${property}`, 'is not a column')];
148
+ if (column.primaryKey) return [invalid(`anonymize.${property}`, 'is the primary key')];
149
+ if (value === null && column.notNull) {
150
+ return [invalid(`anonymize.${property}`, 'is null and the column is not nullable')];
151
+ }
152
+ return [];
153
+ });
154
+ }
155
+
156
+ /** A kept row under a parent the erase deletes: it would block the delete, or be cascaded away. */
157
+ function keptUnderDeleted(
158
+ subject: string,
159
+ edge: Edge,
160
+ treatment: SubjectTreatment,
161
+ ): UltimateError | undefined {
162
+ if (isDelete(treatment) || nulls(treatment, edge.property)) return undefined;
163
+ if (edge.nullable && (edge.onDelete === 'set null' || edge.onDelete === 'set default')) {
164
+ return undefined;
165
+ }
166
+ return new SubjectTreatmentInvalidError({
167
+ subject,
168
+ table: edge.from,
169
+ field: edge.property,
170
+ instead: "'delete'",
171
+ reason:
172
+ edge.onDelete === 'cascade'
173
+ ? `keeps rows the delete of ${edge.parent} cascades away — use 'delete', or anonymize ${edge.property}: null on a nullable key`
174
+ : `keeps rows that block the delete of ${edge.parent} — use 'delete', or anonymize ${edge.property}: null on a nullable key`,
175
+ });
176
+ }
177
+
178
+ export function planSubject(input: SubjectPlanInput): {
179
+ readonly plan: SubjectPlan;
180
+ readonly problems: readonly UltimateError[];
181
+ } {
182
+ const byName = new Map(
183
+ input.entries
184
+ .filter((entry) => entry.describe().view === undefined)
185
+ .map((entry) => [entry.name, entry]),
186
+ );
187
+ const subject = input.subject;
188
+ const problems: UltimateError[] = [];
189
+ const root = byName.get(subject)?.describe();
190
+ const keys = root?.columns.filter((column) => column.primaryKey) ?? [];
191
+ const key = keys[0]?.property ?? 'id';
192
+ if (root === undefined || keys.length !== 1) {
193
+ problems.push(
194
+ new SubjectTreatmentInvalidError({
195
+ subject,
196
+ table: subject,
197
+ field: 'the subject',
198
+ reason:
199
+ root === undefined
200
+ ? 'names no registered entity'
201
+ : 'has a composite primary key: a subject is found by one key',
202
+ }),
203
+ );
204
+ }
205
+ for (const [table, treatment] of Object.entries(input.tables)) {
206
+ problems.push(...treatmentProblems(subject, table, treatment, byName.get(table)));
207
+ }
208
+ const inbound = inboundEdges(input, byName);
209
+ const treatmentOf = (entity: string) => own(input.tables, entity);
210
+ const propagates = (entity: string) => entity === subject || isDelete(treatmentOf(entity));
211
+
212
+ const state = new Map<string, 'open' | 'done'>();
213
+ const via = new Map<string, SubjectVia[]>();
214
+ const order: string[] = [];
215
+ const unlink: SubjectUnlink[] = [];
216
+ const undeclared = new Set<string>();
217
+ if (treatmentOf(subject) === undefined && root !== undefined) {
218
+ problems.push(new SubjectTableUndeclaredError({ subject, table: subject, path: [subject] }));
219
+ undeclared.add(subject);
220
+ }
221
+
222
+ const walk = (entity: string, path: readonly string[]): void => {
223
+ state.set(entity, 'open');
224
+ const deleted = isDelete(treatmentOf(entity));
225
+ for (const edge of inbound.get(entity) ?? []) {
226
+ const here = [...path, `${edge.from}.${edge.property}`];
227
+ const treatment = treatmentOf(edge.from);
228
+ if (treatment === undefined) {
229
+ if (!undeclared.has(edge.from)) {
230
+ undeclared.add(edge.from);
231
+ problems.push(new SubjectTableUndeclaredError({ subject, table: edge.from, path: here }));
232
+ }
233
+ continue;
234
+ }
235
+ if (state.get(edge.from) === 'open') {
236
+ // A back edge closes a loop: it finds no rows, and a deleted parent needs it cleared first.
237
+ if (!deleted) continue;
238
+ if (edge.nullable) unlink.push({ entity: edge.from, ...edgeVia(edge) });
239
+ else problems.push(new SubjectCycleError({ subject, table: edge.from, ...cycleOf(edge) }));
240
+ continue;
241
+ }
242
+ if (deleted) {
243
+ const kept = keptUnderDeleted(subject, edge, treatment);
244
+ if (kept !== undefined) problems.push(kept);
245
+ }
246
+ via.set(edge.from, [...(via.get(edge.from) ?? []), edgeVia(edge)]);
247
+ if (state.has(edge.from)) continue;
248
+ if (propagates(edge.from)) walk(edge.from, here);
249
+ else {
250
+ state.set(edge.from, 'done');
251
+ order.push(edge.from);
252
+ }
253
+ }
254
+ state.set(entity, 'done');
255
+ order.push(entity);
256
+ };
257
+ if (root !== undefined) walk(subject, [subject]);
258
+
259
+ for (const table of Object.keys(input.tables)) {
260
+ if (byName.has(table) && !state.has(table)) {
261
+ problems.push(
262
+ new SubjectTreatmentInvalidError({
263
+ subject,
264
+ table,
265
+ field: table,
266
+ reason: `is declared and nothing reaches it from ${subject} — remove it, or list its key column in columns:`,
267
+ }),
268
+ );
269
+ }
270
+ }
271
+ const steps = order
272
+ .filter((entity) => treatmentOf(entity) !== undefined)
273
+ .map((entity) => ({
274
+ entity,
275
+ treatment: treatmentOf(entity) as SubjectTreatment,
276
+ via: via.get(entity) ?? [],
277
+ }));
278
+ return { plan: { subject, key, unlink, steps }, problems };
279
+ }
280
+
281
+ const edgeVia = (edge: Edge): SubjectVia => ({
282
+ property: edge.property,
283
+ parent: edge.parent,
284
+ parentKey: edge.parentKey,
285
+ });
286
+
287
+ const cycleOf = (edge: Edge) => ({ column: edge.property, parent: edge.parent });