@crombolo/data 0.0.0-stage → 0.9.1

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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +31 -4
  4. package/src/change-report.ts +166 -0
  5. package/src/cluster.ts +108 -0
  6. package/src/database.ts +121 -0
  7. package/src/dialect.ts +128 -0
  8. package/src/index.ts +60 -0
  9. package/src/locks.ts +103 -0
  10. package/src/maintenance.ts +51 -0
  11. package/src/migrations.ts +431 -0
  12. package/src/node-ref.ts +107 -0
  13. package/src/pending.ts +25 -0
  14. package/src/repositories/bundles.ts +191 -0
  15. package/src/repositories/content-types.ts +831 -0
  16. package/src/repositories/data-types.ts +222 -0
  17. package/src/repositories/dictionary.ts +259 -0
  18. package/src/repositories/documents.ts +2389 -0
  19. package/src/repositories/domains.ts +108 -0
  20. package/src/repositories/folders.ts +120 -0
  21. package/src/repositories/form-entries.ts +314 -0
  22. package/src/repositories/form-workflows.ts +213 -0
  23. package/src/repositories/languages.ts +108 -0
  24. package/src/repositories/log-searches.ts +33 -0
  25. package/src/repositories/members.ts +427 -0
  26. package/src/repositories/nodes.ts +361 -0
  27. package/src/repositories/notifications.ts +54 -0
  28. package/src/repositories/public-access.ts +187 -0
  29. package/src/repositories/redirects.ts +274 -0
  30. package/src/repositories/references.ts +162 -0
  31. package/src/repositories/tags.ts +93 -0
  32. package/src/repositories/templates.ts +114 -0
  33. package/src/repositories/transfer-runs.ts +190 -0
  34. package/src/repositories/user-data.ts +90 -0
  35. package/src/repositories/users.ts +898 -0
  36. package/src/schema/assistant.ts +74 -0
  37. package/src/schema/built-in-content-types.ts +106 -0
  38. package/src/schema/built-in-media-types.ts +335 -0
  39. package/src/schema/built-in-member-types.ts +61 -0
  40. package/src/schema/bundle-components.ts +49 -0
  41. package/src/schema/bundles-rename.ts +32 -0
  42. package/src/schema/change-report.ts +46 -0
  43. package/src/schema/client-id-prefix.ts +43 -0
  44. package/src/schema/content-editing.ts +50 -0
  45. package/src/schema/content-seed.ts +586 -0
  46. package/src/schema/content.ts +263 -0
  47. package/src/schema/created-packages.ts +56 -0
  48. package/src/schema/elements.ts +62 -0
  49. package/src/schema/form-permission-prefix.ts +50 -0
  50. package/src/schema/form-permissions.ts +26 -0
  51. package/src/schema/form-workflows.ts +54 -0
  52. package/src/schema/forms.ts +63 -0
  53. package/src/schema/group-permissions.ts +20 -0
  54. package/src/schema/identity.ts +176 -0
  55. package/src/schema/log-viewer.ts +68 -0
  56. package/src/schema/member-property-type.ts +30 -0
  57. package/src/schema/members.ts +97 -0
  58. package/src/schema/plan.ts +73 -0
  59. package/src/schema/public-access.ts +56 -0
  60. package/src/schema/recycle-bin.ts +21 -0
  61. package/src/schema/redirects.ts +57 -0
  62. package/src/schema/rich-text-ui.ts +34 -0
  63. package/src/schema/seed.ts +298 -0
  64. package/src/schema/server-role.ts +21 -0
  65. package/src/schema/since-version.ts +26 -0
  66. package/src/schema/transfer-runs.ts +71 -0
  67. package/src/schema/upgrade-report.ts +43 -0
  68. package/src/schema/users-localisation.ts +85 -0
  69. package/src/schema/values.ts +108 -0
  70. package/src/schema-ops.ts +35 -0
  71. package/src/schema-state.ts +196 -0
  72. package/README.md +0 -3
package/src/index.ts ADDED
@@ -0,0 +1,60 @@
1
+ export * from './change-report.ts'
2
+ export * from './cluster.ts'
3
+ export * from './database.ts'
4
+ export * from './dialect.ts'
5
+ export * from './locks.ts'
6
+ export * from './maintenance.ts'
7
+ export * from './migrations.ts'
8
+ export * from './node-ref.ts'
9
+ export * from './pending.ts'
10
+ export * from './repositories/bundles.ts'
11
+ export * from './repositories/content-types.ts'
12
+ export * from './repositories/data-types.ts'
13
+ export * from './repositories/dictionary.ts'
14
+ export * from './repositories/documents.ts'
15
+ export * from './repositories/domains.ts'
16
+ export * from './repositories/folders.ts'
17
+ export * from './repositories/form-entries.ts'
18
+ export * from './repositories/form-workflows.ts'
19
+ export * from './repositories/languages.ts'
20
+ export * from './repositories/log-searches.ts'
21
+ export * from './repositories/members.ts'
22
+ export * from './repositories/nodes.ts'
23
+ export * from './repositories/notifications.ts'
24
+ export * from './repositories/public-access.ts'
25
+ export * from './repositories/redirects.ts'
26
+ export * from './repositories/references.ts'
27
+ export * from './repositories/tags.ts'
28
+ export * from './repositories/templates.ts'
29
+ export * from './repositories/transfer-runs.ts'
30
+ export * from './repositories/user-data.ts'
31
+ export * from './repositories/users.ts'
32
+ export * from './schema/assistant.ts'
33
+ export * from './schema/built-in-content-types.ts'
34
+ export * from './schema/built-in-media-types.ts'
35
+ export * from './schema/built-in-member-types.ts'
36
+ export * from './schema/bundles-rename.ts'
37
+ export * from './schema/change-report.ts'
38
+ export * from './schema/client-id-prefix.ts'
39
+ export * from './schema/content.ts'
40
+ export * from './schema/content-seed.ts'
41
+ export * from './schema/created-packages.ts'
42
+ export * from './schema/elements.ts'
43
+ export * from './schema/form-permission-prefix.ts'
44
+ export * from './schema/group-permissions.ts'
45
+ export * from './schema/identity.ts'
46
+ export * from './schema/log-viewer.ts'
47
+ export * from './schema/member-property-type.ts'
48
+ export * from './schema/members.ts'
49
+ export * from './schema/plan.ts'
50
+ export * from './schema/public-access.ts'
51
+ export * from './schema/recycle-bin.ts'
52
+ export * from './schema/rich-text-ui.ts'
53
+ export * from './schema/seed.ts'
54
+ export * from './schema/since-version.ts'
55
+ export * from './schema/transfer-runs.ts'
56
+ export * from './schema/upgrade-report.ts'
57
+ export * from './schema/users-localisation.ts'
58
+ export * from './schema/values.ts'
59
+ export * from './schema-ops.ts'
60
+ export * from './schema-state.ts'
package/src/locks.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Named write locks around content-tree mutation. Umbraco takes a row lock per
3
+ * lock id; SQLite has a single writer, so an in-process queue is both sufficient
4
+ * and necessary, while Postgres uses advisory locks.
5
+ */
6
+ import { AsyncLocalStorage } from 'node:async_hooks'
7
+ import type { Db } from './database.ts'
8
+
9
+ /** Mirrors Umbraco's Constants.Locks. */
10
+ export const Locks = {
11
+ Servers: -331,
12
+ ContentTypes: -332,
13
+ ContentTree: -333,
14
+ MediaTree: -334,
15
+ MemberTree: -335,
16
+ MediaTypes: -336,
17
+ MemberTypes: -337,
18
+ Domains: -338,
19
+ KeyValues: -339,
20
+ Languages: -340,
21
+ ScheduledPublishing: -341,
22
+ /** Ours: schema sync and schema-state transitions. */
23
+ Schema: -342,
24
+ } as const
25
+
26
+ export type LockId = (typeof Locks)[keyof typeof Locks]
27
+
28
+ export interface LockManager {
29
+ /** Runs `fn` while holding the named lock. Re-entrant within one chain. */
30
+ withLock<T>(id: LockId, fn: () => Promise<T>): Promise<T>
31
+ }
32
+
33
+ /**
34
+ * The locks the current async chain holds. A holder is emptied on release, so
35
+ * work the holder left running in the background does not inherit the lock.
36
+ */
37
+ const held = new AsyncLocalStorage<Set<LockId>>()
38
+
39
+ /** Runs `fn` directly when this chain already holds `id`; otherwise takes it. */
40
+ async function reentrant<T>(
41
+ id: LockId,
42
+ fn: () => Promise<T>,
43
+ acquire: (run: () => Promise<T>) => Promise<T>,
44
+ ): Promise<T> {
45
+ const current = held.getStore()
46
+ if (current?.has(id)) return fn()
47
+ return acquire(async () => {
48
+ const holding = new Set(current)
49
+ holding.add(id)
50
+ try {
51
+ return await held.run(holding, fn)
52
+ } finally {
53
+ holding.clear()
54
+ }
55
+ })
56
+ }
57
+
58
+ class SqliteLockManager implements LockManager {
59
+ #queues = new Map<number, Promise<unknown>>()
60
+
61
+ withLock<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
62
+ return reentrant(id, fn, (run) => this.#acquire(id, run))
63
+ }
64
+
65
+ async #acquire<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
66
+ const previous = this.#queues.get(id) ?? Promise.resolve()
67
+ let release!: () => void
68
+ const gate = new Promise<void>((resolve) => {
69
+ release = resolve
70
+ })
71
+ this.#queues.set(
72
+ id,
73
+ previous.then(() => gate),
74
+ )
75
+ await previous
76
+ try {
77
+ return await fn()
78
+ } finally {
79
+ release()
80
+ if (this.#queues.get(id) === gate) this.#queues.delete(id)
81
+ }
82
+ }
83
+ }
84
+
85
+ class PostgresLockManager implements LockManager {
86
+ #db: Db
87
+ constructor(db: Db) {
88
+ this.#db = db
89
+ }
90
+
91
+ withLock<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
92
+ return reentrant(id, fn, (run) =>
93
+ this.#db.transaction(async (tx) => {
94
+ await tx.exec('SELECT pg_advisory_xact_lock(?)', [id])
95
+ return run()
96
+ }),
97
+ )
98
+ }
99
+ }
100
+
101
+ export function createLockManager(db: Db): LockManager {
102
+ return db.dialect.name === 'postgres' ? new PostgresLockManager(db) : new SqliteLockManager()
103
+ }
@@ -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,431 @@
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/10-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
+ up(db: Db): Promise<void>
33
+ }
34
+
35
+ function compareRelease(a: string, b: string): number {
36
+ const pa = a.split(/[.+-]/).map(Number)
37
+ const pb = b.split(/[.+-]/).map(Number)
38
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
39
+ const da = pa[i] ?? 0
40
+ const db = pb[i] ?? 0
41
+ if (da !== db) return da < db ? -1 : 1
42
+ }
43
+ return 0
44
+ }
45
+
46
+ /**
47
+ * What a release promises a running cluster. `compatible`: every migration it
48
+ * ships is an expand, so the previous release keeps working against the
49
+ * migrated database and nodes can be rolled one at a time. `breaking`: it ships
50
+ * a contract, which removes something an older node may still read.
51
+ */
52
+ export type MigrationCompatibility = 'compatible' | 'breaking'
53
+
54
+ /** Derived, not asserted: `breaking` exactly when the release ships a contract. */
55
+ export function migrationCompatibility(
56
+ plan: MigrationPlan,
57
+ release: string,
58
+ ): MigrationCompatibility {
59
+ const contracts = plan.migrations.some(
60
+ (m) =>
61
+ m.kind === 'contract' && m.release !== undefined && compareRelease(m.release, release) === 0,
62
+ )
63
+ return contracts ? 'breaking' : 'compatible'
64
+ }
65
+
66
+ /** `x.0.0` with x ≥ 1. Pre-1.0 no release is major, so nothing may contract before 1.0.0. */
67
+ export function isMajorRelease(release: string): boolean {
68
+ const [major = 0, minor = 0, patch = 0] = release.split(/[.+-]/).map(Number)
69
+ return major >= 1 && minor === 0 && patch === 0
70
+ }
71
+
72
+ export class MigrationPlan {
73
+ readonly migrations: readonly Migration[]
74
+
75
+ constructor(migrations: readonly Migration[]) {
76
+ this.migrations = migrations
77
+ let expected = INITIAL_STATE
78
+ const seen = new Map<string, Migration>()
79
+ for (const migration of migrations) {
80
+ if (migration.from !== expected) {
81
+ throw new Error(
82
+ `Migration '${migration.name}' expects state ${migration.from} but the chain is at ${expected}.`,
83
+ )
84
+ }
85
+ if (migration.kind === 'contract') {
86
+ const expand = migration.contracts ? seen.get(migration.contracts) : undefined
87
+ if (!migration.contracts || !expand)
88
+ throw new Error(
89
+ `Contract migration '${migration.name}' must name the expand migration it removes.`,
90
+ )
91
+ if (expand.kind === 'contract')
92
+ throw new Error(
93
+ `Contract migration '${migration.name}' cannot contract another contract.`,
94
+ )
95
+ if (!migration.release || !expand.release)
96
+ throw new Error(
97
+ `Contract migration '${migration.name}' and '${expand.name}' must both declare a release.`,
98
+ )
99
+ if (compareRelease(expand.release, migration.release) >= 0)
100
+ throw new Error(
101
+ `Contract migration '${migration.name}' (${migration.release}) may only remove what an earlier release added; '${expand.name}' is ${expand.release}.`,
102
+ )
103
+ if (!isMajorRelease(migration.release))
104
+ throw new Error(
105
+ `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.`,
106
+ )
107
+ }
108
+ seen.set(migration.name, migration)
109
+ expected = migration.to
110
+ }
111
+ }
112
+
113
+ get finalState(): string {
114
+ return this.migrations.at(-1)?.to ?? INITIAL_STATE
115
+ }
116
+
117
+ /** The steps still to run from a state; empty when current. Undefined when the state is not in the plan. */
118
+ pendingFrom(state: string): Migration[] | undefined {
119
+ if (state === this.finalState) return []
120
+ const index = this.migrations.findIndex((m) => m.from === state)
121
+ if (index < 0) return undefined
122
+ return this.migrations.slice(index)
123
+ }
124
+ }
125
+
126
+ async function ensureTables(db: Db): Promise<void> {
127
+ const t = db.dialect.types
128
+ await db.exec(
129
+ `CREATE TABLE IF NOT EXISTS key_value (
130
+ key ${t.text} PRIMARY KEY,
131
+ value ${t.text},
132
+ update_date ${t.timestamp} NOT NULL
133
+ )`,
134
+ )
135
+ await db.exec(
136
+ `CREATE TABLE IF NOT EXISTS migration_history (
137
+ id ${t.identity},
138
+ name ${t.varchar(255)} NOT NULL,
139
+ kind ${t.varchar(20)} NOT NULL,
140
+ from_state ${t.varchar(255)},
141
+ to_state ${t.varchar(255)},
142
+ applied_at ${t.timestamp} NOT NULL,
143
+ duration_ms ${t.integer} NOT NULL,
144
+ checksum ${t.varchar(64)},
145
+ applied_by ${t.varchar(255)},
146
+ note ${t.text}
147
+ )`,
148
+ )
149
+ }
150
+
151
+ export async function readState(db: Db): Promise<string> {
152
+ await ensureTables(db)
153
+ const rows = await db.query<{ value: string }>('SELECT value FROM key_value WHERE key = ?', [
154
+ MIGRATION_STATE_KEY,
155
+ ])
156
+ return rows[0]?.value ?? INITIAL_STATE
157
+ }
158
+
159
+ async function writeState(db: Db, state: string): Promise<void> {
160
+ const now = db.dialect.now()
161
+ await db.query(`UPDATE key_value SET value = ?, update_date = ${now} WHERE key = ?`, [
162
+ state,
163
+ MIGRATION_STATE_KEY,
164
+ ])
165
+ const rows = await db.query<{ value: string }>('SELECT value FROM key_value WHERE key = ?', [
166
+ MIGRATION_STATE_KEY,
167
+ ])
168
+ if (rows.length === 0) {
169
+ await db.exec(
170
+ `INSERT INTO key_value (key, value, update_date) VALUES (${db.dialect.placeholder(1)}, ${db.dialect.placeholder(2)}, ${now})`,
171
+ [MIGRATION_STATE_KEY, state],
172
+ )
173
+ }
174
+ }
175
+
176
+ export interface LedgerEntry {
177
+ name: string
178
+ /** 'expand' | 'contract' for framework steps; 'value' for a value migration; 'upgrade' for a cut-over. */
179
+ kind: string
180
+ fromState?: string | null
181
+ toState?: string | null
182
+ durationMs: number
183
+ checksum?: string | null
184
+ appliedBy?: string | null
185
+ note?: string | null
186
+ }
187
+
188
+ /**
189
+ * A setting in `key_value`, Umbraco's own place for one, read and written as a
190
+ * string. Used for facts a node must agree on across restarts and across a load
191
+ * balanced set — the key member session cookies are signed with, for one.
192
+ */
193
+ export async function readKeyValue(db: Db, key: string): Promise<string | undefined> {
194
+ await ensureTables(db)
195
+ const rows = await db.query<{ value: string | null }>(
196
+ 'SELECT value FROM key_value WHERE key = ?',
197
+ [key],
198
+ )
199
+ return rows[0]?.value ?? undefined
200
+ }
201
+
202
+ export async function writeKeyValue(db: Db, key: string, value: string): Promise<void> {
203
+ await ensureTables(db)
204
+ const now = db.dialect.now()
205
+ await db.exec(`UPDATE key_value SET value = ?, update_date = ${now} WHERE key = ?`, [value, key])
206
+ const rows = await db.query('SELECT value FROM key_value WHERE key = ?', [key])
207
+ if (rows.length === 0)
208
+ await db.exec(`INSERT INTO key_value (key, value, update_date) VALUES (?, ?, ${now})`, [
209
+ key,
210
+ value,
211
+ ])
212
+ }
213
+
214
+ /**
215
+ * Reads a setting, generating and storing one the first time it is asked for.
216
+ * Two nodes racing settle on whichever row landed first.
217
+ */
218
+ export async function ensureKeyValue(db: Db, key: string, create: () => string): Promise<string> {
219
+ const existing = await readKeyValue(db, key)
220
+ if (existing) return existing
221
+ await writeKeyValue(db, key, create())
222
+ return (await readKeyValue(db, key)) ?? ''
223
+ }
224
+
225
+ /** Appends a row to `migration_history`. */
226
+ export async function recordInLedger(db: Db, entry: LedgerEntry): Promise<void> {
227
+ await ensureTables(db)
228
+ await db.exec(
229
+ `INSERT INTO migration_history (name, kind, from_state, to_state, applied_at, duration_ms, checksum, applied_by, note)
230
+ VALUES (?, ?, ?, ?, ${db.dialect.now()}, ?, ?, ?, ?)`,
231
+ [
232
+ entry.name,
233
+ entry.kind,
234
+ entry.fromState ?? null,
235
+ entry.toState ?? null,
236
+ entry.durationMs,
237
+ entry.checksum ?? null,
238
+ entry.appliedBy ?? null,
239
+ entry.note ?? null,
240
+ ],
241
+ )
242
+ }
243
+
244
+ /** The newest ledger row's id, so a node can later ask what was ledgered after it booted. */
245
+ export async function latestLedgerId(db: Db): Promise<number> {
246
+ await ensureTables(db)
247
+ const rows = await db.query<{ n: number | null }>('SELECT MAX(id) AS n FROM migration_history')
248
+ return Number(rows[0]?.n ?? 0)
249
+ }
250
+
251
+ /** How many contract steps — framework contracts and site purges — were ledgered after a row id. */
252
+ export async function contractsLedgeredAfter(db: Db, afterId: number): Promise<number> {
253
+ const rows = await db.query<{ n: number }>(
254
+ "SELECT COUNT(*) AS n FROM migration_history WHERE id > ? AND kind = 'contract'",
255
+ [afterId],
256
+ )
257
+ return Number(rows[0]?.n ?? 0)
258
+ }
259
+
260
+ export interface LedgerRow extends LedgerEntry {
261
+ id: number
262
+ appliedAt: string
263
+ }
264
+
265
+ export async function readLedger(db: Db): Promise<LedgerRow[]> {
266
+ await ensureTables(db)
267
+ const rows = await db.query(
268
+ 'SELECT id, name, kind, from_state, to_state, applied_at, duration_ms, checksum, applied_by, note FROM migration_history ORDER BY id',
269
+ )
270
+ return rows.map((row) => ({
271
+ id: Number(row.id),
272
+ name: String(row.name),
273
+ kind: String(row.kind),
274
+ fromState: (row.from_state as string | null) ?? null,
275
+ toState: (row.to_state as string | null) ?? null,
276
+ appliedAt: String(row.applied_at),
277
+ durationMs: Number(row.duration_ms),
278
+ checksum: (row.checksum as string | null) ?? null,
279
+ appliedBy: (row.applied_by as string | null) ?? null,
280
+ note: (row.note as string | null) ?? null,
281
+ }))
282
+ }
283
+
284
+ /** The database is at a state this code's plan does not know: newer code wrote it. */
285
+ export class UpgradeStateError extends Error {
286
+ constructor(
287
+ readonly databaseState: string,
288
+ readonly codeState: string,
289
+ ) {
290
+ super(
291
+ `The database is at migration state ${databaseState}, which this version of crombolo (plan ends at ${codeState}) does not know. ` +
292
+ 'It was upgraded by a newer version; run that version, or restore the backup taken before the upgrade.',
293
+ )
294
+ }
295
+ }
296
+
297
+ /** Steps pending on this database, in order. Throws when the database is ahead of the code. */
298
+ export async function pendingMigrations(db: Db, plan: MigrationPlan): Promise<Migration[]> {
299
+ const state = await readState(db)
300
+ const pending = plan.pendingFrom(state)
301
+ if (!pending) throw new UpgradeStateError(state, plan.finalState)
302
+ return pending
303
+ }
304
+
305
+ export interface MigrateResult {
306
+ from: string
307
+ to: string
308
+ applied: string[]
309
+ }
310
+
311
+ export interface MigrateOptions {
312
+ /** Recorded as `applied_by` in the ledger. */
313
+ by?: string
314
+ }
315
+
316
+ /** Walks the plan forward from the database's current state, one transaction per step, ledgering each. */
317
+ export async function migrate(
318
+ db: Db,
319
+ plan: MigrationPlan,
320
+ options: MigrateOptions = {},
321
+ ): Promise<MigrateResult> {
322
+ return db.locks.withLock(Locks.KeyValues, async () => {
323
+ const from = await readState(db)
324
+ let state = from
325
+ const applied: string[] = []
326
+ const pending = plan.pendingFrom(state)
327
+ if (!pending) throw new UpgradeStateError(state, plan.finalState)
328
+ for (const next of pending) {
329
+ const started = performance.now()
330
+ await db.transaction(async (tx) => {
331
+ await next.up(tx)
332
+ })
333
+ state = next.to
334
+ await writeState(db, state)
335
+ await recordInLedger(db, {
336
+ name: next.name,
337
+ kind: next.kind ?? 'expand',
338
+ fromState: next.from,
339
+ toState: next.to,
340
+ durationMs: Math.round(performance.now() - started),
341
+ checksum: next.name,
342
+ appliedBy: options.by ?? null,
343
+ note: next.release ? `release ${next.release}` : null,
344
+ })
345
+ applied.push(next.name)
346
+ }
347
+ return { from, to: state, applied }
348
+ })
349
+ }
350
+
351
+ /** A fresh install stamps the final state so no migrations then run. */
352
+ export async function stampFinalState(db: Db, plan: MigrationPlan): Promise<void> {
353
+ await ensureTables(db)
354
+ await writeState(db, plan.finalState)
355
+ }
356
+
357
+ // ---------------------------------------------------------------- the plan
358
+
359
+ /** Thrown by the recording database when a migration reads: its writes cannot be predicted. */
360
+ export class UnplannableError extends Error {
361
+ constructor(readonly sql: string) {
362
+ super(`reads data to decide what to write: ${sql.trim().split('\n')[0]}`)
363
+ }
364
+ }
365
+
366
+ /** A `Db` that captures statements instead of executing them. */
367
+ class RecordingDb implements Db {
368
+ readonly dialect: Dialect
369
+ readonly locks: LockManager
370
+ readonly statements: string[]
371
+
372
+ constructor(dialect: Dialect, locks: LockManager, statements: string[] = []) {
373
+ this.dialect = dialect
374
+ this.locks = locks
375
+ this.statements = statements
376
+ }
377
+
378
+ async query<T = Record<string, unknown>>(sql: string): Promise<T[]> {
379
+ throw new UnplannableError(sql)
380
+ }
381
+
382
+ async exec(sql: string, params: unknown[] = []): Promise<void> {
383
+ const rendered = this.dialect.render(sql)
384
+ this.statements.push(
385
+ params.length > 0 ? `${rendered} -- params: ${JSON.stringify(params)}` : rendered,
386
+ )
387
+ }
388
+
389
+ async transaction<T>(fn: (tx: Db) => Promise<T>): Promise<T> {
390
+ return fn(this)
391
+ }
392
+
393
+ async close(): Promise<void> {}
394
+ }
395
+
396
+ export interface PlannedStep {
397
+ name: string
398
+ kind: MigrationKind
399
+ release: string | null
400
+ from: string
401
+ to: string
402
+ /** The DDL this step would run, or undefined when it reads data to decide. */
403
+ statements: string[] | undefined
404
+ unplannable?: string
405
+ }
406
+
407
+ /** What `migrate()` would do, without doing it. */
408
+ export async function planUpgrade(db: Db, plan: MigrationPlan): Promise<PlannedStep[]> {
409
+ const pending = await pendingMigrations(db, plan)
410
+ const steps: PlannedStep[] = []
411
+ for (const migration of pending) {
412
+ const recorder = new RecordingDb(db.dialect, db.locks)
413
+ const step: PlannedStep = {
414
+ name: migration.name,
415
+ kind: migration.kind ?? 'expand',
416
+ release: migration.release ?? null,
417
+ from: migration.from,
418
+ to: migration.to,
419
+ statements: undefined,
420
+ }
421
+ try {
422
+ await migration.up(recorder)
423
+ step.statements = recorder.statements
424
+ } catch (error) {
425
+ if (error instanceof UnplannableError) step.unplannable = error.message
426
+ else throw error
427
+ }
428
+ steps.push(step)
429
+ }
430
+ return steps
431
+ }