@crombolo/data 0.0.0-stage → 0.10.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 (82) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +31 -4
  4. package/src/bundle-tables/builder.ts +264 -0
  5. package/src/bundle-tables/capability.ts +377 -0
  6. package/src/bundle-tables/chain.ts +105 -0
  7. package/src/bundle-tables/ddl.ts +124 -0
  8. package/src/bundle-tables/guard.ts +116 -0
  9. package/src/bundle-tables/index.ts +7 -0
  10. package/src/bundle-tables/names.ts +58 -0
  11. package/src/bundle-tables/schema.ts +485 -0
  12. package/src/change-report.ts +166 -0
  13. package/src/cluster.ts +108 -0
  14. package/src/database.ts +121 -0
  15. package/src/dialect.ts +136 -0
  16. package/src/index.ts +63 -0
  17. package/src/locks.ts +103 -0
  18. package/src/maintenance.ts +51 -0
  19. package/src/migrations.ts +547 -0
  20. package/src/node-ref.ts +107 -0
  21. package/src/pending.ts +25 -0
  22. package/src/repositories/bundles.ts +223 -0
  23. package/src/repositories/content-types.ts +831 -0
  24. package/src/repositories/data-types.ts +222 -0
  25. package/src/repositories/dictionary.ts +259 -0
  26. package/src/repositories/documents.ts +2389 -0
  27. package/src/repositories/domains.ts +108 -0
  28. package/src/repositories/folders.ts +120 -0
  29. package/src/repositories/form-entries.ts +314 -0
  30. package/src/repositories/form-workflows.ts +213 -0
  31. package/src/repositories/languages.ts +108 -0
  32. package/src/repositories/log-searches.ts +33 -0
  33. package/src/repositories/members.ts +427 -0
  34. package/src/repositories/nodes.ts +361 -0
  35. package/src/repositories/notifications.ts +54 -0
  36. package/src/repositories/public-access.ts +187 -0
  37. package/src/repositories/redirects.ts +274 -0
  38. package/src/repositories/references.ts +162 -0
  39. package/src/repositories/tags.ts +93 -0
  40. package/src/repositories/templates.ts +114 -0
  41. package/src/repositories/transfer-runs.ts +208 -0
  42. package/src/repositories/user-data.ts +90 -0
  43. package/src/repositories/users.ts +898 -0
  44. package/src/schema/assistant.ts +74 -0
  45. package/src/schema/built-in-content-types.ts +106 -0
  46. package/src/schema/built-in-media-types.ts +335 -0
  47. package/src/schema/built-in-member-types.ts +61 -0
  48. package/src/schema/bundle-components.ts +49 -0
  49. package/src/schema/bundle-package.ts +27 -0
  50. package/src/schema/bundles-rename.ts +32 -0
  51. package/src/schema/change-report.ts +46 -0
  52. package/src/schema/client-id-prefix.ts +43 -0
  53. package/src/schema/content-editing.ts +50 -0
  54. package/src/schema/content-seed.ts +586 -0
  55. package/src/schema/content.ts +263 -0
  56. package/src/schema/created-packages.ts +56 -0
  57. package/src/schema/elements.ts +62 -0
  58. package/src/schema/form-permission-prefix.ts +50 -0
  59. package/src/schema/form-permissions.ts +26 -0
  60. package/src/schema/form-workflows.ts +54 -0
  61. package/src/schema/forms.ts +63 -0
  62. package/src/schema/group-permissions.ts +20 -0
  63. package/src/schema/identity.ts +176 -0
  64. package/src/schema/log-viewer.ts +68 -0
  65. package/src/schema/member-property-type.ts +30 -0
  66. package/src/schema/members.ts +97 -0
  67. package/src/schema/plan.ts +77 -0
  68. package/src/schema/public-access.ts +56 -0
  69. package/src/schema/recycle-bin.ts +21 -0
  70. package/src/schema/redirects.ts +57 -0
  71. package/src/schema/rich-text-ui.ts +34 -0
  72. package/src/schema/seed.ts +298 -0
  73. package/src/schema/server-role.ts +21 -0
  74. package/src/schema/since-version.ts +26 -0
  75. package/src/schema/transfer-run-package.ts +27 -0
  76. package/src/schema/transfer-runs.ts +71 -0
  77. package/src/schema/upgrade-report.ts +43 -0
  78. package/src/schema/users-localisation.ts +85 -0
  79. package/src/schema/values.ts +108 -0
  80. package/src/schema-ops.ts +35 -0
  81. package/src/schema-state.ts +196 -0
  82. package/README.md +0 -3
@@ -0,0 +1,51 @@
1
+ /**
2
+ * A cluster-wide pause on editing, held in `key_value` so every node sees it on
3
+ * its next write rather than its next poll. Readers are never affected; the
4
+ * write gate (`assertNodeMayWrite`) refuses editor writes while it is set.
5
+ */
6
+ import type { Db } from './database.ts'
7
+ import { readKeyValue, writeKeyValue } from './migrations.ts'
8
+
9
+ export const WRITES_PAUSED_KEY = 'Crombolo.Operations.WritesPaused'
10
+
11
+ export interface WritesPaused {
12
+ reason: string
13
+ by: string | null
14
+ at: string
15
+ }
16
+
17
+ export function parseWritesPaused(value: string | null | undefined): WritesPaused | undefined {
18
+ if (!value) return undefined
19
+ try {
20
+ const parsed = JSON.parse(value) as Partial<WritesPaused>
21
+ return {
22
+ reason: String(parsed.reason ?? ''),
23
+ by: parsed.by ?? null,
24
+ at: String(parsed.at ?? ''),
25
+ }
26
+ } catch {
27
+ return { reason: '', by: null, at: '' }
28
+ }
29
+ }
30
+
31
+ export async function readWritesPaused(db: Db): Promise<WritesPaused | undefined> {
32
+ return parseWritesPaused(await readKeyValue(db, WRITES_PAUSED_KEY))
33
+ }
34
+
35
+ export async function pauseWrites(
36
+ db: Db,
37
+ options: { reason: string; by?: string },
38
+ ): Promise<WritesPaused> {
39
+ const paused: WritesPaused = {
40
+ reason: options.reason,
41
+ by: options.by ?? null,
42
+ at: new Date().toISOString(),
43
+ }
44
+ await writeKeyValue(db, WRITES_PAUSED_KEY, JSON.stringify(paused))
45
+ return paused
46
+ }
47
+
48
+ /** An empty value rather than a deleted row, so the key keeps its history in `update_date`. */
49
+ export async function resumeWrites(db: Db): Promise<void> {
50
+ await writeKeyValue(db, WRITES_PAUSED_KEY, '')
51
+ }
@@ -0,0 +1,547 @@
1
+ /**
2
+ * Umbraco tracks upgrade state as a single state token in `key_value`, not a
3
+ * version number and not a list of applied migrations: the plan is a chain of
4
+ * transitions walked forward to its final state. That design handles concurrent
5
+ * development branches far better than sequence numbers, so we keep it — and
6
+ * harden it (docs/packaging-and-upgrades.md): a ledger of every step that
7
+ * ran, expand/contract declared and linted, a recorded plan, and an explicit
8
+ * refusal when the database is newer than the code.
9
+ */
10
+ import type { Db } from './database.ts'
11
+ import type { Dialect } from './dialect.ts'
12
+ import type { LockManager } from './locks.ts'
13
+ import { Locks } from './locks.ts'
14
+
15
+ export const MIGRATION_STATE_KEY = 'Crombolo.Core.Upgrader.State'
16
+ export const INITIAL_STATE = '{00000000-0000-0000-0000-000000000000}'
17
+
18
+ export type MigrationKind = 'expand' | 'contract'
19
+
20
+ export interface Migration {
21
+ /** State the database must be in for this step to apply. */
22
+ from: string
23
+ /** State the database is in once this step has run. */
24
+ to: string
25
+ name: string
26
+ /** Expand adds and never removes; contract removes what an earlier release's expand added. Default expand. */
27
+ kind?: MigrationKind
28
+ /** The framework release that ships this step. Required for a contract and for what it contracts. */
29
+ release?: string
30
+ /** For a contract: the name of the expand migration it removes. */
31
+ contracts?: string
32
+ /** Recorded in the ledger, and compared when the plan verifies what ran. Defaults to `name`. */
33
+ checksum?: string
34
+ up(db: Db): Promise<void>
35
+ /**
36
+ * Writes data once `up` has run, in the same transaction. `planUpgrade` cannot
37
+ * predict what it writes, so a step that has one is reported as unplannable.
38
+ */
39
+ backfill?(db: Db): Promise<void>
40
+ }
41
+
42
+ /** Where a chain keeps its state, and what it calls itself in the ledger. */
43
+ export interface ChainOptions {
44
+ /** The `key_value` key holding the chain's state. */
45
+ stateKey?: string
46
+ /** The state before the first step. */
47
+ initialState?: string
48
+ /** A bundle's id, for a bundle's chain; absent for core's. */
49
+ bundle?: string
50
+ }
51
+
52
+ function compareRelease(a: string, b: string): number {
53
+ const pa = a.split(/[.+-]/).map(Number)
54
+ const pb = b.split(/[.+-]/).map(Number)
55
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
56
+ const da = pa[i] ?? 0
57
+ const db = pb[i] ?? 0
58
+ if (da !== db) return da < db ? -1 : 1
59
+ }
60
+ return 0
61
+ }
62
+
63
+ /**
64
+ * What a release promises a running cluster. `compatible`: every migration it
65
+ * ships is an expand, so the previous release keeps working against the
66
+ * migrated database and nodes can be rolled one at a time. `breaking`: it ships
67
+ * a contract, which removes something an older node may still read.
68
+ */
69
+ export type MigrationCompatibility = 'compatible' | 'breaking'
70
+
71
+ /** Derived, not asserted: `breaking` exactly when the release ships a contract. */
72
+ export function migrationCompatibility(
73
+ plan: MigrationPlan,
74
+ release: string,
75
+ ): MigrationCompatibility {
76
+ const contracts = plan.migrations.some(
77
+ (m) =>
78
+ m.kind === 'contract' && m.release !== undefined && compareRelease(m.release, release) === 0,
79
+ )
80
+ return contracts ? 'breaking' : 'compatible'
81
+ }
82
+
83
+ /** `x.0.0` with x ≥ 1. Pre-1.0 no release is major, so nothing may contract before 1.0.0. */
84
+ export function isMajorRelease(release: string): boolean {
85
+ const [major = 0, minor = 0, patch = 0] = release.split(/[.+-]/).map(Number)
86
+ return major >= 1 && minor === 0 && patch === 0
87
+ }
88
+
89
+ export class MigrationPlan {
90
+ readonly migrations: readonly Migration[]
91
+ readonly stateKey: string
92
+ readonly initialState: string
93
+ readonly bundle: string | undefined
94
+
95
+ constructor(migrations: readonly Migration[], options: ChainOptions = {}) {
96
+ this.migrations = migrations
97
+ this.stateKey = options.stateKey ?? MIGRATION_STATE_KEY
98
+ this.initialState = options.initialState ?? INITIAL_STATE
99
+ this.bundle = options.bundle
100
+ let expected = this.initialState
101
+ const seen = new Map<string, Migration>()
102
+ for (const migration of migrations) {
103
+ if (migration.from !== expected) {
104
+ throw new Error(
105
+ `Migration '${migration.name}' expects state ${migration.from} but the chain is at ${expected}.`,
106
+ )
107
+ }
108
+ if (migration.kind === 'contract') {
109
+ const expand = migration.contracts ? seen.get(migration.contracts) : undefined
110
+ if (!migration.contracts || !expand)
111
+ throw new Error(
112
+ `Contract migration '${migration.name}' must name the expand migration it removes.`,
113
+ )
114
+ if (expand.kind === 'contract')
115
+ throw new Error(
116
+ `Contract migration '${migration.name}' cannot contract another contract.`,
117
+ )
118
+ if (!migration.release || !expand.release)
119
+ throw new Error(
120
+ `Contract migration '${migration.name}' and '${expand.name}' must both declare a release.`,
121
+ )
122
+ if (compareRelease(expand.release, migration.release) >= 0)
123
+ throw new Error(
124
+ `Contract migration '${migration.name}' (${migration.release}) may only remove what an earlier release added; '${expand.name}' is ${expand.release}.`,
125
+ )
126
+ if (!isMajorRelease(migration.release))
127
+ throw new Error(
128
+ `Contract migration '${migration.name}' ships in ${migration.release}, but a contract may only ship in a major release (x.0.0): minors and patches are rolled under live nodes and must only add.`,
129
+ )
130
+ }
131
+ seen.set(migration.name, migration)
132
+ expected = migration.to
133
+ }
134
+ }
135
+
136
+ get finalState(): string {
137
+ return this.migrations.at(-1)?.to ?? this.initialState
138
+ }
139
+
140
+ /** The steps that have run when the chain is at a state; undefined when the state is not in it. */
141
+ appliedAt(state: string): Migration[] | undefined {
142
+ if (state === this.initialState) return []
143
+ const index = this.migrations.findIndex((m) => m.to === state)
144
+ return index < 0 ? undefined : this.migrations.slice(0, index + 1)
145
+ }
146
+
147
+ /** The steps still to run from a state; empty when current. Undefined when the state is not in the plan. */
148
+ pendingFrom(state: string): Migration[] | undefined {
149
+ if (state === this.finalState) return []
150
+ const index = this.migrations.findIndex((m) => m.from === state)
151
+ if (index < 0) return undefined
152
+ return this.migrations.slice(index)
153
+ }
154
+ }
155
+
156
+ /** Connections whose ledger tables are known to exist with every column. */
157
+ const ensured = new WeakSet<Db>()
158
+
159
+ async function ensureTables(db: Db): Promise<void> {
160
+ if (ensured.has(db)) return
161
+ const t = db.dialect.types
162
+ await db.exec(
163
+ `CREATE TABLE IF NOT EXISTS key_value (
164
+ key ${t.text} PRIMARY KEY,
165
+ value ${t.text},
166
+ update_date ${t.timestamp} NOT NULL
167
+ )`,
168
+ )
169
+ await db.exec(
170
+ `CREATE TABLE IF NOT EXISTS migration_history (
171
+ id ${t.identity},
172
+ name ${t.varchar(255)} NOT NULL,
173
+ kind ${t.varchar(20)} NOT NULL,
174
+ from_state ${t.varchar(255)},
175
+ to_state ${t.varchar(255)},
176
+ applied_at ${t.timestamp} NOT NULL,
177
+ duration_ms ${t.integer} NOT NULL,
178
+ checksum ${t.varchar(64)},
179
+ applied_by ${t.varchar(255)},
180
+ note ${t.text},
181
+ bundle ${t.varchar(64)}
182
+ )`,
183
+ )
184
+ // A ledger from before bundles could own tables has no `bundle` column. It is
185
+ // added here, where the table is, rather than by a core migration: the ledger
186
+ // has to be able to record a bundle's step whatever state core is at.
187
+ if (!(await hasColumn(db, 'migration_history', 'bundle')))
188
+ await db.exec(`ALTER TABLE migration_history ADD COLUMN bundle ${t.varchar(64)}`)
189
+ ensured.add(db)
190
+ }
191
+
192
+ async function hasColumn(db: Db, table: string, column: string): Promise<boolean> {
193
+ const rows =
194
+ db.dialect.name === 'sqlite'
195
+ ? await db.query<{ name: string }>(`SELECT name FROM pragma_table_info('${table}')`)
196
+ : await db.query<{ name: string }>(
197
+ 'SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?',
198
+ [table],
199
+ )
200
+ return rows.some((row) => row.name === column)
201
+ }
202
+
203
+ /** A chain's state: core's unless a plan says otherwise. */
204
+ export async function readState(
205
+ db: Db,
206
+ chain: Pick<MigrationPlan, 'stateKey' | 'initialState'> = {
207
+ stateKey: MIGRATION_STATE_KEY,
208
+ initialState: INITIAL_STATE,
209
+ },
210
+ ): Promise<string> {
211
+ await ensureTables(db)
212
+ const rows = await db.query<{ value: string }>('SELECT value FROM key_value WHERE key = ?', [
213
+ chain.stateKey,
214
+ ])
215
+ return rows[0]?.value ?? chain.initialState
216
+ }
217
+
218
+ async function writeState(db: Db, state: string, key = MIGRATION_STATE_KEY): Promise<void> {
219
+ const now = db.dialect.now()
220
+ await db.query(`UPDATE key_value SET value = ?, update_date = ${now} WHERE key = ?`, [state, key])
221
+ const rows = await db.query<{ value: string }>('SELECT value FROM key_value WHERE key = ?', [key])
222
+ if (rows.length === 0) {
223
+ await db.exec(
224
+ `INSERT INTO key_value (key, value, update_date) VALUES (${db.dialect.placeholder(1)}, ${db.dialect.placeholder(2)}, ${now})`,
225
+ [key, state],
226
+ )
227
+ }
228
+ }
229
+
230
+ /** Forgets a setting entirely, as a purged bundle's state is forgotten. */
231
+ export async function deleteKeyValue(db: Db, key: string): Promise<void> {
232
+ await ensureTables(db)
233
+ await db.exec('DELETE FROM key_value WHERE key = ?', [key])
234
+ }
235
+
236
+ export interface LedgerEntry {
237
+ name: string
238
+ /** 'expand' | 'contract' for framework steps; 'value' for a value migration; 'upgrade' for a cut-over. */
239
+ kind: string
240
+ fromState?: string | null
241
+ toState?: string | null
242
+ durationMs: number
243
+ checksum?: string | null
244
+ appliedBy?: string | null
245
+ note?: string | null
246
+ /** The bundle whose chain this step belongs to; null for core. */
247
+ bundle?: string | null
248
+ }
249
+
250
+ /**
251
+ * A setting in `key_value`, Umbraco's own place for one, read and written as a
252
+ * string. Used for facts a node must agree on across restarts and across a load
253
+ * balanced set — the key member session cookies are signed with, for one.
254
+ */
255
+ export async function readKeyValue(db: Db, key: string): Promise<string | undefined> {
256
+ await ensureTables(db)
257
+ const rows = await db.query<{ value: string | null }>(
258
+ 'SELECT value FROM key_value WHERE key = ?',
259
+ [key],
260
+ )
261
+ return rows[0]?.value ?? undefined
262
+ }
263
+
264
+ export async function writeKeyValue(db: Db, key: string, value: string): Promise<void> {
265
+ await ensureTables(db)
266
+ const now = db.dialect.now()
267
+ await db.exec(`UPDATE key_value SET value = ?, update_date = ${now} WHERE key = ?`, [value, key])
268
+ const rows = await db.query('SELECT value FROM key_value WHERE key = ?', [key])
269
+ if (rows.length === 0)
270
+ await db.exec(`INSERT INTO key_value (key, value, update_date) VALUES (?, ?, ${now})`, [
271
+ key,
272
+ value,
273
+ ])
274
+ }
275
+
276
+ /**
277
+ * Reads a setting, generating and storing one the first time it is asked for.
278
+ * Two nodes racing settle on whichever row landed first.
279
+ */
280
+ export async function ensureKeyValue(db: Db, key: string, create: () => string): Promise<string> {
281
+ const existing = await readKeyValue(db, key)
282
+ if (existing) return existing
283
+ await writeKeyValue(db, key, create())
284
+ return (await readKeyValue(db, key)) ?? ''
285
+ }
286
+
287
+ /** Appends a row to `migration_history`. */
288
+ export async function recordInLedger(db: Db, entry: LedgerEntry): Promise<void> {
289
+ await ensureTables(db)
290
+ await db.exec(
291
+ `INSERT INTO migration_history (name, kind, from_state, to_state, applied_at, duration_ms, checksum, applied_by, note, bundle)
292
+ VALUES (?, ?, ?, ?, ${db.dialect.now()}, ?, ?, ?, ?, ?)`,
293
+ [
294
+ entry.name,
295
+ entry.kind,
296
+ entry.fromState ?? null,
297
+ entry.toState ?? null,
298
+ entry.durationMs,
299
+ entry.checksum ?? null,
300
+ entry.appliedBy ?? null,
301
+ entry.note ?? null,
302
+ entry.bundle ?? null,
303
+ ],
304
+ )
305
+ }
306
+
307
+ /** The newest ledger row's id, so a node can later ask what was ledgered after it booted. */
308
+ export async function latestLedgerId(db: Db): Promise<number> {
309
+ await ensureTables(db)
310
+ const rows = await db.query<{ n: number | null }>('SELECT MAX(id) AS n FROM migration_history')
311
+ return Number(rows[0]?.n ?? 0)
312
+ }
313
+
314
+ /**
315
+ * How many contract steps — framework contracts and site purges — were ledgered
316
+ * after a row id. A bundle's contract is not counted: it removes only the
317
+ * bundle's own tables, which no read of core's depends on.
318
+ */
319
+ export async function contractsLedgeredAfter(db: Db, afterId: number): Promise<number> {
320
+ const rows = await db.query<{ n: number }>(
321
+ "SELECT COUNT(*) AS n FROM migration_history WHERE id > ? AND kind = 'contract' AND bundle IS NULL",
322
+ [afterId],
323
+ )
324
+ return Number(rows[0]?.n ?? 0)
325
+ }
326
+
327
+ export interface LedgerRow extends LedgerEntry {
328
+ id: number
329
+ appliedAt: string
330
+ }
331
+
332
+ export async function readLedger(db: Db): Promise<LedgerRow[]> {
333
+ await ensureTables(db)
334
+ const rows = await db.query(
335
+ 'SELECT id, name, kind, from_state, to_state, applied_at, duration_ms, checksum, applied_by, note, bundle FROM migration_history ORDER BY id',
336
+ )
337
+ return rows.map((row) => ({
338
+ id: Number(row.id),
339
+ name: String(row.name),
340
+ kind: String(row.kind),
341
+ fromState: (row.from_state as string | null) ?? null,
342
+ toState: (row.to_state as string | null) ?? null,
343
+ appliedAt: String(row.applied_at),
344
+ durationMs: Number(row.duration_ms),
345
+ checksum: (row.checksum as string | null) ?? null,
346
+ appliedBy: (row.applied_by as string | null) ?? null,
347
+ note: (row.note as string | null) ?? null,
348
+ bundle: (row.bundle as string | null) ?? null,
349
+ }))
350
+ }
351
+
352
+ /** The database is at a state this code's plan does not know: newer code wrote it. */
353
+ export class UpgradeStateError extends Error {
354
+ constructor(
355
+ readonly databaseState: string,
356
+ readonly codeState: string,
357
+ /** The bundle whose chain it is, when it is not core's. */
358
+ readonly bundle?: string,
359
+ ) {
360
+ super(
361
+ bundle
362
+ ? `The database holds the bundle "${bundle}"'s tables at state ${databaseState}, which this version of the bundle (its steps end at ${codeState}) does not know. ` +
363
+ 'A newer release of the bundle wrote them; install that release, or restore the backup taken before the upgrade.'
364
+ : `The database is at migration state ${databaseState}, which this version of crombolo (plan ends at ${codeState}) does not know. ` +
365
+ 'It was upgraded by a newer version; run that version, or restore the backup taken before the upgrade.',
366
+ )
367
+ }
368
+ }
369
+
370
+ /** A bundle step changed after it ran here: what is in the database is not what the code says made it. */
371
+ export class BundleStepEditedError extends Error {
372
+ constructor(
373
+ readonly bundle: string,
374
+ readonly step: string,
375
+ ) {
376
+ super(
377
+ `The bundle "${bundle}"'s step "${step}" is not the step that ran on this database: it was edited after it shipped. ` +
378
+ 'A shipped step is never changed; put the change in a new step.',
379
+ )
380
+ }
381
+ }
382
+
383
+ /** Steps pending on this database, in order. Throws when the database is ahead of the code. */
384
+ export async function pendingMigrations(db: Db, plan: MigrationPlan): Promise<Migration[]> {
385
+ const state = await readState(db, plan)
386
+ const pending = plan.pendingFrom(state)
387
+ if (!pending) throw new UpgradeStateError(state, plan.finalState, plan.bundle)
388
+ return pending
389
+ }
390
+
391
+ /**
392
+ * Refuses a bundle chain whose applied steps no longer match the code. Only the
393
+ * rows since the bundle was last purged count: a purge starts its chain again.
394
+ */
395
+ async function verifyApplied(db: Db, plan: MigrationPlan, state: string): Promise<void> {
396
+ if (!plan.bundle) return
397
+ const applied = plan.appliedAt(state) ?? []
398
+ if (applied.length === 0) return
399
+ const rows = await db.query<{ name: string; kind: string; checksum: string | null }>(
400
+ 'SELECT name, kind, checksum FROM migration_history WHERE bundle = ? ORDER BY id',
401
+ [plan.bundle],
402
+ )
403
+ const purged = rows.map((row) => row.kind).lastIndexOf('purge')
404
+ const ran = new Map(
405
+ rows
406
+ .slice(purged + 1)
407
+ .filter((row) => row.kind === 'expand' || row.kind === 'contract')
408
+ .map((row) => [row.name, row.checksum]),
409
+ )
410
+ for (const step of applied) {
411
+ const recorded = ran.get(step.name)
412
+ if (recorded !== undefined && recorded !== (step.checksum ?? step.name))
413
+ throw new BundleStepEditedError(plan.bundle, step.name)
414
+ }
415
+ }
416
+
417
+ export interface MigrateResult {
418
+ from: string
419
+ to: string
420
+ applied: string[]
421
+ }
422
+
423
+ export interface MigrateOptions {
424
+ /** Recorded as `applied_by` in the ledger. */
425
+ by?: string
426
+ }
427
+
428
+ /** Walks the plan forward from the database's current state, one transaction per step, ledgering each. */
429
+ export async function migrate(
430
+ db: Db,
431
+ plan: MigrationPlan,
432
+ options: MigrateOptions = {},
433
+ ): Promise<MigrateResult> {
434
+ return db.locks.withLock(Locks.KeyValues, async () => {
435
+ const from = await readState(db, plan)
436
+ let state = from
437
+ const applied: string[] = []
438
+ const pending = plan.pendingFrom(state)
439
+ if (!pending) throw new UpgradeStateError(state, plan.finalState, plan.bundle)
440
+ await verifyApplied(db, plan, state)
441
+ for (const next of pending) {
442
+ const started = performance.now()
443
+ await db.transaction(async (tx) => {
444
+ await next.up(tx)
445
+ await next.backfill?.(tx)
446
+ })
447
+ state = next.to
448
+ await writeState(db, state, plan.stateKey)
449
+ await recordInLedger(db, {
450
+ name: next.name,
451
+ kind: next.kind ?? 'expand',
452
+ fromState: next.from,
453
+ toState: next.to,
454
+ durationMs: Math.round(performance.now() - started),
455
+ checksum: next.checksum ?? next.name,
456
+ appliedBy: options.by ?? null,
457
+ note: next.release ? `release ${next.release}` : null,
458
+ bundle: plan.bundle ?? null,
459
+ })
460
+ applied.push(next.name)
461
+ }
462
+ return { from, to: state, applied }
463
+ })
464
+ }
465
+
466
+ /** A fresh install stamps the final state so no migrations then run. */
467
+ export async function stampFinalState(db: Db, plan: MigrationPlan): Promise<void> {
468
+ await ensureTables(db)
469
+ await writeState(db, plan.finalState, plan.stateKey)
470
+ }
471
+
472
+ // ---------------------------------------------------------------- the plan
473
+
474
+ /** Thrown by the recording database when a migration reads: its writes cannot be predicted. */
475
+ export class UnplannableError extends Error {
476
+ constructor(readonly sql: string) {
477
+ super(`reads data to decide what to write: ${sql.trim().split('\n')[0]}`)
478
+ }
479
+ }
480
+
481
+ /** A `Db` that captures statements instead of executing them. */
482
+ class RecordingDb implements Db {
483
+ readonly dialect: Dialect
484
+ readonly locks: LockManager
485
+ readonly statements: string[]
486
+
487
+ constructor(dialect: Dialect, locks: LockManager, statements: string[] = []) {
488
+ this.dialect = dialect
489
+ this.locks = locks
490
+ this.statements = statements
491
+ }
492
+
493
+ async query<T = Record<string, unknown>>(sql: string): Promise<T[]> {
494
+ throw new UnplannableError(sql)
495
+ }
496
+
497
+ async exec(sql: string, params: unknown[] = []): Promise<void> {
498
+ const rendered = this.dialect.render(sql)
499
+ this.statements.push(
500
+ params.length > 0 ? `${rendered} -- params: ${JSON.stringify(params)}` : rendered,
501
+ )
502
+ }
503
+
504
+ async transaction<T>(fn: (tx: Db) => Promise<T>): Promise<T> {
505
+ return fn(this)
506
+ }
507
+
508
+ async close(): Promise<void> {}
509
+ }
510
+
511
+ export interface PlannedStep {
512
+ name: string
513
+ kind: MigrationKind
514
+ release: string | null
515
+ from: string
516
+ to: string
517
+ /** The DDL this step would run, or undefined when it reads data to decide. */
518
+ statements: string[] | undefined
519
+ unplannable?: string
520
+ }
521
+
522
+ /** What `migrate()` would do, without doing it. */
523
+ export async function planUpgrade(db: Db, plan: MigrationPlan): Promise<PlannedStep[]> {
524
+ const pending = await pendingMigrations(db, plan)
525
+ const steps: PlannedStep[] = []
526
+ for (const migration of pending) {
527
+ const recorder = new RecordingDb(db.dialect, db.locks)
528
+ const step: PlannedStep = {
529
+ name: migration.name,
530
+ kind: migration.kind ?? 'expand',
531
+ release: migration.release ?? null,
532
+ from: migration.from,
533
+ to: migration.to,
534
+ statements: undefined,
535
+ }
536
+ try {
537
+ await migration.up(recorder)
538
+ step.statements = recorder.statements
539
+ if (migration.backfill) step.unplannable = 'backfills data, which only running it can show'
540
+ } catch (error) {
541
+ if (error instanceof UnplannableError) step.unplannable = error.message
542
+ else throw error
543
+ }
544
+ steps.push(step)
545
+ }
546
+ return steps
547
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * A node named on a command line: either its uuid, or a path through the tree
3
+ * by name.
4
+ *
5
+ * Paths exist because nobody hand-types `9a1f3c2e-…`, and the person running a
6
+ * transfer knows where content sits, not what its key is. One resolver serves
7
+ * `content export --root` and `content import --under` so the two cannot drift
8
+ * in what they accept.
9
+ *
10
+ * Three rules keep the convenience from becoming ambiguity:
11
+ *
12
+ * - a uuid-shaped argument is always a uuid, never a path, so a path can never
13
+ * be mistaken for a key or the other way round;
14
+ * - a path matches case-insensitively by node name, from the content root, with
15
+ * a leading slash optional;
16
+ * - a path matching nothing, or more than one node, is an error that names the
17
+ * candidates. Guessing here would place content somewhere nobody chose.
18
+ */
19
+ import { SystemNodes } from '@crombolo/core'
20
+ import type { Db } from './database.ts'
21
+ import { NodeRepository, type NodeRow } from './repositories/nodes.ts'
22
+
23
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
24
+
25
+ export type NodeRefResolution =
26
+ | { ok: true; node: NodeRow }
27
+ | {
28
+ ok: false
29
+ reason: 'not-found' | 'ambiguous'
30
+ /** The segment that failed, or the whole reference when it was a key. */
31
+ at: string
32
+ /** What was there instead, to print alongside the failure. */
33
+ candidates: string[]
34
+ }
35
+
36
+ export const looksLikeKey = (ref: string): boolean => UUID.test(ref.trim())
37
+
38
+ /** `/Campaigns/Autumn 2026` → `['Campaigns', 'Autumn 2026']` */
39
+ export function pathSegments(ref: string): string[] {
40
+ return ref
41
+ .split('/')
42
+ .map((segment) => segment.trim())
43
+ .filter((segment) => segment.length > 0)
44
+ }
45
+
46
+ /**
47
+ * Resolves a reference against the tree of `objectTypes`. Pass every object type
48
+ * a segment could name — a media path runs through media folders, and a document
49
+ * path through documents only.
50
+ */
51
+ export async function resolveNodeRef(
52
+ db: Db,
53
+ objectTypes: readonly string[],
54
+ ref: string,
55
+ ): Promise<NodeRefResolution> {
56
+ const nodes = new NodeRepository(db)
57
+ const trimmed = ref.trim()
58
+ if (looksLikeKey(trimmed)) {
59
+ const node = await nodes.byKey(trimmed)
60
+ return node
61
+ ? { ok: true, node }
62
+ : { ok: false, reason: 'not-found', at: trimmed, candidates: [] }
63
+ }
64
+
65
+ const segments = pathSegments(trimmed)
66
+ if (segments.length === 0) return { ok: false, reason: 'not-found', at: trimmed, candidates: [] }
67
+
68
+ let parentId: number = SystemNodes.Root
69
+ let resolved: NodeRow | undefined
70
+ for (const segment of segments) {
71
+ const matches = await nodes.childrenNamed(parentId, objectTypes, segment)
72
+ if (matches.length === 0)
73
+ return {
74
+ ok: false,
75
+ reason: 'not-found',
76
+ at: segment,
77
+ candidates: await nodes.childNames(parentId, objectTypes),
78
+ }
79
+ if (matches.length > 1)
80
+ return {
81
+ ok: false,
82
+ reason: 'ambiguous',
83
+ at: segment,
84
+ // Two siblings of one name: the keys are the only way to tell them apart.
85
+ candidates: matches.map((m) => m.key),
86
+ }
87
+ resolved = matches[0] as NodeRow
88
+ parentId = resolved.id
89
+ }
90
+ return resolved
91
+ ? { ok: true, node: resolved }
92
+ : { ok: false, reason: 'not-found', at: trimmed, candidates: [] }
93
+ }
94
+
95
+ /** The message a CLI or a finding prints for a failed resolution. */
96
+ export function describeRefFailure(ref: string, failure: NodeRefResolution): string {
97
+ if (failure.ok) return ''
98
+ if (failure.reason === 'ambiguous')
99
+ return `"${ref}": more than one node is named "${failure.at}" here — name one by key: ${failure.candidates.join(', ')}`
100
+ const there =
101
+ failure.candidates.length > 0
102
+ ? ` — there is ${failure.candidates.map((c) => `"${c}"`).join(', ')}`
103
+ : ''
104
+ return looksLikeKey(ref)
105
+ ? `"${ref}": no node with that key here`
106
+ : `"${ref}": nothing named "${failure.at}"${there}`
107
+ }