@aopslabs/aops-server 0.2.23 → 0.2.25

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.
@@ -0,0 +1,437 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * First-party backup of the relations AOPS owns, scoped by the domain providers
4
+ * and consistent across all of them.
5
+ *
6
+ * Two properties do the work here, and they pull in opposite directions:
7
+ *
8
+ * The bundle is fragmented per domain, because maintenance ownership is
9
+ * per domain -- a provider knows its own relations and nobody else's.
10
+ *
11
+ * The snapshot is single and global, because the domains are not independent.
12
+ * Measured: `scopeId` is a hub, and more than seven thousand projectman and
13
+ * docman rows point at agentspace scopes with no foreign key to enforce it.
14
+ * Five separate snapshots would each be internally consistent and mutually
15
+ * wrong, and nothing in the database would object.
16
+ *
17
+ * So: one REPEATABLE READ READ ONLY transaction, N fragment files, one manifest.
18
+ *
19
+ * Values leave as ::text and return as typed casts. That hands serialization to
20
+ * PostgreSQL rather than to the driver -- measured to matter: the default JSON
21
+ * path loses jsonb integers above 2^53, and the owned set contains such columns.
22
+ *
23
+ * Nothing here writes the plaintext bundle to disk. Rows stream out of a cursor
24
+ * and into gzipped chunks, so the largest thing in memory is one chunk.
25
+ */
26
+ import { createHash } from 'node:crypto'
27
+ import { gzipSync } from 'node:zlib'
28
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync,
29
+ renameSync, rmSync, statSync, writeSync } from 'node:fs'
30
+ import path from 'node:path'
31
+
32
+ import { providerContractSha256 as computeProviderContractSha256 } from './domain-backup-provider.mjs'
33
+
34
+ const CONTRACT = 'aops.owned-set-backup/v2'
35
+ const RECEIPT_CONTRACT = 'aops.backup-receipt/v1'
36
+ const CHUNK_TARGET_BYTES = 12 * 1024 * 1024
37
+ const FETCH_ROWS = 2000
38
+
39
+ const quote = (id) => `"${String(id).replace(/"/g, '""')}"`
40
+ const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex')
41
+
42
+ /** .part -> fsync -> atomic rename, 0600. A reader never sees a half-written file. */
43
+ function writeAtomic(target, buffer) {
44
+ const staging = `${target}.part`
45
+ const handle = openSync(staging, 'w', 0o600)
46
+ try { writeSync(handle, buffer); fsyncSync(handle) } finally { closeSync(handle) }
47
+ renameSync(staging, target)
48
+ }
49
+
50
+ /**
51
+ * A digest over a ledger's ordered content, excluding the timestamp.
52
+ *
53
+ * Row count alone cannot distinguish two different migration histories of the
54
+ * same length: substitute one tag for another and the count agrees while the
55
+ * claim does not. `appliedAt` is left out because it records when a migration ran
56
+ * on this machine, not which migration ran.
57
+ */
58
+ export async function readLedgerDigest(client, relation) {
59
+ const { rows } = await client.query(`
60
+ SELECT a.attname AS column, format_type(a.atttypid, a.atttypmod) AS type
61
+ FROM pg_attribute a
62
+ JOIN pg_class c ON c.oid = a.attrelid
63
+ JOIN pg_namespace n ON n.oid = c.relnamespace
64
+ WHERE n.nspname = 'public' AND c.relname = $1 AND a.attnum > 0 AND NOT a.attisdropped
65
+ ORDER BY a.attnum`, [relation])
66
+ if (rows.length === 0) return { rowCount: 0, contentSha256: sha256(Buffer.alloc(0)) }
67
+ // Timestamps are excluded BY TYPE, not by name. The column is `applied_at` in
68
+ // these ledgers and `appliedAt` elsewhere; excluding one spelling left the other
69
+ // in the digest, and every legitimately rebuilt ledger then differed from the
70
+ // one it was rebuilt from -- for no reason except that it was applied at a
71
+ // different moment, which is the one thing the digest must not care about.
72
+ const columns = rows
73
+ .filter((row) => !/^timestamp( |$)/.test(String(row.type)))
74
+ .map((row) => row.column)
75
+ const projection = columns.map((column) => `coalesce(${quote(column)}::text, '')`).join(` || '|' || `)
76
+ const digest = await client.query(`
77
+ SELECT count(*)::int AS rows,
78
+ coalesce(string_agg(line, E'\n' ORDER BY line), '') AS body
79
+ FROM (SELECT ${projection} AS line FROM public.${quote(relation)}) source`)
80
+ return { rowCount: digest.rows[0].rows, contentSha256: sha256(Buffer.from(digest.rows[0].body)) }
81
+ }
82
+
83
+ async function openSession(client) {
84
+ await client.query("SET SESSION TIME ZONE 'UTC'")
85
+ await client.query("SET SESSION DateStyle TO 'ISO, YMD'")
86
+ await client.query("SET SESSION IntervalStyle TO 'iso_8601'")
87
+ await client.query('SET SESSION extra_float_digits TO 3')
88
+ await client.query("SET SESSION bytea_output TO 'hex'")
89
+ }
90
+
91
+ async function readShape(client, relations) {
92
+ const columns = await client.query(`
93
+ SELECT c.relname AS relation, a.attname AS column, a.attnum,
94
+ format_type(a.atttypid, a.atttypmod) AS type, a.atttypid::int AS typeOid
95
+ FROM pg_attribute a
96
+ JOIN pg_class c ON c.oid = a.attrelid
97
+ JOIN pg_namespace n ON n.oid = c.relnamespace
98
+ WHERE n.nspname = 'public' AND c.relname = ANY($1)
99
+ AND a.attnum > 0 AND NOT a.attisdropped AND c.relkind IN ('r', 'p')
100
+ ORDER BY c.relname, a.attnum`, [relations])
101
+ const foreignKeys = await client.query(`
102
+ SELECT src.relname AS child, tgt.relname AS parent,
103
+ (SELECT string_agg(att.attname, ',' ORDER BY att.attnum)
104
+ FROM unnest(k.conkey) ck
105
+ JOIN pg_attribute att ON att.attrelid = k.conrelid AND att.attnum = ck) AS columns
106
+ FROM pg_constraint k
107
+ JOIN pg_class src ON src.oid = k.conrelid
108
+ JOIN pg_class tgt ON tgt.oid = k.confrelid
109
+ WHERE k.contype = 'f' AND src.relname = ANY($1) AND tgt.relname = ANY($1)`, [relations])
110
+ // A foreign key with one end outside the set means the set is not closed, and a
111
+ // single TRUNCATE of it would fail at restore. Fail here instead, where the
112
+ // message can say which relation crosses the boundary.
113
+ const crossing = await client.query(`
114
+ SELECT src.relname AS child, tgt.relname AS parent
115
+ FROM pg_constraint k
116
+ JOIN pg_class src ON src.oid = k.conrelid
117
+ JOIN pg_class tgt ON tgt.oid = k.confrelid
118
+ WHERE k.contype = 'f' AND ((src.relname = ANY($1)) <> (tgt.relname = ANY($1)))`, [relations])
119
+ if (crossing.rows.length > 0) {
120
+ const sample = crossing.rows.slice(0, 3).map((row) => `${row.child}->${row.parent}`).join(',')
121
+ throw new Error(`owned_set_not_fk_closed:${crossing.rows.length}:${sample}`)
122
+ }
123
+ // Only sequences OWNED BY a column of an owned relation. Reading every sequence
124
+ // in the schema would put a setval on something outside the boundary at restore
125
+ // -- the one thing this design promises not to do. The dependency is the
126
+ // authority here, not the name: a sequence named after one of our relations may
127
+ // belong to something else entirely.
128
+ const sequences = await client.query(`
129
+ SELECT s.relname AS name, seq.last_value
130
+ FROM pg_class s
131
+ JOIN pg_namespace sn ON sn.oid = s.relnamespace
132
+ JOIN pg_depend d ON d.objid = s.oid AND d.classid = 'pg_class'::regclass AND d.deptype = 'a'
133
+ JOIN pg_class t ON t.oid = d.refobjid
134
+ JOIN pg_namespace tn ON tn.oid = t.relnamespace
135
+ LEFT JOIN pg_sequences seq ON seq.schemaname = sn.nspname AND seq.sequencename = s.relname
136
+ WHERE s.relkind = 'S' AND sn.nspname = 'public' AND tn.nspname = 'public'
137
+ AND t.relname = ANY($1)`, [relations])
138
+ const byRelation = new Map()
139
+ for (const row of columns.rows) {
140
+ if (!byRelation.has(row.relation)) byRelation.set(row.relation, [])
141
+ byRelation.get(row.relation).push({ column: row.column, type: row.type, typeOid: row.typeoid })
142
+ }
143
+ return { columns: byRelation, foreignKeys: foreignKeys.rows, sequences: sequences.rows }
144
+ }
145
+
146
+ /** Parents before children. Self-references are handled at load time, not here. */
147
+ export function topologicalOrder(relations, foreignKeys) {
148
+ const dependencies = new Map(relations.map((relation) => [relation, new Set()]))
149
+ const selfReferencing = new Map()
150
+ for (const edge of foreignKeys) {
151
+ const columns = String(edge.columns ?? '').split(',').filter(Boolean)
152
+ if (edge.child === edge.parent) { selfReferencing.set(edge.child, columns); continue }
153
+ dependencies.get(edge.child)?.add(edge.parent)
154
+ }
155
+ const order = []
156
+ const remaining = new Set(relations)
157
+ while (remaining.size > 0) {
158
+ const ready = [...remaining].filter((relation) =>
159
+ [...dependencies.get(relation)].every((parent) => !remaining.has(parent)))
160
+ if (ready.length === 0) throw new Error(`owned_set_foreign_key_cycle:${[...remaining].sort().join(',')}`)
161
+ for (const relation of ready.sort()) { order.push(relation); remaining.delete(relation) }
162
+ }
163
+ return { order, selfReferencing }
164
+ }
165
+
166
+ /**
167
+ * Copies the predecessor's migration closure into the bundle, verifying every
168
+ * byte on the way in.
169
+ *
170
+ * The physical source is the installed package, not a repository tree: the
171
+ * repository is what a release was built from, the installed package is what an
172
+ * operator actually has, and a bundle that embeds anything else embeds a claim
173
+ * rather than the bytes. Each file is hashed as it is copied and compared to the
174
+ * manifest the lineage is bound to, so a closure that shifted under us fails here
175
+ * instead of producing a bundle that restores a different schema than it says.
176
+ */
177
+ export function embedMigrationClosure(bundleDirectory, migrationSetManifest) {
178
+ if (!migrationSetManifest?.roots?.length) {
179
+ throw new Error('owned_set_backup_closure_manifest_empty')
180
+ }
181
+ const embedded = []
182
+ for (const root of migrationSetManifest.roots) {
183
+ if (!root.sourceDir || !existsSync(root.sourceDir)) {
184
+ throw new Error(`owned_set_backup_closure_source_missing:${root.root}:${String(root.sourceDir)}`)
185
+ }
186
+ for (const file of root.files) {
187
+ // A file may arrive as bytes rather than as a path. The journal does: the
188
+ // bundle carries the applied prefix of the closure, and the journal that
189
+ // describes exactly that prefix is not a file anyone shipped. Everything
190
+ // else still comes off disk, and both go through the same hash and size
191
+ // check below -- being synthesized buys no exemption from the manifest.
192
+ let bytes
193
+ if (file.contents !== undefined) {
194
+ bytes = Buffer.isBuffer(file.contents) ? file.contents : Buffer.from(file.contents)
195
+ } else {
196
+ const source = path.join(root.sourceDir, file.relativePath)
197
+ if (!existsSync(source)) {
198
+ throw new Error(`owned_set_backup_closure_file_missing:${root.root}:${file.relativePath}`)
199
+ }
200
+ bytes = readFileSync(source)
201
+ }
202
+ const actual = sha256(bytes)
203
+ if (actual !== file.sha256) {
204
+ throw new Error(`owned_set_backup_closure_file_changed:${root.root}:${file.relativePath}`)
205
+ }
206
+ if (bytes.length !== file.byteLength) {
207
+ throw new Error(`owned_set_backup_closure_file_size_changed:${root.root}:${file.relativePath}`)
208
+ }
209
+ const target = path.join(bundleDirectory, 'closure', root.root, file.relativePath)
210
+ mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 })
211
+ writeAtomic(target, bytes)
212
+ embedded.push({ root: root.root, relativePath: file.relativePath, sha256: actual, byteLength: bytes.length })
213
+ }
214
+ }
215
+ return {
216
+ schemaVersion: 1,
217
+ rootCount: migrationSetManifest.roots.length,
218
+ fileCount: embedded.length,
219
+ byteLength: embedded.reduce((sum, file) => sum + file.byteLength, 0),
220
+ files: embedded,
221
+ migrationBundleSha256: sha256(Buffer.from(JSON.stringify(embedded))),
222
+ }
223
+ }
224
+
225
+ export async function backupOwnedSet({ client, providerSet, identity, bundleDirectory, receiptPath, backupSetId, migrationSetManifest }) {
226
+ if (!backupSetId) throw new Error('owned_set_backup_set_id_required')
227
+ if (!(identity?.lineageId ?? identity?.id)) throw new Error('owned_set_backup_identity_lineage_id_required')
228
+ if (identity?.identityMode !== 'semantic-owned-v1') {
229
+ // A legacy-raw lineage has no owned relation list, so there is no defensible
230
+ // scope to back up: the retiring predecessor relations cannot be found by
231
+ // intersecting with the target contract.
232
+ throw new Error(`owned_set_backup_not_eligible:identityMode=${String(identity?.identityMode)}`)
233
+ }
234
+ // The declaration the providers actually carry, hashed over its semantic half.
235
+ // A lineage that states which contract it expects is checked against it here
236
+ // rather than at restore time: a backup taken under the wrong contract should
237
+ // never be written, not merely refused later when the data is already needed.
238
+ // Measured: the codec holds no registry of known types. A value leaves as
239
+ // `::text` and returns as `$n::<the column's own format_type()>`, which
240
+ // round-trips enums, composites, domains, ranges and arrays a product author
241
+ // invents later -- proved in owned-set-codec-types.test.mjs. So there is no
242
+ // "unknown type" for an extension to cover, and nothing here implements one.
243
+ // A domain that declares an extension is therefore declaring something this
244
+ // build cannot honour, and refusing is the only honest answer: the alternative
245
+ // is a backup taken as if the declaration had been read.
246
+ for (const provider of providerSet.providers) {
247
+ if ((provider.codecExtensions ?? []).length > 0) {
248
+ throw new Error(`owned_set_backup_codec_extension_unsupported:${provider.domain}`)
249
+ }
250
+ }
251
+ const providerContractSha256 = computeProviderContractSha256(providerSet)
252
+ // Required, not "checked when present". The restore side carried the same
253
+ // shape -- an optional value feeding a truthy guard -- and there the default
254
+ // invocation supplied nothing, so the comparison never ran and a drifted
255
+ // contract passed silently. The argument that it cannot be absent here is the
256
+ // same argument that was wrong there: the snapshot refuses a non-semantic
257
+ // lineage and the validator requires the field, but this function is callable
258
+ // on its own, and an identity without a contract means nothing is comparing
259
+ // what was computed to what the lineage records.
260
+ if (typeof identity.providerContractSha256 !== 'string' || identity.providerContractSha256.length !== 64) {
261
+ throw new Error(`owned_set_backup_identity_contract_absent:${String(identity.id)}`)
262
+ }
263
+ if (identity.providerContractSha256 !== providerContractSha256) {
264
+ throw new Error('owned_set_backup_provider_contract_mismatch:' +
265
+ `${identity.providerContractSha256}:${providerContractSha256}`)
266
+ }
267
+ rmSync(bundleDirectory, { recursive: true, force: true })
268
+ mkdirSync(path.join(bundleDirectory, 'fragments'), { recursive: true, mode: 0o700 })
269
+
270
+ await openSession(client)
271
+ const allRelations = [...providerSet.payloadRelations, ...providerSet.ledgerRelations].sort()
272
+ const shape = await readShape(client, allRelations)
273
+ const { order, selfReferencing } = topologicalOrder(allRelations, shape.foreignKeys)
274
+ const ledgers = new Set(providerSet.ledgerRelations)
275
+
276
+ const chunks = []
277
+ let totalRows = 0
278
+ let largestChunkBytes = 0
279
+
280
+ // ONE snapshot for every domain. Fragmenting the output does not fragment the read.
281
+ await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY')
282
+ const ledgerEvidence = {}
283
+ for (const provider of providerSet.providers) {
284
+ const relations = order.filter((relation) =>
285
+ provider.payloadRelations.includes(relation) || provider.ledgerRelation === relation)
286
+ for (const relation of relations) {
287
+ const columns = shape.columns.get(relation) ?? []
288
+ if (columns.length === 0) continue
289
+ const projection = columns.map((column) => `${quote(column.column)}::text AS ${quote(column.column)}`).join(', ')
290
+ await client.query(`DECLARE owned_cursor NO SCROLL CURSOR FOR SELECT ${projection} FROM public.${quote(relation)}`)
291
+ let index = 0
292
+ let buffer = []
293
+ let bufferBytes = 0
294
+ const flush = () => {
295
+ if (buffer.length === 0) return
296
+ const plain = Buffer.from(`${buffer.join('\n')}\n`)
297
+ const packed = gzipSync(plain, { level: 6 })
298
+ const name = `${provider.domain}.${relation}.${String(index).padStart(4, '0')}.ndjson.gz`
299
+ writeAtomic(path.join(bundleDirectory, 'fragments', name), packed)
300
+ chunks.push({
301
+ domain: provider.domain,
302
+ relation,
303
+ isLedger: ledgers.has(relation),
304
+ index,
305
+ file: `fragments/${name}`,
306
+ rowCount: buffer.length,
307
+ uncompressedBytes: plain.length,
308
+ compressedBytes: packed.length,
309
+ uncompressedSha256: sha256(plain),
310
+ compressedSha256: sha256(packed),
311
+ })
312
+ largestChunkBytes = Math.max(largestChunkBytes, plain.length)
313
+ index += 1
314
+ buffer = []
315
+ bufferBytes = 0
316
+ }
317
+ let relationRows = 0
318
+ for (;;) {
319
+ const { rows } = await client.query(`FETCH ${FETCH_ROWS} FROM owned_cursor`)
320
+ if (rows.length === 0) break
321
+ for (const row of rows) {
322
+ const line = JSON.stringify(columns.map((column) => row[column.column]))
323
+ buffer.push(line)
324
+ bufferBytes += line.length + 1
325
+ relationRows += 1
326
+ totalRows += 1
327
+ if (bufferBytes >= CHUNK_TARGET_BYTES) flush()
328
+ }
329
+ }
330
+ flush()
331
+ await client.query('CLOSE owned_cursor')
332
+ if (ledgers.has(relation)) {
333
+ // The digest is computed from the same snapshot the rows were read in, so
334
+ // it describes the state this bundle captured rather than whatever the
335
+ // ledger looks like by the time anyone validates it.
336
+ const digest = await readLedgerDigest(client, relation)
337
+ ledgerEvidence[relation] = {
338
+ domain: provider.domain,
339
+ rowCount: relationRows,
340
+ contentSha256: digest.contentSha256,
341
+ }
342
+ }
343
+ }
344
+ }
345
+ await client.query('COMMIT')
346
+
347
+ const embeddedClosure = migrationSetManifest ? embedMigrationClosure(bundleDirectory, migrationSetManifest) : null
348
+
349
+ const manifest = {
350
+ schemaVersion: 2,
351
+ contract: CONTRACT,
352
+ backupSetId,
353
+ identity: {
354
+ // Policy records carry the id as `id`; a receipt written from `lineageId`
355
+ // silently records undefined, and the anchor's binding to its source
356
+ // lineage is exactly what must not be optional.
357
+ lineageId: identity.lineageId ?? identity.id,
358
+ identityMode: identity.identityMode,
359
+ postgresMajor: identity.postgresMajor,
360
+ ownedFingerprintSha256: identity.ownedFingerprintSha256,
361
+ ownedRelations: [...identity.ownedRelations].sort(),
362
+ },
363
+ // What each domain declared, and the identity of that declaration. The list
364
+ // alone proves nothing: an edge can be deleted from the inventory and every
365
+ // relation-coverage check stays green -- measured, not assumed. The hash is
366
+ // the part a restore can refuse on.
367
+ providerContractSha256,
368
+ providers: providerSet.providers.map((provider) => ({
369
+ contractSchemaVersion: provider.contractSchemaVersion,
370
+ domain: provider.domain,
371
+ payloadRelations: provider.payloadRelations,
372
+ ledgerRelation: provider.ledgerRelation,
373
+ closureRoots: provider.closureRoots,
374
+ outboundWeakEdges: provider.outboundWeakEdges,
375
+ codecExtensions: provider.codecExtensions,
376
+ })),
377
+ shape: Object.fromEntries([...shape.columns]),
378
+ foreignKeys: shape.foreignKeys,
379
+ selfReferencing: Object.fromEntries([...selfReferencing]),
380
+ restoreOrder: order,
381
+ sequences: shape.sequences.map((sequence) => ({
382
+ name: sequence.name,
383
+ lastValue: sequence.last_value === null ? null : String(sequence.last_value),
384
+ })),
385
+ // Ledgers travel as evidence, never as payload to replay. See the provider
386
+ // contract for why: a ledger row asserts a migration ran, and asserting that
387
+ // independently of the DDL is a lie nothing downstream would catch.
388
+ ledgerEvidence,
389
+ chunks,
390
+ totalRows,
391
+ // Without the closure this bundle proves data fidelity and nothing more: it
392
+ // cannot rebuild a schema, so it is not disaster-recovery eligible. The flag
393
+ // is written from what actually happened rather than from intent.
394
+ closure: embeddedClosure,
395
+ migrationSetEmbedded: embeddedClosure !== null,
396
+ }
397
+ const manifestBytes = Buffer.from(JSON.stringify(manifest, null, 2))
398
+ writeAtomic(path.join(bundleDirectory, 'manifest.json'), manifestBytes)
399
+
400
+ // The integrity root lives OUTSIDE the bundle. Measured reason: with the chunk
401
+ // hashes and the manifest in one mutable file, a coordinated edit rewrites both
402
+ // and the bundle validates itself. That attack was accepted by an earlier
403
+ // version of this design; the anchor is what refuses it.
404
+ const bundleSha256 = sha256(manifestBytes)
405
+ const receipt = {
406
+ schemaVersion: 1,
407
+ contract: RECEIPT_CONTRACT,
408
+ bundleSha256,
409
+ backupSetId,
410
+ sourceLineageId: identity.lineageId ?? identity.id,
411
+ postgresMajor: identity.postgresMajor,
412
+ // Also outside the bundle. The bundle digest says "these bytes did not
413
+ // change"; this says "they were produced under the provider contract the
414
+ // lineage expects", which is a different claim and the one a coordinated
415
+ // edit of the bundle cannot forge.
416
+ providerContractSha256,
417
+ bundlePath: path.resolve(bundleDirectory),
418
+ }
419
+ mkdirSync(path.dirname(receiptPath), { recursive: true, mode: 0o700 })
420
+ writeAtomic(receiptPath, Buffer.from(JSON.stringify(receipt, null, 2)))
421
+
422
+ return {
423
+ backupSetId,
424
+ bundleSha256,
425
+ relations: allRelations.length,
426
+ rows: totalRows,
427
+ chunks: chunks.length,
428
+ fragments: new Set(chunks.map((chunk) => chunk.domain)).size,
429
+ compressedBytes: chunks.reduce((sum, chunk) => sum + chunk.compressedBytes, 0),
430
+ uncompressedBytes: chunks.reduce((sum, chunk) => sum + chunk.uncompressedBytes, 0),
431
+ largestChunkBytes,
432
+ closureFiles: embeddedClosure?.fileCount ?? 0,
433
+ migrationSetEmbedded: embeddedClosure !== null,
434
+ }
435
+ }
436
+
437
+ export { CONTRACT, RECEIPT_CONTRACT }