@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.
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * What each domain supplies to a backup set, and what the core owns instead.
4
+ *
5
+ * The split follows the measurement rather than symmetry. A domain knows three
6
+ * things nobody else can know: which relations it owns, which relation is its
7
+ * migration ledger, and which of its columns point at another domain without a
8
+ * foreign key to enforce it. Everything else -- the snapshot, the chunk format,
9
+ * the codec, sequence handling, eligibility -- is identical for every domain, so
10
+ * it lives in the core. Giving each provider its own codec would be five copies
11
+ * of one behaviour and five places for them to drift apart.
12
+ *
13
+ * DEVIATION FROM THE DISCUSS CONSENSUS, recorded rather than quietly taken.
14
+ *
15
+ * The consensus text has each domain provider supply owned tables, the full
16
+ * migration closure, schema identity, typed text codecs, sequences, ledger
17
+ * evidence and versioned weak-reference edges. This provider supplies three of
18
+ * those: owned tables, its ledger relation, and its outbound weak edges. The
19
+ * rest sits in the core.
20
+ *
21
+ * The reason is measurable rather than stylistic. The codec is one behaviour --
22
+ * values leave as ::text and return as typed casts -- and it is identical for
23
+ * every domain because it is a property of PostgreSQL, not of any domain. The
24
+ * same holds for sequence handling and for the closure, which is collected per
25
+ * *package* and not per domain. Five copies of one behaviour is five places for
26
+ * them to drift, and a drift between two domains' codecs would be invisible
27
+ * until it corrupted one domain's restore.
28
+ *
29
+ * The operator directed this shape explicitly: "overengineering istemiyorum".
30
+ *
31
+ * What the consensus was protecting -- that no domain's specifics are assumed by
32
+ * the core -- is preserved: the core asks each provider which relations it owns
33
+ * and which of its columns cross a boundary, and never infers either.
34
+ *
35
+ * Ledgers are listed but never restored as data. Measured reason: a ledger row
36
+ * asserts that a migration ran. Replaying rows independently of the DDL can
37
+ * assert it about a schema where it did not, and nothing downstream would catch
38
+ * the lie. On an empty target the engine writes the ledger by applying the
39
+ * embedded closure; on an existing target the ledger is left alone and only
40
+ * validated. Either way the bundle carries it as evidence, not as payload.
41
+ */
42
+ import { createHash } from 'node:crypto'
43
+ import { readFileSync } from 'node:fs'
44
+ import path from 'node:path'
45
+ import { fileURLToPath } from 'node:url'
46
+
47
+ const CONTRACT = 'aops.domain-backup-provider/v1'
48
+ const PROVIDER_CONTRACT_SCHEMA_VERSION = 1
49
+ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
50
+
51
+ /** A relation is payload if a domain owns it and it is not that domain's ledger. */
52
+ export function buildDomainProviders(inventory, { ownedRelations, policyRoots } = {}) {
53
+ if (inventory?.contract !== 'aops.domain-ownership-inventory/v1') {
54
+ throw new Error(`domain_backup_provider_inventory_contract_invalid:${String(inventory?.contract)}`)
55
+ }
56
+ // Which closure roots belong to a domain is a domain fact, and until now it was
57
+ // not in the contract at all: the closure arrived from outside as a migration
58
+ // set manifest, so nothing tied "these bytes" to "this domain".
59
+ //
60
+ // Bound by id, not by the migrations directory. The first version used the
61
+ // directory because both sides carried it, and that held only in a repository
62
+ // checkout: the npm staging step rewrites every policy root to
63
+ // `migrations/<id>` while the ownership inventory ships its repository paths
64
+ // unchanged. In an installed package the two never matched, and a backup on a
65
+ // real installation refused with root_unmatched. Ids are the same string in
66
+ // both layouts because both are generated from the same root list.
67
+ if (!Array.isArray(policyRoots)) throw new Error('domain_backup_provider_policy_roots_absent')
68
+ const rootsById = new Map()
69
+ for (const root of policyRoots) {
70
+ if (rootsById.has(root.id)) {
71
+ throw new Error(`domain_backup_provider_root_id_ambiguous:${root.id}`)
72
+ }
73
+ rootsById.set(root.id, root)
74
+ }
75
+ const ledgerByDomain = new Map(inventory.ledgers.map((entry) => [entry.domain, entry.relation]))
76
+ const edgesByDomain = new Map()
77
+ for (const edge of inventory.weakEdges ?? []) {
78
+ if (!edgesByDomain.has(edge.fromDomain)) edgesByDomain.set(edge.fromDomain, [])
79
+ edgesByDomain.get(edge.fromDomain).push(edge)
80
+ }
81
+ // The inventory describes the target state. A backup is taken of whatever state
82
+ // the database is actually in, and a predecessor still holds the relations its
83
+ // pending migrations are about to drop -- `missions` on the 0.3.24 predecessor.
84
+ // Those belong to the domain that created them, so when the caller says which
85
+ // owned set it is backing up, add them back to that domain rather than leaving
86
+ // a relation with data in it outside every provider's payload.
87
+ const owned = ownedRelations ? new Set(ownedRelations) : null
88
+ const retiringByDomain = new Map()
89
+ for (const entry of inventory.createdThenDropped ?? []) {
90
+ // No owned set means the target state, which by definition has already
91
+ // dropped these. Adding them anyway would produce a provider set matching no
92
+ // state that ever exists -- neither the target nor any predecessor.
93
+ if (!owned || !owned.has(entry.table)) continue
94
+ if (!retiringByDomain.has(entry.domain)) retiringByDomain.set(entry.domain, [])
95
+ retiringByDomain.get(entry.domain).push(entry.table)
96
+ }
97
+ const providers = inventory.domains.map((domain) => {
98
+ const root = rootsById.get(domain.domain)
99
+ if (!root) throw new Error(`domain_backup_provider_root_unmatched:${domain.domain}`)
100
+ return Object.freeze({
101
+ contractSchemaVersion: PROVIDER_CONTRACT_SCHEMA_VERSION,
102
+ domain: domain.domain,
103
+ payloadRelations: [...domain.ownedRelations, ...(retiringByDomain.get(domain.domain) ?? [])].sort(),
104
+ retiringRelations: Object.freeze([...(retiringByDomain.get(domain.domain) ?? [])].sort()),
105
+ ledgerRelation: ledgerByDomain.get(domain.domain) ?? null,
106
+ outboundWeakEdges: Object.freeze(edgesByDomain.get(domain.domain) ?? []),
107
+ // Which roots produce this domain's relations -- the reference, never the
108
+ // bytes. The bytes stay in the lineage migration set manifest; duplicating
109
+ // them per domain would be a second place for them to drift.
110
+ // Deliberately without the migrations directory. It is inside the contract
111
+ // hash, and the npm staging step rewrites it: a lineage whose contract was
112
+ // computed in a checkout would never match the same release installed from
113
+ // the registry, and the backup would refuse on a real machine while passing
114
+ // every test in the repository. The root id and its migration table identify
115
+ // the closure and are the same string in both layouts.
116
+ closureRoots: Object.freeze([Object.freeze({
117
+ rootId: root.id,
118
+ migrationTable: root.migrationTable,
119
+ })]),
120
+ // Empty unless the core codec registry does not know a type this domain
121
+ // uses. An empty list is a claim -- "core handles everything here" -- and it
122
+ // is inside the contract hash, so it cannot change without being noticed.
123
+ codecExtensions: Object.freeze([]),
124
+ })
125
+ })
126
+ assertOwnershipDisjoint(providers)
127
+ return Object.freeze({
128
+ schemaVersion: 1,
129
+ contract: CONTRACT,
130
+ providers: Object.freeze(providers),
131
+ // The set the core actually reads: payload from every domain, plus every
132
+ // ledger. It has to equal the owned set the lineage identity was measured
133
+ // against, or the backup is scoped to a different database than the one the
134
+ // classifier recognised.
135
+ payloadRelations: providers.flatMap((p) => p.payloadRelations).sort(),
136
+ ledgerRelations: providers.map((p) => p.ledgerRelation).filter(Boolean).sort(),
137
+ })
138
+ }
139
+
140
+ /**
141
+ * Two domains claiming one relation is not a conflict to resolve at backup time;
142
+ * it means ownership is undecided, and a backup scoped by an undecided boundary
143
+ * would silently include or exclude rows depending on iteration order.
144
+ */
145
+ export function assertOwnershipDisjoint(providers) {
146
+ const owner = new Map()
147
+ for (const provider of providers) {
148
+ for (const relation of [provider.payloadRelations, provider.ledgerRelation ? [provider.ledgerRelation] : []].flat()) {
149
+ const existing = owner.get(relation)
150
+ if (existing && existing !== provider.domain) {
151
+ throw new Error(`domain_backup_provider_ownership_conflict:${relation}:${existing}:${provider.domain}`)
152
+ }
153
+ owner.set(relation, provider.domain)
154
+ }
155
+ }
156
+ }
157
+
158
+ /**
159
+ * The provider set has to cover exactly the relations the lineage identity was
160
+ * measured against -- no more, no fewer. A relation the providers do not claim
161
+ * would be silently dropped from every backup; one they claim but the identity
162
+ * does not know is a relation nobody derived a reference for.
163
+ */
164
+ export function assertCoversOwnedIdentity(providerSet, ownedRelations) {
165
+ const covered = new Set([...providerSet.payloadRelations, ...providerSet.ledgerRelations])
166
+ const owned = new Set(ownedRelations)
167
+ const missing = [...owned].filter((relation) => !covered.has(relation)).sort()
168
+ const extra = [...covered].filter((relation) => !owned.has(relation)).sort()
169
+ if (missing.length || extra.length) {
170
+ throw new Error(`domain_backup_provider_coverage_mismatch:missing=${missing.join(',')}:extra=${extra.join(',')}`)
171
+ }
172
+ return { covered: covered.size }
173
+ }
174
+
175
+ /**
176
+ * The identity of what each domain DECLARED, and nothing else.
177
+ *
178
+ * Two properties earn their cost. It hashes only the semantic half: a weak edge
179
+ * carries a `note` explaining why no foreign key enforces it, and rewording that
180
+ * note must not invalidate a backup. And it serialises in a fixed key order
181
+ * rather than whatever order the objects happen to have, so the value describes
182
+ * the declaration instead of the code path that built it.
183
+ *
184
+ * The previous version of this function hashed `JSON.stringify({contract,
185
+ * providers})` -- note text included, key order incidental -- and was written
186
+ * nowhere except a CLI progress line. Measured: deleting every weak edge from
187
+ * the inventory changed it and nothing failed, because no gate consumed it.
188
+ */
189
+ function canonicalJson(value) {
190
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
191
+ if (value && typeof value === 'object') {
192
+ return `{${Object.keys(value).sort()
193
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`
194
+ }
195
+ return JSON.stringify(value ?? null)
196
+ }
197
+
198
+ /** The semantic half of one weak edge. `note` is deliberately absent. */
199
+ function semanticWeakEdge(edge) {
200
+ return {
201
+ id: edge.id,
202
+ fromDomain: edge.fromDomain,
203
+ toDomain: edge.toDomain,
204
+ toRelation: edge.toRelation,
205
+ column: edge.column,
206
+ kind: edge.kind,
207
+ }
208
+ }
209
+
210
+ export function providerContractSha256(providerSet) {
211
+ return createHash('sha256').update(canonicalJson({
212
+ contract: providerSet.contract,
213
+ schemaVersion: providerSet.schemaVersion,
214
+ providers: [...providerSet.providers]
215
+ .sort((a, b) => (a.domain < b.domain ? -1 : a.domain > b.domain ? 1 : 0))
216
+ .map((provider) => ({
217
+ contractSchemaVersion: provider.contractSchemaVersion,
218
+ domain: provider.domain,
219
+ payloadRelations: [...provider.payloadRelations].sort(),
220
+ ledgerRelation: provider.ledgerRelation,
221
+ closureRoots: [...provider.closureRoots]
222
+ .sort((a, b) => (a.rootId < b.rootId ? -1 : a.rootId > b.rootId ? 1 : 0)),
223
+ outboundWeakEdges: [...provider.outboundWeakEdges].map(semanticWeakEdge)
224
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
225
+ codecExtensions: [...provider.codecExtensions].sort(),
226
+ })),
227
+ })).digest('hex')
228
+ }
229
+
230
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
231
+ const inventory = JSON.parse(readFileSync(
232
+ path.join(repoRoot, 'apps/aops-server/scripts/community-domain-ownership-v1.json'), 'utf8'))
233
+ const policy = JSON.parse(readFileSync(
234
+ path.join(repoRoot, 'apps/aops-server/scripts/community-migration-policy-v1.json'), 'utf8'))
235
+ const set = buildDomainProviders(inventory, { policyRoots: policy.roots })
236
+ for (const provider of set.providers) {
237
+ process.stdout.write(`${provider.domain.padEnd(12)} payload=${String(provider.payloadRelations.length).padStart(3)}` +
238
+ ` ledger=${provider.ledgerRelation ?? '-'} edges=${provider.outboundWeakEdges.length}` +
239
+ ` roots=${provider.closureRoots.map((r) => r.rootId).join('/')}\n`)
240
+ }
241
+ process.stdout.write(`payload=${set.payloadRelations.length} ledger=${set.ledgerRelations.length} ` +
242
+ `total=${set.payloadRelations.length + set.ledgerRelations.length} ` +
243
+ `contract=${providerContractSha256(set)}\n`)
244
+ }
245
+
246
+ export { CONTRACT }