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,158 @@
1
+ import { fail } from "@/errors";
2
+
3
+
4
+ const IV_LENGTH = 12; // 96-bit IV is the industry standard for GCM.
5
+ const KEY_LENGTH = 32; // 256-bit key for AES-256.
6
+ const textEncoder = new TextEncoder();
7
+ const textDecoder = new TextDecoder();
8
+
9
+ function assertAesKeyLength(rawKey: Uint8Array) {
10
+ if (rawKey.length !== KEY_LENGTH) {
11
+ fail({
12
+ code: "CRYPTO_INVALID_KEY_LENGTH",
13
+ title: "Invalid AES key length",
14
+ detail: `Invalid key length. Expected ${KEY_LENGTH} bytes for AES-256, got ${rawKey.length} bytes.`,
15
+ category: "crypto",
16
+ retryable: false,
17
+ });
18
+ }
19
+ }
20
+
21
+ function toOwnedArrayBuffer(bytes: Uint8Array): ArrayBuffer {
22
+ const copy = new Uint8Array(bytes.byteLength);
23
+ copy.set(bytes);
24
+ return copy.buffer as ArrayBuffer;
25
+ }
26
+
27
+ async function importAesKey(rawKey: Uint8Array, usages: readonly ("encrypt" | "decrypt")[]): Promise<CryptoKey> {
28
+ assertAesKeyLength(rawKey);
29
+
30
+ const keyBytes = rawKey.slice();
31
+ try {
32
+ return await globalThis.crypto.subtle.importKey(
33
+ "raw",
34
+ toOwnedArrayBuffer(keyBytes),
35
+ "AES-GCM",
36
+ false,
37
+ [...usages]
38
+ );
39
+ } finally {
40
+ keyBytes.fill(0);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Encrypts raw bytes using AES-256-GCM.
46
+ *
47
+ * This overload exists for sensitive call sites that need direct control over plaintext buffer
48
+ * lifecycle so the caller can explicitly wipe the source bytes after encryption.
49
+ *
50
+ * @param plaintext - Raw plaintext bytes to encrypt.
51
+ * @param rawKey - 32-byte symmetric key.
52
+ * @returns Combined buffer in `IV || ciphertext+tag` format.
53
+ * @throws {WorkerError} When key length is invalid.
54
+ */
55
+ export async function encryptGCMBytes(plaintext: Uint8Array, rawKey: Uint8Array): Promise<Uint8Array> {
56
+ const key = await importAesKey(rawKey, ["encrypt"]);
57
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
58
+ const ciphertextBuffer = await globalThis.crypto.subtle.encrypt(
59
+ { name: "AES-GCM", iv },
60
+ key,
61
+ toOwnedArrayBuffer(plaintext)
62
+ );
63
+
64
+ const combined = new Uint8Array(iv.length + ciphertextBuffer.byteLength);
65
+ combined.set(iv);
66
+ combined.set(new Uint8Array(ciphertextBuffer), iv.length);
67
+
68
+ return combined;
69
+ }
70
+
71
+ /**
72
+ * Encrypts UTF-8 plaintext using AES-256-GCM.
73
+ *
74
+ * @param plaintext - Text payload to encrypt.
75
+ * @param rawKey - 32-byte symmetric key.
76
+ * @returns Combined buffer in `IV || ciphertext+tag` format.
77
+ * @throws {WorkerError} When key length is invalid.
78
+ */
79
+ export async function encryptGCM(plaintext: string, rawKey: Uint8Array): Promise<Uint8Array> {
80
+ const plaintextBytes = textEncoder.encode(plaintext);
81
+ try {
82
+ return await encryptGCMBytes(plaintextBytes, rawKey);
83
+ } finally {
84
+ plaintextBytes.fill(0);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Decrypts a buffer in `IV || ciphertext+tag` format.
90
+ *
91
+ * Returns raw bytes so high-sensitivity callers can zero the decrypted buffer immediately after
92
+ * parsing, instead of leaving plaintext in immutable JS string storage.
93
+ *
94
+ * @param combined - Combined encrypted payload produced by `encryptGCM`.
95
+ * @param rawKey - 32-byte symmetric key.
96
+ * @returns Decrypted plaintext bytes.
97
+ * @throws {WorkerError} When key/ciphertext is invalid or integrity verification fails.
98
+ */
99
+ export async function decryptGCMBytes(combined: Uint8Array, rawKey: Uint8Array): Promise<Uint8Array> {
100
+ assertAesKeyLength(rawKey);
101
+
102
+ if (combined.length < IV_LENGTH + 16) {
103
+ fail({
104
+ code: "CRYPTO_INVALID_CIPHERTEXT",
105
+ title: "Invalid ciphertext",
106
+ detail: "Invalid ciphertext. Too short to be a valid AES-GCM payload.",
107
+ category: "crypto",
108
+ retryable: false,
109
+ });
110
+ }
111
+
112
+ const crypto = globalThis.crypto;
113
+
114
+ const iv = combined.slice(0, IV_LENGTH);
115
+ const ciphertext = combined.slice(IV_LENGTH);
116
+
117
+ const key = await importAesKey(rawKey, ["decrypt"]);
118
+
119
+ let decryptBuffer: ArrayBuffer;
120
+ try {
121
+ decryptBuffer = await crypto.subtle.decrypt(
122
+ { name: "AES-GCM", iv },
123
+ key,
124
+ toOwnedArrayBuffer(ciphertext)
125
+ );
126
+ } catch (error) {
127
+ fail({
128
+ code: "CRYPTO_INTEGRITY_FAILURE",
129
+ title: "AES-GCM integrity verification failed",
130
+ detail: "Decryption failed because the ciphertext or auth tag was corrupted.",
131
+ category: "crypto",
132
+ retryable: false,
133
+ cause: error,
134
+ });
135
+ }
136
+
137
+ return new Uint8Array(decryptBuffer);
138
+ }
139
+
140
+ /**
141
+ * Decrypts a buffer in `IV || ciphertext+tag` format` and decodes it as UTF-8 text.
142
+ *
143
+ * Prefer `decryptGCMBytes` when handling raw PII so the caller can explicitly wipe the plaintext
144
+ * buffer after use.
145
+ *
146
+ * @param combined - Combined encrypted payload produced by `encryptGCM`.
147
+ * @param rawKey - 32-byte symmetric key.
148
+ * @returns Decrypted UTF-8 plaintext.
149
+ * @throws {WorkerError} When key/ciphertext is invalid or integrity verification fails.
150
+ */
151
+ export async function decryptGCM(combined: Uint8Array, rawKey: Uint8Array): Promise<string> {
152
+ const decryptedBytes = await decryptGCMBytes(combined, rawKey);
153
+ try {
154
+ return textDecoder.decode(decryptedBytes);
155
+ } finally {
156
+ decryptedBytes.fill(0);
157
+ }
158
+ }
@@ -0,0 +1,48 @@
1
+ import { base64ToBytes } from "@/lib";
2
+ import { decryptGCMBytes, encryptGCMBytes } from "./aes";
3
+
4
+ const KEY_SIZE = 32;
5
+
6
+ /**
7
+ * Generates a new random 32-byte data-encryption key.
8
+ *
9
+ * @returns Cryptographically secure DEK bytes.
10
+ */
11
+ export function generateDEK(): Uint8Array {
12
+ const crypto = globalThis.crypto;
13
+ return crypto.getRandomValues(new Uint8Array(KEY_SIZE));
14
+ }
15
+
16
+ /**
17
+ * Wraps a DEK with the worker KEK.
18
+ *
19
+ * @param dek - Plain DEK bytes.
20
+ * @param kek - 32-byte KEK bytes.
21
+ * @returns Encrypted DEK blob.
22
+ */
23
+ export async function wrapKey(dek: Uint8Array, kek: Uint8Array): Promise<Uint8Array> {
24
+ return encryptGCMBytes(dek, kek);
25
+ }
26
+
27
+ /**
28
+ * Unwraps a previously wrapped DEK with the worker KEK.
29
+ *
30
+ * @param wrappedKey - Encrypted DEK blob.
31
+ * @param kek - 32-byte KEK bytes.
32
+ * @returns Plain DEK bytes.
33
+ */
34
+ export async function unwrapKey(wrappedKey: Uint8Array, kek: Uint8Array): Promise<Uint8Array> {
35
+ const decrypted = await decryptGCMBytes(wrappedKey, kek);
36
+ if (decrypted.length === KEY_SIZE) {
37
+ const dek = decrypted.slice();
38
+ decrypted.fill(0);
39
+ return dek;
40
+ }
41
+
42
+ try {
43
+ const legacyText = new TextDecoder().decode(decrypted);
44
+ return base64ToBytes(legacyText);
45
+ } finally {
46
+ decrypted.fill(0);
47
+ }
48
+ }
@@ -0,0 +1,60 @@
1
+ import { bytesToHex } from "@/lib";
2
+
3
+ const textEncoder = new TextEncoder();
4
+
5
+ function copyToOwnedBytes(bytes: Uint8Array): Uint8Array {
6
+ const owned = new Uint8Array(bytes.byteLength);
7
+ owned.set(bytes);
8
+ return owned;
9
+ }
10
+
11
+ /**
12
+ * Imports HMAC-SHA256 key material once for repeated pseudonymization operations.
13
+ *
14
+ * @param keyMaterial - Raw key bytes or a legacy string secret.
15
+ * @returns Web Crypto HMAC key suitable for repeated `sign` calls.
16
+ */
17
+ export async function importHmacKey(keyMaterial: Uint8Array | string): Promise<CryptoKey> {
18
+ const rawKey = typeof keyMaterial === "string"
19
+ ? copyToOwnedBytes(textEncoder.encode(keyMaterial))
20
+ : copyToOwnedBytes(keyMaterial);
21
+ const keyBuffer = rawKey.buffer.slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength) as ArrayBuffer;
22
+ return globalThis.crypto.subtle.importKey(
23
+ "raw",
24
+ keyBuffer,
25
+ { name: "HMAC", hash: "SHA-256" },
26
+ false,
27
+ ["sign"]
28
+ );
29
+ }
30
+
31
+ /**
32
+ * Computes deterministic HMAC-SHA256 with a pre-imported Web Crypto key.
33
+ *
34
+ * @param input - Plain input string to sign.
35
+ * @param key - Pre-imported HMAC-SHA256 key.
36
+ * @returns Lowercase hex digest.
37
+ */
38
+ export async function generateHMACWithKey(input: string, key: CryptoKey): Promise<string> {
39
+ const signature = await globalThis.crypto.subtle.sign(
40
+ "HMAC",
41
+ key,
42
+ textEncoder.encode(input)
43
+ );
44
+
45
+ return bytesToHex(new Uint8Array(signature));
46
+ }
47
+
48
+ /**
49
+ * Computes deterministic HMAC-SHA256 for worker pseudonymization and lookup keys.
50
+ *
51
+ * Prefer `importHmacKey` + `generateHMACWithKey` when signing many values in one request.
52
+ *
53
+ * @param input - Plain input string to sign.
54
+ * @param salt - HMAC key material (salt/secret).
55
+ * @returns Lowercase hex digest.
56
+ */
57
+ export async function generateHMAC(input: string, salt: string): Promise<string> {
58
+ const key = await importHmacKey(salt);
59
+ return generateHMACWithKey(input, key);
60
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./hmac";
2
+ export * from "./aes";
3
+ export * from "./envelope";
@@ -0,0 +1,36 @@
1
+ import type { Sql } from "@/types";
2
+ import { assertIdentifier } from "@/utils";
3
+ import { sha256HexDigest } from "@/lib";
4
+
5
+ interface SchemaColumnRow {
6
+ table_name: string;
7
+ column_name: string;
8
+ data_type: string;
9
+ }
10
+
11
+ /**
12
+ * Computes a deterministic SHA-256 fingerprint of the live application schema.
13
+ *
14
+ * The signature is built from ordered `table_name + column_name + data_type` tuples.
15
+ *
16
+ * @param sql - Postgres pool or transaction used for metadata query.
17
+ * @param appSchema - Application schema to fingerprint.
18
+ * @returns Hex-encoded SHA-256 schema hash.
19
+ * @throws {WorkerError} When `appSchema` is not a safe SQL identifier.
20
+ */
21
+ export async function detectSchemaDrift(sql: Sql, appSchema: string): Promise<string> {
22
+ const safeAppSchema = assertIdentifier(appSchema, "application schema name");
23
+
24
+ const columns = await sql<SchemaColumnRow[]>`
25
+ SELECT table_name, column_name, data_type
26
+ FROM information_schema.columns
27
+ WHERE table_schema = ${safeAppSchema}
28
+ ORDER BY table_name ASC, ordinal_position ASC, column_name ASC
29
+ `;
30
+
31
+ const signature = columns.map(
32
+ (column) => `${column.table_name}${column.column_name}${column.data_type}`)
33
+ .join("");
34
+
35
+ return sha256HexDigest(signature)
36
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Dependency-graph discovery using PostgreSQL catalog metadata and recursive CTE traversal.
3
+ */
4
+
5
+ import { assertIdentifier, quoteQualifiedIdentifier } from "@/utils";
6
+ import { fail } from "@/errors";
7
+ import type { SqlExecutor } from "@/types";
8
+
9
+ export interface DependencyNode {
10
+ table_schema: string;
11
+ table_name: string;
12
+ column_name: string;
13
+ parent_table: string;
14
+ delete_action: "NO_ACTION" | "RESTRICT" | "CASCADE" | "SET_NULL" | "SET_DEFAULT" | "UNKNOWN";
15
+ depth: number;
16
+ }
17
+
18
+ export interface DependencyGraphOptions {
19
+ maxDepth?: number;
20
+ failOnUnsafeDeleteAction?: boolean;
21
+ }
22
+
23
+ const DEFAULT_MAX_DEPTH = 32;
24
+ const UNSAFE_DELETE_ACTIONS = new Set(["CASCADE", "SET_NULL", "SET_DEFAULT"]);
25
+
26
+ function normalizeDeleteAction(value: string): DependencyNode["delete_action"] {
27
+ switch (value) {
28
+ case "a":
29
+ return "NO_ACTION";
30
+ case "r":
31
+ return "RESTRICT";
32
+ case "c":
33
+ return "CASCADE";
34
+ case "n":
35
+ return "SET_NULL";
36
+ case "d":
37
+ return "SET_DEFAULT";
38
+ default:
39
+ return "UNKNOWN";
40
+ }
41
+ }
42
+
43
+ function resolveMaxDepth(input?: number): number {
44
+ if (input === undefined) {
45
+ return DEFAULT_MAX_DEPTH;
46
+ }
47
+
48
+ if (!Number.isInteger(input) || input < 1) {
49
+ fail({
50
+ code: "GRAPH_MAX_DEPTH_INVALID",
51
+ title: "Invalid graph max depth",
52
+ detail: "maxDepth must be an integer greater than 0.",
53
+ category: "validation",
54
+ retryable: false,
55
+ });
56
+ }
57
+
58
+ return input;
59
+ }
60
+
61
+ /**
62
+ * Discovers the transitive foreign-key dependency graph for a root table.
63
+ *
64
+ * The recursive CTE tracks visited OIDs to prevent cyclic loops, fails closed when the configured
65
+ * depth limit is reached, and rejects FK actions that would silently delete or rewrite dependent
66
+ * records outside the worker's explicit vault/redaction logic.
67
+ *
68
+ * @param sql - Postgres pool or active transaction.
69
+ * @param schema - Root table schema.
70
+ * @param rootTable - Root table name.
71
+ * @param options - Optional traversal controls.
72
+ * @returns Ordered dependency nodes containing table/column lineage metadata.
73
+ * @throws {WorkerError} When root table is missing, depth is invalid, unsafe FK actions are present,
74
+ * or the traversal depth limit is reached.
75
+ */
76
+ export async function getDependencyGraph(
77
+ sql: SqlExecutor,
78
+ schema: string,
79
+ rootTable: string,
80
+ options: DependencyGraphOptions = {}
81
+ ): Promise<DependencyNode[]> {
82
+ const safeSchema = assertIdentifier(schema, "schema name");
83
+ const safeRootTable = assertIdentifier(rootTable, "table name");
84
+ const maxDepth = resolveMaxDepth(options.maxDepth);
85
+ const qualifiedRoot = quoteQualifiedIdentifier(safeSchema, safeRootTable);
86
+
87
+ const [rootExists] = await sql<{ oid: string | null }[]>`
88
+ SELECT to_regclass(${qualifiedRoot})::text AS oid
89
+ `;
90
+
91
+ if (!rootExists?.oid) {
92
+ fail({
93
+ code: "GRAPH_ROOT_TABLE_MISSING",
94
+ title: "Root table not found",
95
+ detail: `Root table ${safeSchema}.${safeRootTable} does not exist.`,
96
+ category: "validation",
97
+ retryable: false,
98
+ context: { schema: safeSchema, rootTable: safeRootTable },
99
+ });
100
+ }
101
+
102
+ const result = await sql<
103
+ Array<
104
+ Omit<DependencyNode, "delete_action"> & {
105
+ delete_action_code: string;
106
+ table_oid: number;
107
+ reached_limit: boolean;
108
+ }
109
+ >
110
+ >`
111
+ WITH RECURSIVE dependency_tree AS (
112
+ SELECT
113
+ connamespace::regnamespace::text AS table_schema,
114
+ conrelid::regclass::text AS table_name,
115
+ a.attname AS column_name,
116
+ confrelid::regclass::text AS parent_table,
117
+ c.confdeltype::text AS delete_action_code,
118
+ conrelid::oid AS table_oid,
119
+ ARRAY[confrelid::oid, conrelid::oid] AS path,
120
+ 1 AS depth,
121
+ FALSE AS reached_limit
122
+ FROM pg_constraint c
123
+ JOIN pg_attribute a
124
+ ON a.attrelid = c.conrelid
125
+ AND a.attnum = ANY(c.conkey)
126
+ WHERE c.contype = 'f'
127
+ AND c.confrelid = to_regclass(${qualifiedRoot})
128
+
129
+ UNION ALL
130
+
131
+ SELECT
132
+ child.connamespace::regnamespace::text AS table_schema,
133
+ child.conrelid::regclass::text AS table_name,
134
+ a.attname AS column_name,
135
+ child.confrelid::regclass::text AS parent_table,
136
+ child.confdeltype::text AS delete_action_code,
137
+ child.conrelid::oid AS table_oid,
138
+ dt.path || child.conrelid::oid AS path,
139
+ dt.depth + 1 AS depth,
140
+ dt.depth + 1 >= ${maxDepth} AS reached_limit
141
+ FROM pg_constraint child
142
+ JOIN pg_attribute a
143
+ ON a.attrelid = child.conrelid
144
+ AND a.attnum = ANY(child.conkey)
145
+ JOIN dependency_tree dt
146
+ ON child.confrelid = dt.table_oid
147
+ WHERE child.contype = 'f'
148
+ AND dt.depth < ${maxDepth}
149
+ AND NOT child.conrelid::oid = ANY(dt.path)
150
+ )
151
+ SELECT DISTINCT ON (table_name, column_name)
152
+ table_schema,
153
+ table_name,
154
+ column_name,
155
+ parent_table,
156
+ delete_action_code,
157
+ depth,
158
+ table_oid,
159
+ reached_limit
160
+ FROM dependency_tree
161
+ ORDER BY table_name, column_name, depth ASC
162
+ `;
163
+
164
+ const graph = result.map(({ table_oid: _tableOid, reached_limit: _reachedLimit, delete_action_code, ...node }) => ({
165
+ ...node,
166
+ delete_action: normalizeDeleteAction(delete_action_code),
167
+ }));
168
+
169
+ if (options.failOnUnsafeDeleteAction !== false) {
170
+ const unsafe = graph.find((node) => UNSAFE_DELETE_ACTIONS.has(node.delete_action));
171
+ if (unsafe) {
172
+ fail({
173
+ code: "GRAPH_UNSAFE_DELETE_ACTION",
174
+ title: "Unsafe foreign-key delete action detected",
175
+ detail: `Foreign key ${unsafe.table_name}.${unsafe.column_name} uses ON DELETE ${unsafe.delete_action}; the worker refuses to run because Postgres could mutate dependent data outside the explicit erasure plan.`,
176
+ category: "integrity",
177
+ retryable: false,
178
+ fatal: true,
179
+ context: {
180
+ schema: safeSchema,
181
+ rootTable: safeRootTable,
182
+ table: unsafe.table_name,
183
+ column: unsafe.column_name,
184
+ deleteAction: unsafe.delete_action,
185
+ },
186
+ });
187
+ }
188
+ }
189
+
190
+ if (result.some((row) => row.depth >= maxDepth || row.reached_limit)) {
191
+ fail({
192
+ code: "GRAPH_DEPTH_LIMIT_REACHED",
193
+ title: "Dependency graph depth limit reached",
194
+ detail: `Dependency graph for ${safeSchema}.${safeRootTable} reached the safety limit of ${maxDepth}. Increase maxDepth before running destructive operations.`,
195
+ category: "integrity",
196
+ retryable: false,
197
+ fatal: true,
198
+ context: { schema: safeSchema, rootTable: safeRootTable, maxDepth },
199
+ });
200
+ }
201
+
202
+ return graph;
203
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./migrations";
2
+ export * from "./sql-debug";
3
+ export * from "./drift";
4
+ export * from "./graph";