@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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +31 -4
- package/src/change-report.ts +166 -0
- package/src/cluster.ts +108 -0
- package/src/database.ts +121 -0
- package/src/dialect.ts +128 -0
- package/src/index.ts +60 -0
- package/src/locks.ts +103 -0
- package/src/maintenance.ts +51 -0
- package/src/migrations.ts +431 -0
- package/src/node-ref.ts +107 -0
- package/src/pending.ts +25 -0
- package/src/repositories/bundles.ts +191 -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 +190 -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/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 +73 -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-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
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
|
+
}
|