@aopslabs/aops-server 0.2.24 → 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.
- package/THIRD_PARTY_NOTICES +2 -2
- package/THIRD_PARTY_NOTICES.inventory.json +5 -5
- package/build/client/_app/immutable/chunks/{B7Ksy9Vx.js → Cs-rWsD4.js} +1 -1
- package/build/client/_app/immutable/entry/{app.Bqm7erUX.js → app.Ck59hFAi.js} +2 -2
- package/build/client/_app/immutable/entry/start.mFYy5iA-.js +1 -0
- package/build/client/_app/immutable/nodes/{1.BwqSn0AV.js → 1.YS2rDA-X.js} +1 -1
- package/build/client/_app/version.json +1 -1
- package/build/handler.js +4 -4
- package/build/index.js +4 -4
- package/build/server/chunks/chunks/{internal.js-FlQEV1Hh.js → internal.js-v_HGj6nU.js} +1 -1
- package/build/server/chunks/{handler-efTCmtgM.js → handler-CZshoFnY.js} +2 -2
- package/build/server/chunks/{index.js-CGfay5SX.js → index.js-CySur4Kt.js} +1 -1
- package/build/server/chunks/{manifest.js-BTST1jcU.js → manifest.js-vXuDslAA.js} +2 -2
- package/build/server/chunks/nodes/{1.js-K2ekrPGa.js → 1.js-BbsuS5PD.js} +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/runtime-closure.package.json +1 -1
- package/scripts/community-domain-ownership-v1.json +187 -0
- package/scripts/domain-backup-provider.mjs +246 -0
- package/scripts/owned-set-backup.mjs +437 -0
- package/scripts/owned-set-restore.mjs +655 -0
- package/scripts/owned-set-snapshot.mjs +182 -0
- package/build/client/_app/immutable/entry/start.2cgNGUw5.js +0 -1
|
@@ -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 }
|