@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,182 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One entry point for taking a first-party snapshot of the relations AOPS owns.
4
+ *
5
+ * The update flow needs a pre-migration backup and, until now, took it with
6
+ * pg_dump: a host binary the operator has to install, of a version the server
7
+ * will accept, before an update may proceed. The operator's decision was to
8
+ * remove that dependency — "pg_dump bağımlılığı istemiyorum" — and the machinery
9
+ * for it already exists in this directory. What was missing was a surface the
10
+ * CLI can call.
11
+ *
12
+ * It ships in the server package and is invoked the way `community-host.mjs` and
13
+ * `container-start.mjs` are: resolved inside the installed package and run with
14
+ * node. That is the repository's existing shape for "the CLI needs something the
15
+ * server owns", and following it costs nothing, where moving 1,180 lines into a
16
+ * published TypeScript package would have cost a port and a weaker type
17
+ * boundary in the package that already caught one real defect this way.
18
+ *
19
+ * The connection URL arrives through AOPS_PG_URL, never argv, for the same
20
+ * reason the rest of this product does it: argv is visible to every process on
21
+ * the machine and lands in logs nobody redacts.
22
+ */
23
+ import { createHash } from 'node:crypto'
24
+ import { existsSync, readFileSync } from 'node:fs'
25
+ import path from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+
28
+ import { Client } from 'pg'
29
+
30
+ import { buildDomainProviders, assertCoversOwnedIdentity } from './domain-backup-provider.mjs'
31
+ import { backupOwnedSet } from './owned-set-backup.mjs'
32
+
33
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url))
34
+
35
+ function parseArgs(argv) {
36
+ const value = (name) => {
37
+ const index = argv.indexOf(name)
38
+ return index >= 0 ? argv[index + 1] : undefined
39
+ }
40
+ return {
41
+ bundleDirectory: value('--bundle-dir'),
42
+ receiptPath: value('--receipt'),
43
+ backupSetId: value('--set-id'),
44
+ policyPath: value('--policy'),
45
+ inventoryPath: value('--inventory'),
46
+ migrationsRoot: value('--migrations-root'),
47
+ }
48
+ }
49
+
50
+ /**
51
+ * The migration closure, read from the tree the policy points at.
52
+ *
53
+ * Embedding it is what makes a bundle able to rebuild a schema rather than only
54
+ * refill one. Digested per file so a bundle cannot claim bytes it does not
55
+ * carry.
56
+ */
57
+ /**
58
+ * The closure that goes into the bundle is the one that was APPLIED, not the one
59
+ * the running package ships.
60
+ *
61
+ * Measured on a restored copy of a real 0.3.24 database: the package's closure
62
+ * carries two agentspace migrations and eight for projectman, while the database
63
+ * being snapshotted is at one and six. Embedding the package's closure produced a
64
+ * bundle that claimed the 0.3.24 lineage but carried the migrations that leave
65
+ * 0.3.24 -- including the one that drops `missions`. A disaster restore from it
66
+ * would have rebuilt a schema the bundle's own rows do not fit, and the policy
67
+ * validator refused it (`community_strict_policy_target_invalid`) because the
68
+ * lineage's applied counts disagreed with the roots rebuilt from those bytes.
69
+ *
70
+ * So each root is cut to the count the lineage records, and the journal is cut
71
+ * with it: `bundleOnlyPolicy` rebuilds the roots from the embedded journal, so a
72
+ * full journal beside a partial file set describes migrations that are not there.
73
+ */
74
+ function collectMigrationSetManifest(policy, migrationsRoot, appliedCounts) {
75
+ if (!Array.isArray(appliedCounts) || appliedCounts.length !== policy.roots.length) {
76
+ throw new Error(`owned_set_snapshot_applied_counts_shape:${String(appliedCounts?.length)}:${policy.roots.length}`)
77
+ }
78
+ return {
79
+ roots: policy.roots.map((root, rootIndex) => {
80
+ // Two shapes, and they are not interchangeable. In the shipped package the
81
+ // staging step copies each root to `migrations/<root.id>`; in a repository
82
+ // checkout `migrationsDir` is the whole relative path, most of it inside
83
+ // node_modules. Resolving one as the other silently looks for `sys/` at the
84
+ // repository root, which is where the first version of this went.
85
+ const sourceDir = migrationsRoot
86
+ ? path.resolve(migrationsRoot, root.migrationsDir)
87
+ : path.join(scriptDir, '..', 'migrations', root.id)
88
+ const applied = appliedCounts[rootIndex]
89
+ if (!Number.isInteger(applied) || applied < 0 || applied > root.migrations.length) {
90
+ throw new Error(`owned_set_snapshot_applied_count_out_of_range:${root.id}:${String(applied)}`)
91
+ }
92
+ const appliedMigrations = root.migrations.slice(0, applied)
93
+ const appliedTags = new Set(appliedMigrations.map((migration) => migration.tag))
94
+ const files = []
95
+ for (const migration of appliedMigrations) {
96
+ const file = path.join(sourceDir, `${migration.tag}.sql`)
97
+ const bytes = readFileSync(file)
98
+ files.push({
99
+ relativePath: `${migration.tag}.sql`,
100
+ mode: '0644',
101
+ byteLength: bytes.length,
102
+ sha256: createHash('sha256').update(bytes).digest('hex'),
103
+ })
104
+ }
105
+ const journal = path.join(sourceDir, 'meta/_journal.json')
106
+ if (existsSync(journal)) {
107
+ // Rewritten rather than copied, and carried as bytes rather than as a
108
+ // path, because the truncated journal exists nowhere on disk.
109
+ const source = JSON.parse(readFileSync(journal, 'utf8'))
110
+ const entries = source.entries.filter((entry) => appliedTags.has(entry.tag))
111
+ if (entries.length !== appliedMigrations.length) {
112
+ throw new Error(`owned_set_snapshot_journal_incomplete:${root.id}:${entries.length}:${appliedMigrations.length}`)
113
+ }
114
+ const bytes = Buffer.from(`${JSON.stringify({ ...source, entries }, null, 2)}\n`, 'utf8')
115
+ files.push({
116
+ relativePath: 'meta/_journal.json',
117
+ mode: '0644',
118
+ byteLength: bytes.length,
119
+ sha256: createHash('sha256').update(bytes).digest('hex'),
120
+ contents: bytes,
121
+ })
122
+ }
123
+ return { root: root.id, sourceDir, files }
124
+ }),
125
+ }
126
+ }
127
+
128
+ export async function snapshotOwnedSet(options) {
129
+ const repoUrl = process.env.AOPS_PG_URL
130
+ if (!repoUrl) throw new Error('owned_set_snapshot_pg_url_absent')
131
+ for (const required of ['bundleDirectory', 'receiptPath', 'backupSetId']) {
132
+ if (!options[required]) throw new Error(`owned_set_snapshot_${required}_required`)
133
+ }
134
+ const policy = JSON.parse(readFileSync(
135
+ options.policyPath ?? path.join(scriptDir, 'community-migration-policy-package-v1.json'), 'utf8'))
136
+ const inventory = JSON.parse(readFileSync(
137
+ options.inventoryPath ?? path.join(scriptDir, 'community-domain-ownership-v1.json'), 'utf8'))
138
+
139
+ const client = new Client({ connectionString: repoUrl })
140
+ await client.connect()
141
+ try {
142
+ // Which lineage this database is at decides which owned set to copy. Read
143
+ // from the database rather than assumed from the policy's target: an
144
+ // installation about to be updated is, by definition, not at the target.
145
+ const { inspectCommunityStrictPgSchema } = await import('@aopslabs/aops-pg-bootstrap')
146
+ const state = await inspectCommunityStrictPgSchema({ client, policy })
147
+ const identity = policy.lineages.find((lineage) => lineage.id === state.lineageId)
148
+ if (!identity) throw new Error(`owned_set_snapshot_lineage_unknown:${state.lineageId}`)
149
+ if (identity.identityMode !== 'semantic-owned-v1') {
150
+ // Refused rather than guessed. A legacy-raw lineage carries no owned
151
+ // relation list, so there is no defensible boundary to copy, and copying
152
+ // the wrong boundary is worse than not copying.
153
+ throw new Error(`owned_set_snapshot_not_eligible:${identity.id}:${identity.identityMode}`)
154
+ }
155
+ const providerSet = buildDomainProviders(inventory, {
156
+ ownedRelations: identity.ownedRelations,
157
+ policyRoots: policy.roots,
158
+ })
159
+ assertCoversOwnedIdentity(providerSet, identity.ownedRelations)
160
+ const summary = await backupOwnedSet({
161
+ client,
162
+ providerSet,
163
+ identity,
164
+ bundleDirectory: options.bundleDirectory,
165
+ receiptPath: options.receiptPath,
166
+ backupSetId: options.backupSetId,
167
+ migrationSetManifest: collectMigrationSetManifest(policy, options.migrationsRoot, identity.appliedCounts),
168
+ })
169
+ return { ...summary, lineageId: identity.id, contract: 'aops.owned-set-snapshot/v1' }
170
+ } finally {
171
+ await client.end()
172
+ }
173
+ }
174
+
175
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
176
+ snapshotOwnedSet(parseArgs(process.argv.slice(2)))
177
+ .then((result) => process.stdout.write(`${JSON.stringify(result, null, 2)}\n`))
178
+ .catch((error) => {
179
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
180
+ process.exit(1)
181
+ })
182
+ }
@@ -1 +0,0 @@
1
- import{l as o,a as r}from"../chunks/DHVET-3z.js";export{o as load_css,r as start};