@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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +31 -4
- package/src/bundle-tables/builder.ts +264 -0
- package/src/bundle-tables/capability.ts +377 -0
- package/src/bundle-tables/chain.ts +105 -0
- package/src/bundle-tables/ddl.ts +124 -0
- package/src/bundle-tables/guard.ts +116 -0
- package/src/bundle-tables/index.ts +7 -0
- package/src/bundle-tables/names.ts +58 -0
- package/src/bundle-tables/schema.ts +485 -0
- package/src/change-report.ts +166 -0
- package/src/cluster.ts +108 -0
- package/src/database.ts +121 -0
- package/src/dialect.ts +136 -0
- package/src/index.ts +63 -0
- package/src/locks.ts +103 -0
- package/src/maintenance.ts +51 -0
- package/src/migrations.ts +547 -0
- package/src/node-ref.ts +107 -0
- package/src/pending.ts +25 -0
- package/src/repositories/bundles.ts +223 -0
- package/src/repositories/content-types.ts +831 -0
- package/src/repositories/data-types.ts +222 -0
- package/src/repositories/dictionary.ts +259 -0
- package/src/repositories/documents.ts +2389 -0
- package/src/repositories/domains.ts +108 -0
- package/src/repositories/folders.ts +120 -0
- package/src/repositories/form-entries.ts +314 -0
- package/src/repositories/form-workflows.ts +213 -0
- package/src/repositories/languages.ts +108 -0
- package/src/repositories/log-searches.ts +33 -0
- package/src/repositories/members.ts +427 -0
- package/src/repositories/nodes.ts +361 -0
- package/src/repositories/notifications.ts +54 -0
- package/src/repositories/public-access.ts +187 -0
- package/src/repositories/redirects.ts +274 -0
- package/src/repositories/references.ts +162 -0
- package/src/repositories/tags.ts +93 -0
- package/src/repositories/templates.ts +114 -0
- package/src/repositories/transfer-runs.ts +208 -0
- package/src/repositories/user-data.ts +90 -0
- package/src/repositories/users.ts +898 -0
- package/src/schema/assistant.ts +74 -0
- package/src/schema/built-in-content-types.ts +106 -0
- package/src/schema/built-in-media-types.ts +335 -0
- package/src/schema/built-in-member-types.ts +61 -0
- package/src/schema/bundle-components.ts +49 -0
- package/src/schema/bundle-package.ts +27 -0
- package/src/schema/bundles-rename.ts +32 -0
- package/src/schema/change-report.ts +46 -0
- package/src/schema/client-id-prefix.ts +43 -0
- package/src/schema/content-editing.ts +50 -0
- package/src/schema/content-seed.ts +586 -0
- package/src/schema/content.ts +263 -0
- package/src/schema/created-packages.ts +56 -0
- package/src/schema/elements.ts +62 -0
- package/src/schema/form-permission-prefix.ts +50 -0
- package/src/schema/form-permissions.ts +26 -0
- package/src/schema/form-workflows.ts +54 -0
- package/src/schema/forms.ts +63 -0
- package/src/schema/group-permissions.ts +20 -0
- package/src/schema/identity.ts +176 -0
- package/src/schema/log-viewer.ts +68 -0
- package/src/schema/member-property-type.ts +30 -0
- package/src/schema/members.ts +97 -0
- package/src/schema/plan.ts +77 -0
- package/src/schema/public-access.ts +56 -0
- package/src/schema/recycle-bin.ts +21 -0
- package/src/schema/redirects.ts +57 -0
- package/src/schema/rich-text-ui.ts +34 -0
- package/src/schema/seed.ts +298 -0
- package/src/schema/server-role.ts +21 -0
- package/src/schema/since-version.ts +26 -0
- package/src/schema/transfer-run-package.ts +27 -0
- package/src/schema/transfer-runs.ts +71 -0
- package/src/schema/upgrade-report.ts +43 -0
- package/src/schema/users-localisation.ts +85 -0
- package/src/schema/values.ts +108 -0
- package/src/schema-ops.ts +35 -0
- package/src/schema-state.ts +196 -0
- 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
|
+
}
|
package/src/node-ref.ts
ADDED
|
@@ -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
|
+
}
|