dpdp-erasure-cli 1.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 (155) hide show
  1. package/.env.example +55 -0
  2. package/Dockerfile +33 -0
  3. package/compliance.worker.yaml +64 -0
  4. package/package.json +41 -0
  5. package/src/constants/index.ts +1 -0
  6. package/src/errors/fail.ts +110 -0
  7. package/src/errors/index.ts +4 -0
  8. package/src/errors/inferer.ts +166 -0
  9. package/src/errors/registry.ts +122 -0
  10. package/src/errors/types.ts +65 -0
  11. package/src/errors/worker.ts +161 -0
  12. package/src/index.ts +328 -0
  13. package/src/lib/crypto/digest.ts +22 -0
  14. package/src/lib/crypto/encoding.ts +78 -0
  15. package/src/lib/crypto/index.ts +2 -0
  16. package/src/lib/index.ts +1 -0
  17. package/src/modules/bootstrap/index.ts +2 -0
  18. package/src/modules/bootstrap/integrity.ts +38 -0
  19. package/src/modules/bootstrap/preflight.ts +296 -0
  20. package/src/modules/cli/check-integrity.ts +48 -0
  21. package/src/modules/cli/dry-run.ts +90 -0
  22. package/src/modules/cli/graph.ts +87 -0
  23. package/src/modules/cli/index.ts +184 -0
  24. package/src/modules/cli/init.ts +115 -0
  25. package/src/modules/cli/inspect.ts +86 -0
  26. package/src/modules/cli/introspector.ts +117 -0
  27. package/src/modules/cli/keygen.ts +38 -0
  28. package/src/modules/cli/scan.ts +126 -0
  29. package/src/modules/cli/sign.ts +50 -0
  30. package/src/modules/cli/ui.ts +61 -0
  31. package/src/modules/cli/verify-schema.ts +31 -0
  32. package/src/modules/cli/verify.ts +85 -0
  33. package/src/modules/config/compatibility.ts +271 -0
  34. package/src/modules/config/index.ts +4 -0
  35. package/src/modules/config/reader.ts +149 -0
  36. package/src/modules/config/signature.ts +69 -0
  37. package/src/modules/config/validation.ts +658 -0
  38. package/src/modules/crypto/aes.ts +158 -0
  39. package/src/modules/crypto/envelope.ts +48 -0
  40. package/src/modules/crypto/hmac.ts +60 -0
  41. package/src/modules/crypto/index.ts +3 -0
  42. package/src/modules/db/drift.ts +36 -0
  43. package/src/modules/db/graph.ts +203 -0
  44. package/src/modules/db/index.ts +4 -0
  45. package/src/modules/db/migrations.ts +254 -0
  46. package/src/modules/db/sql-debug.ts +61 -0
  47. package/src/modules/engine/blob/index.ts +3 -0
  48. package/src/modules/engine/blob/s3.ts +455 -0
  49. package/src/modules/engine/blob/store.ts +236 -0
  50. package/src/modules/engine/blob/types.ts +44 -0
  51. package/src/modules/engine/helpers/identity.ts +47 -0
  52. package/src/modules/engine/helpers/index.ts +4 -0
  53. package/src/modules/engine/helpers/outbox.ts +118 -0
  54. package/src/modules/engine/helpers/runtime.ts +115 -0
  55. package/src/modules/engine/helpers/types.ts +61 -0
  56. package/src/modules/engine/index.ts +6 -0
  57. package/src/modules/engine/notifier/config.ts +147 -0
  58. package/src/modules/engine/notifier/dispatcher.ts +300 -0
  59. package/src/modules/engine/notifier/index.ts +3 -0
  60. package/src/modules/engine/notifier/payload.ts +51 -0
  61. package/src/modules/engine/notifier/reservation.ts +153 -0
  62. package/src/modules/engine/notifier/types.ts +38 -0
  63. package/src/modules/engine/shredder.ts +254 -0
  64. package/src/modules/engine/types.ts +146 -0
  65. package/src/modules/engine/vault/compiled-targets.ts +562 -0
  66. package/src/modules/engine/vault/context.ts +254 -0
  67. package/src/modules/engine/vault/dry-run.ts +94 -0
  68. package/src/modules/engine/vault/execution.ts +485 -0
  69. package/src/modules/engine/vault/index.ts +3 -0
  70. package/src/modules/engine/vault/purge.ts +82 -0
  71. package/src/modules/engine/vault/retention.ts +124 -0
  72. package/src/modules/engine/vault/satellite-mutation.ts +193 -0
  73. package/src/modules/engine/vault/satellite.ts +103 -0
  74. package/src/modules/engine/vault/shadow.ts +36 -0
  75. package/src/modules/engine/vault/static-plan.ts +116 -0
  76. package/src/modules/engine/vault/store.ts +34 -0
  77. package/src/modules/engine/vault/vault.ts +84 -0
  78. package/src/modules/introspector/classifier.ts +502 -0
  79. package/src/modules/introspector/dag.ts +276 -0
  80. package/src/modules/introspector/index.ts +7 -0
  81. package/src/modules/introspector/naming.ts +75 -0
  82. package/src/modules/introspector/report.ts +153 -0
  83. package/src/modules/introspector/run.ts +123 -0
  84. package/src/modules/introspector/s3-sampler.ts +227 -0
  85. package/src/modules/introspector/types.ts +131 -0
  86. package/src/modules/introspector/yaml.ts +101 -0
  87. package/src/modules/network/api/control-plane.ts +275 -0
  88. package/src/modules/network/api/index.ts +1 -0
  89. package/src/modules/network/api/validation.ts +71 -0
  90. package/src/modules/network/index.ts +4 -0
  91. package/src/modules/network/object-store/aws/client.ts +444 -0
  92. package/src/modules/network/object-store/aws/credentials.ts +271 -0
  93. package/src/modules/network/object-store/aws/index.ts +2 -0
  94. package/src/modules/network/object-store/aws/sigv4.ts +190 -0
  95. package/src/modules/network/object-store/aws/type.ts +6 -0
  96. package/src/modules/network/object-store/index.ts +1 -0
  97. package/src/modules/network/outbox/dispatcher.ts +183 -0
  98. package/src/modules/network/outbox/index.ts +3 -0
  99. package/src/modules/network/outbox/process.ts +133 -0
  100. package/src/modules/network/outbox/shared.ts +56 -0
  101. package/src/modules/network/outbox/store.ts +346 -0
  102. package/src/modules/network/outbox/types.ts +54 -0
  103. package/src/modules/network/request-signing.ts +61 -0
  104. package/src/modules/worker/index.ts +2 -0
  105. package/src/modules/worker/tasks.ts +58 -0
  106. package/src/modules/worker/types.ts +89 -0
  107. package/src/modules/worker/worker.ts +243 -0
  108. package/src/secrets/index.ts +4 -0
  109. package/src/secrets/kms/index.ts +2 -0
  110. package/src/secrets/kms/signature.ts +82 -0
  111. package/src/secrets/kms/validation.ts +64 -0
  112. package/src/secrets/reader.ts +42 -0
  113. package/src/secrets/repository/crypto.ts +89 -0
  114. package/src/secrets/repository/index.ts +2 -0
  115. package/src/secrets/repository/methods.ts +37 -0
  116. package/src/secrets/resolvers.ts +247 -0
  117. package/src/secrets/signature.ts +78 -0
  118. package/src/types/index.ts +1 -0
  119. package/src/types/types.ts +23 -0
  120. package/src/utils/identifiers.ts +48 -0
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/json.ts +35 -0
  123. package/src/utils/logger.ts +161 -0
  124. package/src/validation/zod.ts +70 -0
  125. package/tests/adversarial.test.ts +464 -0
  126. package/tests/blob-s3.test.ts +216 -0
  127. package/tests/config.test.ts +395 -0
  128. package/tests/control-plane-client.test.ts +108 -0
  129. package/tests/crypto.test.ts +106 -0
  130. package/tests/errors.test.ts +69 -0
  131. package/tests/fetch-dispatcher.test.ts +213 -0
  132. package/tests/graph.test.ts +84 -0
  133. package/tests/helpers/index.ts +101 -0
  134. package/tests/index-preflight.test.ts +168 -0
  135. package/tests/introspector-classifier.test.ts +62 -0
  136. package/tests/introspector-report.test.ts +85 -0
  137. package/tests/introspector.test.ts +394 -0
  138. package/tests/kms.test.ts +124 -0
  139. package/tests/logger.test.ts +61 -0
  140. package/tests/notifier.test.ts +303 -0
  141. package/tests/outbox.test.ts +478 -0
  142. package/tests/purge-policy.test.ts +124 -0
  143. package/tests/retention.test.ts +103 -0
  144. package/tests/s3-client.test.ts +110 -0
  145. package/tests/satellite.test.ts +119 -0
  146. package/tests/schema-compatibility.test.ts +237 -0
  147. package/tests/schema-integrity.test.ts +64 -0
  148. package/tests/shredder.test.ts +163 -0
  149. package/tests/vault.compiled-targets.test.ts +243 -0
  150. package/tests/vault.replica.test.ts +59 -0
  151. package/tests/vault.test.ts +279 -0
  152. package/tests/worker.retry.test.ts +291 -0
  153. package/tests/worker.test.ts +200 -0
  154. package/tsconfig.json +19 -0
  155. package/vitest.config.ts +13 -0
@@ -0,0 +1,562 @@
1
+ import type { CompiledExecutionTargetInput, MutationRule } from "@modules/config";
2
+ import type { Tsql } from "@/types";
3
+ import { assertIdentifier, quoteIdentifier, quoteQualifiedIdentifier } from "@/utils";
4
+ import { fail } from "@/errors";
5
+ import { yieldWorkerEventLoop, type SatelliteMutationResult } from "./satellite-mutation";
6
+ import { computeMutationValue } from "./context";
7
+
8
+ const DEFAULT_COMPILED_TARGET_BATCH_SIZE = 1000;
9
+
10
+ interface QualifiedTarget {
11
+ schema: string;
12
+ table: string;
13
+ }
14
+
15
+ interface ParsedCompiledTarget {
16
+ key: string;
17
+ schema: string;
18
+ table: string;
19
+ parentKey: string | null;
20
+ parentColumns: string[];
21
+ childColumns: string[];
22
+ primaryKeyColumns: string[];
23
+ action?: "redact" | "hard_delete";
24
+ mutationRules: Record<string, MutationRule>;
25
+ depth: number;
26
+ }
27
+
28
+ interface CompiledTargetRow {
29
+ row_ctid: string;
30
+ row_key: string;
31
+ [column: string]: unknown;
32
+ }
33
+
34
+ interface BulkMutationRow {
35
+ rowCtid: string;
36
+ rowKey: string;
37
+ mutationValues: Record<string, string | null>;
38
+ }
39
+
40
+ function targetKey(target: QualifiedTarget): string {
41
+ return `${target.schema}.${target.table}`;
42
+ }
43
+
44
+ function parseQualifiedTable(value: string, defaultSchema: string): QualifiedTarget {
45
+ const parts = value.split(".");
46
+ if (parts.length === 1) {
47
+ return {
48
+ schema: defaultSchema,
49
+ table: assertIdentifier(parts[0]!, "compiled DAG target table"),
50
+ };
51
+ }
52
+
53
+ if (parts.length === 2) {
54
+ return {
55
+ schema: assertIdentifier(parts[0]!, "compiled DAG target schema"),
56
+ table: assertIdentifier(parts[1]!, "compiled DAG target table"),
57
+ };
58
+ }
59
+
60
+ fail({
61
+ code: "COMPILED_DAG_TABLE_INVALID",
62
+ title: "Invalid compiled DAG table",
63
+ detail: `Invalid compiled DAG table reference "${value}". Expected table or schema.table.`,
64
+ category: "configuration",
65
+ retryable: false,
66
+ fatal: true,
67
+ });
68
+ }
69
+
70
+ function parseQualifiedColumn(fragment: string): { table?: string; column: string } {
71
+ const cleaned = fragment.trim().replace(/"/g, "");
72
+ const parts = cleaned.split(".").map((part) => part.trim()).filter(Boolean);
73
+ const column = assertIdentifier(parts.at(-1) ?? "", "compiled DAG join column");
74
+ const table = parts.length >= 2 ? assertIdentifier(parts.at(-2)!, "compiled DAG join table") : undefined;
75
+ return { table, column };
76
+ }
77
+
78
+ function parseRowKeyValues(rowKey: string, expectedLength: number, targetKey: string): string[] {
79
+ try {
80
+ const parsed = JSON.parse(rowKey);
81
+ if (!Array.isArray(parsed) || parsed.length !== expectedLength || parsed.some((value) => typeof value !== "string")) {
82
+ throw new Error("invalid row key shape");
83
+ }
84
+ return parsed;
85
+ } catch (error) {
86
+ fail({
87
+ code: "COMPILED_DAG_ROW_KEY_INVALID",
88
+ title: "Compiled DAG row key invalid",
89
+ detail: `Compiled target ${targetKey} produced an invalid row identity.`,
90
+ category: "integrity",
91
+ retryable: false,
92
+ fatal: true,
93
+ context: { cause: error instanceof Error ? error.message : String(error) },
94
+ });
95
+ }
96
+ }
97
+
98
+ function resolveJoinColumns(
99
+ target: CompiledExecutionTargetInput,
100
+ parent: QualifiedTarget,
101
+ child: QualifiedTarget
102
+ ): { parentColumns: string[]; childColumns: string[] } {
103
+ const parentColumns = target.parent_columns ?? [];
104
+ const childColumns = target.child_columns ?? [];
105
+ if (parentColumns.length > 0 || childColumns.length > 0) {
106
+ if (parentColumns.length !== childColumns.length || parentColumns.length === 0) {
107
+ fail({
108
+ code: "COMPILED_DAG_JOIN_COLUMNS_INVALID",
109
+ title: "Invalid compiled DAG join columns",
110
+ detail: `Compiled target ${target.table} must declare matching parent_columns and child_columns.`,
111
+ category: "configuration",
112
+ retryable: false,
113
+ fatal: true,
114
+ });
115
+ }
116
+
117
+ return {
118
+ parentColumns: parentColumns.map((column) => assertIdentifier(column, "compiled DAG parent column")),
119
+ childColumns: childColumns.map((column) => assertIdentifier(column, "compiled DAG child column")),
120
+ };
121
+ }
122
+
123
+ const join = target.join ?? target.fk_condition;
124
+ if (!join || !join.includes("=")) {
125
+ fail({
126
+ code: "COMPILED_DAG_JOIN_MISSING",
127
+ title: "Compiled DAG join missing",
128
+ detail: `Compiled target ${target.table} must declare parent_columns/child_columns or a simple equality join.`,
129
+ category: "configuration",
130
+ retryable: false,
131
+ fatal: true,
132
+ });
133
+ }
134
+
135
+ const [leftRaw, rightRaw] = join.split("=", 2);
136
+ const left = parseQualifiedColumn(leftRaw!);
137
+ const right = parseQualifiedColumn(rightRaw!);
138
+
139
+ if (left.table === parent.table && right.table === child.table) {
140
+ return { parentColumns: [left.column], childColumns: [right.column] };
141
+ }
142
+ if (left.table === child.table && right.table === parent.table) {
143
+ return { parentColumns: [right.column], childColumns: [left.column] };
144
+ }
145
+
146
+ return { parentColumns: [left.column], childColumns: [right.column] };
147
+ }
148
+
149
+ function buildChain(target: ParsedCompiledTarget, byKey: Map<string, ParsedCompiledTarget>): ParsedCompiledTarget[] {
150
+ const chain: ParsedCompiledTarget[] = [];
151
+ let cursor: ParsedCompiledTarget | undefined = target;
152
+ while (cursor) {
153
+ chain.push(cursor);
154
+ cursor = cursor.parentKey ? byKey.get(cursor.parentKey) : undefined;
155
+ }
156
+ return chain;
157
+ }
158
+
159
+ function buildFromClause(chain: readonly ParsedCompiledTarget[]): string {
160
+ const [target] = chain;
161
+ if (!target) {
162
+ fail({
163
+ code: "COMPILED_DAG_CHAIN_EMPTY",
164
+ title: "Compiled DAG chain empty",
165
+ detail: "Compiled DAG target chain unexpectedly resolved to zero tables.",
166
+ category: "configuration",
167
+ retryable: false,
168
+ fatal: true,
169
+ });
170
+ }
171
+
172
+ const clauses = [
173
+ `FROM ${quoteQualifiedIdentifier(target.schema, target.table)} AS t0`,
174
+ ];
175
+
176
+ for (let index = 0; index < chain.length - 1; index += 1) {
177
+ const child = chain[index]!;
178
+ const parent = chain[index + 1]!;
179
+ const conditions = child.childColumns.map((childColumn, columnIndex) => {
180
+ const parentColumn = child.parentColumns[columnIndex]!;
181
+ return `t${index + 1}.${quoteIdentifier(parentColumn)} = t${index}.${quoteIdentifier(childColumn)}`;
182
+ });
183
+
184
+ clauses.push(
185
+ `JOIN ${quoteQualifiedIdentifier(parent.schema, parent.table)} AS t${index + 1} ON ${conditions.join(" AND ")}`
186
+ );
187
+ }
188
+
189
+ return clauses.join("\n");
190
+ }
191
+
192
+ function buildRowKeyExpression(alias: string, primaryKeyColumns: readonly string[]): string {
193
+ return `jsonb_build_array(${primaryKeyColumns
194
+ .map((column) => `${alias}.${quoteIdentifier(column)}::text`)
195
+ .join(", ")})::text`;
196
+ }
197
+
198
+ function parseCompiledTargets(
199
+ appSchema: string,
200
+ rootTable: string,
201
+ targets: readonly CompiledExecutionTargetInput[]
202
+ ): Map<string, ParsedCompiledTarget> {
203
+ const rootKey = targetKey({ schema: appSchema, table: rootTable });
204
+ const parsed = new Map<string, ParsedCompiledTarget>();
205
+
206
+ parsed.set(rootKey, {
207
+ key: rootKey,
208
+ schema: appSchema,
209
+ table: rootTable,
210
+ parentKey: null,
211
+ parentColumns: [],
212
+ childColumns: [],
213
+ primaryKeyColumns: [],
214
+ mutationRules: {},
215
+ depth: 0,
216
+ });
217
+
218
+ for (const target of targets) {
219
+ const child = parseQualifiedTable(target.table, appSchema);
220
+ const key = targetKey(child);
221
+ const parent = target.parent ? parseQualifiedTable(target.parent, appSchema) : null;
222
+ const columns = parent ? resolveJoinColumns(target, parent, child) : { parentColumns: [], childColumns: [] };
223
+ const mutationRules = target.mutation_rules ?? {};
224
+ const primaryKeyColumns = (target.primary_key_columns ?? ["id"]).map((column) =>
225
+ assertIdentifier(column, "compiled DAG primary key column")
226
+ );
227
+
228
+ const piiColumns = target.pii_columns ?? [];
229
+ if (key !== rootKey && piiColumns.length > 0 && !target.action) {
230
+ fail({
231
+ code: "COMPILED_DAG_ACTION_MISSING",
232
+ title: "Compiled DAG mutation action missing",
233
+ detail: `Compiled target ${key} contains PII columns but has no mutation action.`,
234
+ category: "configuration",
235
+ retryable: false,
236
+ fatal: true,
237
+ });
238
+ }
239
+
240
+ if (target.action === "redact" && Object.keys(mutationRules).length === 0) {
241
+ fail({
242
+ code: "COMPILED_DAG_MUTATION_RULES_MISSING",
243
+ title: "Compiled DAG mutation rules missing",
244
+ detail: `Compiled target ${key} declares redact action but no mutation_rules.`,
245
+ category: "configuration",
246
+ retryable: false,
247
+ fatal: true,
248
+ });
249
+ }
250
+
251
+ parsed.set(key, {
252
+ key,
253
+ schema: child.schema,
254
+ table: child.table,
255
+ parentKey: parent ? targetKey(parent) : null,
256
+ parentColumns: columns.parentColumns,
257
+ childColumns: columns.childColumns,
258
+ primaryKeyColumns,
259
+ action: target.action,
260
+ mutationRules,
261
+ depth: 0,
262
+ });
263
+ }
264
+
265
+ for (const target of parsed.values()) {
266
+ const visited = new Set<string>();
267
+ let depth = 0;
268
+ let cursor: ParsedCompiledTarget | undefined = target;
269
+ while (cursor?.parentKey) {
270
+ if (visited.has(cursor.key)) {
271
+ fail({
272
+ code: "COMPILED_DAG_CYCLE",
273
+ title: "Compiled DAG cycle detected",
274
+ detail: `Compiled DAG target ${target.key} forms a parent cycle.`,
275
+ category: "configuration",
276
+ retryable: false,
277
+ fatal: true,
278
+ });
279
+ }
280
+ visited.add(cursor.key);
281
+ depth += 1;
282
+ cursor = parsed.get(cursor.parentKey);
283
+ if (!cursor) {
284
+ fail({
285
+ code: "COMPILED_DAG_PARENT_MISSING",
286
+ title: "Compiled DAG parent missing",
287
+ detail: `Compiled DAG target ${target.key} references missing parent ${target.parentKey}.`,
288
+ category: "configuration",
289
+ retryable: false,
290
+ fatal: true,
291
+ });
292
+ }
293
+ }
294
+ target.depth = depth;
295
+ }
296
+
297
+ return parsed;
298
+ }
299
+
300
+ function buildValuesPlaceholders(
301
+ rowCount: number,
302
+ columnCount: number,
303
+ casts: Partial<Record<number, string>> = {}
304
+ ): string {
305
+ return Array.from({ length: rowCount }, (_, rowIndex) => {
306
+ const offset = rowIndex * columnCount;
307
+ const placeholders = Array.from({ length: columnCount }, (__, columnIndex) => {
308
+ const cast = casts[columnIndex] ?? "";
309
+ return `$${offset + columnIndex + 1}${cast}`;
310
+ });
311
+ return `(${placeholders.join(", ")})`;
312
+ }).join(", ");
313
+ }
314
+
315
+ async function markProcessedRows(
316
+ tx: Tsql,
317
+ targetKeyValue: string,
318
+ rows: readonly { rowKey: string }[]
319
+ ): Promise<void> {
320
+ if (rows.length === 0) {
321
+ return;
322
+ }
323
+
324
+ const columnCount = 2;
325
+ const values = buildValuesPlaceholders(rows.length, columnCount);
326
+ const parameters = rows.flatMap((row) => [targetKeyValue, row.rowKey]);
327
+
328
+ await tx.unsafe(
329
+ `
330
+ INSERT INTO pg_temp.compliance_compiled_target_rows (target_key, row_key)
331
+ VALUES ${values}
332
+ ON CONFLICT DO NOTHING
333
+ `,
334
+ parameters
335
+ );
336
+ }
337
+
338
+ async function executeBulkHardDelete(
339
+ tx: Tsql,
340
+ target: ParsedCompiledTarget,
341
+ rows: readonly CompiledTargetRow[]
342
+ ): Promise<void> {
343
+ if (rows.length === 0) {
344
+ return;
345
+ }
346
+
347
+ const values = buildValuesPlaceholders(rows.length, 1, { 0: "::tid" });
348
+ const parameters = rows.map((row) => row.row_ctid);
349
+
350
+ await tx.unsafe(
351
+ `
352
+ DELETE FROM ${quoteQualifiedIdentifier(target.schema, target.table)} AS target
353
+ USING (VALUES ${values}) AS source(row_ctid)
354
+ WHERE target.ctid = source.row_ctid
355
+ `,
356
+ parameters
357
+ );
358
+ await markProcessedRows(
359
+ tx,
360
+ target.key,
361
+ rows.map((row) => ({ rowKey: row.row_key }))
362
+ );
363
+ }
364
+
365
+ async function executeBulkRedact(
366
+ tx: Tsql,
367
+ target: ParsedCompiledTarget,
368
+ rows: readonly BulkMutationRow[],
369
+ valueMutationColumns: readonly string[],
370
+ nullifyColumns: readonly string[]
371
+ ): Promise<void> {
372
+ if (rows.length === 0 || (valueMutationColumns.length === 0 && nullifyColumns.length === 0)) {
373
+ return;
374
+ }
375
+
376
+ const sourceColumns = ["row_ctid", "row_key", ...valueMutationColumns];
377
+ const columnCount = sourceColumns.length;
378
+ const values = buildValuesPlaceholders(rows.length, columnCount, { 0: "::tid" });
379
+ const parameters = rows.flatMap((row) => [
380
+ row.rowCtid,
381
+ row.rowKey,
382
+ ...valueMutationColumns.map((column) => row.mutationValues[column] ?? null),
383
+ ]);
384
+ const setClause = [
385
+ ...valueMutationColumns.map((column) => `${quoteIdentifier(column)} = source.${quoteIdentifier(column)}`),
386
+ ...nullifyColumns.map((column) => `${quoteIdentifier(column)} = NULL`),
387
+ ].join(", ");
388
+ const sourceAlias = sourceColumns.map((column) => quoteIdentifier(column)).join(", ");
389
+
390
+ await tx.unsafe(
391
+ `
392
+ UPDATE ${quoteQualifiedIdentifier(target.schema, target.table)} AS target
393
+ SET ${setClause}
394
+ FROM (VALUES ${values}) AS source(${sourceAlias})
395
+ WHERE target.ctid = source.row_ctid
396
+ `,
397
+ parameters
398
+ );
399
+ await markProcessedRows(tx, target.key, rows);
400
+ }
401
+
402
+ /**
403
+ * Executes DPO-attested compiled DAG mutations without live recursive graph traversal.
404
+ *
405
+ * Each target is addressed through the precompiled parent/child join chain back to the locked
406
+ * root row. The function selects bounded batches, mutates by transaction-local `ctid`, and
407
+ * yields between batches so worker heartbeats and outbox dispatch are not starved.
408
+ *
409
+ * @param tx - Active repeatable-read transaction.
410
+ * @param appSchema - Client application schema.
411
+ * @param rootTable - Root table name.
412
+ * @param rootIdColumn - Root identifier column.
413
+ * @param subjectId - Locked root subject id.
414
+ * @param compiledTargets - DPO-attested static execution targets.
415
+ * @param hmacKey - Pre-imported worker HMAC key.
416
+ * @param tenantId - Optional tenant discriminator applied to the root table.
417
+ * @returns Mutation summaries for compiled targets that declare an action.
418
+ */
419
+ export async function mutateCompiledTargets(
420
+ tx: Tsql,
421
+ appSchema: string,
422
+ rootTable: string,
423
+ rootIdColumn: string,
424
+ subjectId: string | number,
425
+ compiledTargets: readonly CompiledExecutionTargetInput[],
426
+ hmacKey: CryptoKey,
427
+ tenantId?: string
428
+ ): Promise<SatelliteMutationResult[]> {
429
+ if (compiledTargets.length === 0) {
430
+ return [];
431
+ }
432
+ const byKey = parseCompiledTargets(appSchema, rootTable, compiledTargets);
433
+ const rootKey = targetKey({ schema: appSchema, table: rootTable });
434
+ const executableTargets = Array.from(byKey.values())
435
+ .filter((target) => target.key !== rootKey && target.action)
436
+ .sort((left, right) => right.depth - left.depth || left.key.localeCompare(right.key));
437
+ if (executableTargets.length > 0) {
438
+ await tx.unsafe(`
439
+ CREATE TEMP TABLE IF NOT EXISTS pg_temp.compliance_compiled_target_rows (
440
+ target_key TEXT NOT NULL,
441
+ row_key TEXT NOT NULL,
442
+ PRIMARY KEY (target_key, row_key)
443
+ ) ON COMMIT DROP
444
+ `);
445
+ }
446
+
447
+ const results: SatelliteMutationResult[] = [];
448
+ for (const target of executableTargets) {
449
+ const action = target.action;
450
+ if (!action) {
451
+ continue;
452
+ }
453
+
454
+ const chain = buildChain(target, byKey);
455
+ const rootAlias = `t${chain.length - 1}`;
456
+ const fromClause = buildFromClause(chain);
457
+ const mutationColumns = Object.keys(target.mutationRules);
458
+ const nullifyColumns = mutationColumns.filter((column) => target.mutationRules[column] === "NULLIFY");
459
+ const valueMutationColumns = mutationColumns.filter((column) => target.mutationRules[column] !== "NULLIFY");
460
+ if (target.primaryKeyColumns.length === 0) {
461
+ fail({
462
+ code: "COMPILED_DAG_PRIMARY_KEY_MISSING",
463
+ title: "Compiled DAG primary key missing",
464
+ detail: `Compiled target ${target.key} must define at least one primary_key_columns entry for bounded mutation.`,
465
+ category: "configuration",
466
+ retryable: false,
467
+ fatal: true,
468
+ });
469
+ }
470
+ const rowKeyExpression = buildRowKeyExpression("t0", target.primaryKeyColumns);
471
+
472
+ console.log(`[Compiled Targets] Validating target columns for ${target.schema}.${target.table}...`);
473
+ const colCheck = await tx.unsafe(`SELECT * FROM ${quoteQualifiedIdentifier(target.schema, target.table)} LIMIT 0`);
474
+ console.log(`[Compiled Targets] Columns for ${target.schema}.${target.table}:`, (colCheck.columns ?? []).map((c) => c.name));
475
+ const existingCols = new Set((colCheck.columns ?? []).map((c) => c.name));
476
+ for (const column of mutationColumns) {
477
+ if (!existingCols.has(column)) {
478
+ console.warn(`[Compiled Targets] Target column "${column}" missing from ${target.schema}.${target.table}!`);
479
+ fail({
480
+ code: "COMPILED_DAG_COLUMN_MISSING",
481
+ title: "Target column missing from database schema",
482
+ detail: `Column "${column}" does not exist in target table "${target.schema}.${target.table}".`,
483
+ category: "database",
484
+ retryable: false,
485
+ fatal: false,
486
+ });
487
+ }
488
+ }
489
+ console.log(`[Compiled Targets] Target validation passed for ${target.schema}.${target.table}`);
490
+
491
+ let affectedRows = 0;
492
+
493
+ console.log(`[Compiled Targets] Starting batch mutation loop for ${target.schema}.${target.table}`);
494
+ while (true) {
495
+ const selectColumns = mutationColumns.length > 0
496
+ ? `, ${mutationColumns.map((column) => `t0.${quoteIdentifier(column)} AS ${quoteIdentifier(column)}`).join(", ")}`
497
+ : "";
498
+ const tenantFilter = tenantId ? ` AND ${rootAlias}.${quoteIdentifier("tenant_id")} = $2` : "";
499
+ const targetKeyParameter = tenantId ? "$3" : "$2";
500
+ const sqlText = `
501
+ SELECT t0.ctid::text AS row_ctid, ${rowKeyExpression} AS row_key${selectColumns}
502
+ ${fromClause}
503
+ WHERE ${rootAlias}.${quoteIdentifier(rootIdColumn)} = $1${tenantFilter}
504
+ AND NOT EXISTS (
505
+ SELECT 1
506
+ FROM pg_temp.compliance_compiled_target_rows AS processed
507
+ WHERE processed.target_key = ${targetKeyParameter}
508
+ AND processed.row_key = ${rowKeyExpression}
509
+ )
510
+ LIMIT ${DEFAULT_COMPILED_TARGET_BATCH_SIZE}
511
+ FOR UPDATE OF t0 SKIP LOCKED
512
+ `;
513
+ const parameters = tenantId ? [subjectId, tenantId, target.key] : [subjectId, target.key];
514
+ const rows = await tx.unsafe<CompiledTargetRow[]>(sqlText, parameters);
515
+
516
+ if (rows.length === 0) {
517
+ break;
518
+ }
519
+
520
+ if (action === "hard_delete") {
521
+ for (const row of rows) {
522
+ parseRowKeyValues(row.row_key, target.primaryKeyColumns.length, target.key);
523
+ }
524
+ await executeBulkHardDelete(tx, target, rows);
525
+ } else {
526
+ const mutationRows: BulkMutationRow[] = [];
527
+ for (const row of rows) {
528
+ const mutationValues: Record<string, string | null> = {};
529
+ for (const [column, mutation] of Object.entries(target.mutationRules)) {
530
+ mutationValues[column] = await computeMutationValue(
531
+ mutation,
532
+ row[column],
533
+ appSchema,
534
+ target.table,
535
+ column,
536
+ hmacKey
537
+ );
538
+ }
539
+
540
+ parseRowKeyValues(row.row_key, target.primaryKeyColumns.length, target.key);
541
+ mutationRows.push({
542
+ rowCtid: row.row_ctid,
543
+ rowKey: row.row_key,
544
+ mutationValues,
545
+ });
546
+ }
547
+ await executeBulkRedact(tx, target, mutationRows, valueMutationColumns, nullifyColumns);
548
+ }
549
+
550
+ affectedRows += rows.length;
551
+ await yieldWorkerEventLoop();
552
+ }
553
+
554
+ results.push({
555
+ table: `${target.schema}.${target.table}`,
556
+ action,
557
+ affectedRows,
558
+ });
559
+ }
560
+
561
+ return results;
562
+ }