@fabrika/platform-node 0.0.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/migrations/0001_jobs.sql +33 -0
- package/migrations/0002_deploy_locks.sql +24 -0
- package/package.json +37 -0
- package/src/asset-server-fs.ts +113 -0
- package/src/blob-s3.ts +79 -0
- package/src/index.ts +47 -0
- package/src/job-queue-postgres.ts +275 -0
- package/src/placeholders.ts +199 -0
- package/src/postgres-migrations.ts +355 -0
- package/src/service-postgres-migrations.ts +69 -0
- package/src/sql-postgres.ts +176 -0
- package/src/uuid.ts +94 -0
- package/src/wait-until.ts +69 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// `?` (SQLite/D1) → `$1..$n` (Postgres) — the one real dialect divergence the SQL port papers over.
|
|
2
|
+
//
|
|
3
|
+
// This is a TOKENISER, not a regex, and deliberately so: a `?` is a placeholder only when it is not
|
|
4
|
+
// inside something that quotes or comments it out. A regex that misses one of those cases does not
|
|
5
|
+
// fail — it silently rewrites a character inside a string literal, and the query keeps running with
|
|
6
|
+
// corrupted data. Every construct below therefore has an explicit scanner, and an UNTERMINATED one
|
|
7
|
+
// throws rather than guessing (a loud failure at prepare() beats a wrong query at run time).
|
|
8
|
+
//
|
|
9
|
+
// Constructs the scanner steps over without touching:
|
|
10
|
+
// 'string literal' — `''` is an escaped quote; backslashes are LITERAL (standard_conforming_strings
|
|
11
|
+
// has been on by default since PostgreSQL 9.1, and SQLite has no backslash escape)
|
|
12
|
+
// E'escape string' — the one place a backslash DOES escape, so `E'\'?'` keeps its `?` inside
|
|
13
|
+
// "quoted identifier" — `""` is an escaped quote
|
|
14
|
+
// $$dollar$$ / $tag$…$tag$ — the body is opaque; only the matching tag closes it
|
|
15
|
+
// -- line comment — to end of line
|
|
16
|
+
// /* block comment */ — NESTED, per Postgres (SQLite does not nest; see the divergence note below)
|
|
17
|
+
//
|
|
18
|
+
// And the `?`-family OPERATORS — `??`, `?|`, `?&` (jsonb key-existence on Postgres) — pass through
|
|
19
|
+
// VERBATIM. A lone `?` is always a placeholder; a `?` immediately followed by `?`, `|` or `&` is an
|
|
20
|
+
// operator and is never renumbered.
|
|
21
|
+
//
|
|
22
|
+
// Divergences this creates, all unreachable from the common-subset SQL the port mandates but worth
|
|
23
|
+
// knowing before someone hand-writes a statement:
|
|
24
|
+
// * `?|` / `?&` / `??` are operators here but would be `?` + `|`/`&`/`?` in SQLite. Write bitwise
|
|
25
|
+
// operators with whitespace (`? | 1`) and they parse as intended on both.
|
|
26
|
+
// * Block comments nest here. `/* a /* b */` is a complete comment to SQLite and unterminated here.
|
|
27
|
+
// * `[bracketed]` identifiers (legal in SQLite) are NOT treated as quotes, because `arr[?]` is a
|
|
28
|
+
// Postgres array subscript containing a real placeholder. Bracket-quoting is not portable anyway.
|
|
29
|
+
// * Backticks are not treated as quotes — Postgres has no backtick syntax, so such SQL fails loudly.
|
|
30
|
+
|
|
31
|
+
/** One rewritten statement. `count` is exactly how many values `bind()` must supply. */
|
|
32
|
+
export interface RewrittenSql {
|
|
33
|
+
/** Postgres-dialect text: every positional `?` replaced by `$1..$n`, left to right. */
|
|
34
|
+
text: string
|
|
35
|
+
/** How many placeholders were rewritten. */
|
|
36
|
+
count: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Identifier runs, per the Postgres lexer: a letter, `_` or any non-ASCII character starts one; digits
|
|
40
|
+
// and `$` may follow. (`$` being a word character is why `a$$b` is an identifier, not a dollar-quote.)
|
|
41
|
+
const WORD_START = /[A-Za-z_\u0080-\uffff]/
|
|
42
|
+
const WORD_PART = /[A-Za-z0-9_$\u0080-\uffff]/
|
|
43
|
+
/** `$$` or `$tag$` at the current position — the opening (and closing) delimiter of a dollar-quoted string. */
|
|
44
|
+
const DOLLAR_TAG = /^\$(?:[A-Za-z_\u0080-\uffff][A-Za-z0-9_\u0080-\uffff]*)?\$/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Rewrite a `?`-placeholder statement into Postgres `$n` form. Throws on an unterminated string,
|
|
48
|
+
* identifier, dollar-quote or block comment — the statement is malformed and no rewriting of it can
|
|
49
|
+
* be trusted.
|
|
50
|
+
*/
|
|
51
|
+
export function rewritePlaceholders(sql: string): RewrittenSql {
|
|
52
|
+
let out = ''
|
|
53
|
+
let count = 0
|
|
54
|
+
let i = 0
|
|
55
|
+
|
|
56
|
+
while (i < sql.length) {
|
|
57
|
+
const ch = sql.charAt(i)
|
|
58
|
+
|
|
59
|
+
// `-- …` to end of line (the newline itself is left to the main loop).
|
|
60
|
+
if (ch === '-' && sql.charAt(i + 1) === '-') {
|
|
61
|
+
const end = lineEnd(sql, i)
|
|
62
|
+
out += sql.slice(i, end)
|
|
63
|
+
i = end
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// `/* … */`, nesting-aware.
|
|
68
|
+
if (ch === '/' && sql.charAt(i + 1) === '*') {
|
|
69
|
+
const end = scanBlockComment(sql, i)
|
|
70
|
+
out += sql.slice(i, end)
|
|
71
|
+
i = end
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 'string literal' / "quoted identifier" — neither processes backslashes.
|
|
76
|
+
if (ch === "'" || ch === '"') {
|
|
77
|
+
const end = scanQuoted(sql, i, ch, false)
|
|
78
|
+
out += sql.slice(i, end)
|
|
79
|
+
i = end
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// $$…$$ / $tag$…$tag$. A `$` that opens no tag (e.g. the `$` of a stray `$1`) is just a character.
|
|
84
|
+
if (ch === '$') {
|
|
85
|
+
const tag = dollarTagAt(sql, i)
|
|
86
|
+
if (tag !== null) {
|
|
87
|
+
const bodyEnd = sql.indexOf(tag, i + tag.length)
|
|
88
|
+
if (bodyEnd === -1) {
|
|
89
|
+
throw new Error(`unterminated dollar-quoted string (${tag}) in SQL`)
|
|
90
|
+
}
|
|
91
|
+
const end = bodyEnd + tag.length
|
|
92
|
+
out += sql.slice(i, end)
|
|
93
|
+
i = end
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
out += ch
|
|
97
|
+
i += 1
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A keyword/identifier run. Consuming it whole is what keeps `a$$b` an identifier rather than a
|
|
102
|
+
// dollar-quote, and what makes the `E'…'` check below see only a STANDALONE `E`.
|
|
103
|
+
if (WORD_START.test(ch)) {
|
|
104
|
+
let end = i + 1
|
|
105
|
+
while (end < sql.length && WORD_PART.test(sql.charAt(end))) {
|
|
106
|
+
end += 1
|
|
107
|
+
}
|
|
108
|
+
const word = sql.slice(i, end)
|
|
109
|
+
out += word
|
|
110
|
+
i = end
|
|
111
|
+
// `E'…'` is the one string form where `\` escapes, so `E'\'?'` must keep the `?` inside.
|
|
112
|
+
// The digit check rules out the `e` of a numeric literal (`1e'…'` is not an escape string).
|
|
113
|
+
if ((word === 'E' || word === 'e') && sql.charAt(i) === "'" && !isDigit(sql.charAt(i - 2))) {
|
|
114
|
+
const stringEnd = scanQuoted(sql, i, "'", true)
|
|
115
|
+
out += sql.slice(i, stringEnd)
|
|
116
|
+
i = stringEnd
|
|
117
|
+
}
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The placeholder itself — unless it opens a `?`-family operator, which passes through untouched.
|
|
122
|
+
if (ch === '?') {
|
|
123
|
+
const next = sql.charAt(i + 1)
|
|
124
|
+
if (next === '?' || next === '|' || next === '&') {
|
|
125
|
+
out += ch + next
|
|
126
|
+
i += 2
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
count += 1
|
|
130
|
+
out += `$${count}`
|
|
131
|
+
i += 1
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
out += ch
|
|
136
|
+
i += 1
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { text: out, count }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isDigit(ch: string): boolean {
|
|
143
|
+
return ch >= '0' && ch <= '9'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function lineEnd(sql: string, from: number): number {
|
|
147
|
+
const nl = sql.indexOf('\n', from)
|
|
148
|
+
return nl === -1 ? sql.length : nl
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Index just past the `*` `/` that closes the comment opening at `from`. Nesting-aware; throws if unclosed. */
|
|
152
|
+
function scanBlockComment(sql: string, from: number): number {
|
|
153
|
+
let depth = 0
|
|
154
|
+
let i = from
|
|
155
|
+
while (i < sql.length) {
|
|
156
|
+
if (sql.charAt(i) === '/' && sql.charAt(i + 1) === '*') {
|
|
157
|
+
depth += 1
|
|
158
|
+
i += 2
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
if (sql.charAt(i) === '*' && sql.charAt(i + 1) === '/') {
|
|
162
|
+
depth -= 1
|
|
163
|
+
i += 2
|
|
164
|
+
if (depth === 0) {
|
|
165
|
+
return i
|
|
166
|
+
}
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
i += 1
|
|
170
|
+
}
|
|
171
|
+
throw new Error('unterminated block comment in SQL')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Index just past the closing quote of the literal/identifier opening at `from`. Throws if unclosed. */
|
|
175
|
+
function scanQuoted(sql: string, from: number, quote: string, backslashEscapes: boolean): number {
|
|
176
|
+
let i = from + 1
|
|
177
|
+
while (i < sql.length) {
|
|
178
|
+
const ch = sql.charAt(i)
|
|
179
|
+
if (backslashEscapes && ch === '\\') {
|
|
180
|
+
i += 2
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
if (ch === quote) {
|
|
184
|
+
// A doubled quote is an escaped quote, not the end.
|
|
185
|
+
if (sql.charAt(i + 1) === quote) {
|
|
186
|
+
i += 2
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
return i + 1
|
|
190
|
+
}
|
|
191
|
+
i += 1
|
|
192
|
+
}
|
|
193
|
+
throw new Error(`unterminated ${quote === '"' ? 'quoted identifier' : 'string literal'} in SQL`)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function dollarTagAt(sql: string, from: number): string | null {
|
|
197
|
+
const match = DOLLAR_TAG.exec(sql.slice(from))
|
|
198
|
+
return match === null ? null : match[0]
|
|
199
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import type { SqlDatabase, SqlStatement } from '@fabrika/platform'
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const IDENTIFIER = /^[a-z][a-z0-9_]*$/
|
|
6
|
+
const BUNDLE_NAME = /^[a-z][a-z0-9-]*$/
|
|
7
|
+
const MIGRATION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.sql$/
|
|
8
|
+
const PLATFORM_MIGRATIONS_DIR = join(import.meta.dir, '..', 'migrations')
|
|
9
|
+
|
|
10
|
+
export interface PostgresMigration {
|
|
11
|
+
name: string
|
|
12
|
+
sql: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PostgresMigrationBundle {
|
|
16
|
+
name: string
|
|
17
|
+
migrations: readonly PostgresMigration[]
|
|
18
|
+
/** Only service-owned bundles may adopt matching entries from the old unqualified ledger. */
|
|
19
|
+
adoptLegacy?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PostgresLegacyMigrationLedger {
|
|
23
|
+
/** Old filename-only ledger. */
|
|
24
|
+
table: string
|
|
25
|
+
/** Tables created atomically by this service's first migration. Every sentinel must exist. */
|
|
26
|
+
sentinelTables: readonly string[]
|
|
27
|
+
/** Extra effect probes for migrations whose filename could have collided in a shared old ledger. */
|
|
28
|
+
effects?: readonly PostgresLegacyMigrationEffect[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PostgresLegacyMigrationEffect {
|
|
32
|
+
migration: string
|
|
33
|
+
/** A parameterless SELECT returning one boolean column named `proven`. */
|
|
34
|
+
probeSql: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PostgresMigrationPlan {
|
|
38
|
+
/** Service-scoped ledger table, such as `iam_schema_migrations`. */
|
|
39
|
+
ledgerTable: string
|
|
40
|
+
/** Service-specific session advisory lock. Keep this stable across runner upgrades. */
|
|
41
|
+
lockKey: number
|
|
42
|
+
bundles: readonly PostgresMigrationBundle[]
|
|
43
|
+
legacy?: PostgresLegacyMigrationLedger
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ReadPostgresMigrationBundleOptions {
|
|
47
|
+
name: string
|
|
48
|
+
directory: string
|
|
49
|
+
files?: readonly string[]
|
|
50
|
+
adoptLegacy?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface LedgerRow {
|
|
54
|
+
bundle: string
|
|
55
|
+
name: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface LegacyLedgerRow {
|
|
59
|
+
name: string
|
|
60
|
+
applied_at: number | string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface PresenceRow {
|
|
64
|
+
present: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ProvenRow {
|
|
68
|
+
proven: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read one ordered migration bundle from disk. An explicit file list fails closed when a file is absent. */
|
|
72
|
+
export function readPostgresMigrationBundle(options: ReadPostgresMigrationBundleOptions): PostgresMigrationBundle {
|
|
73
|
+
validateBundleName(options.name)
|
|
74
|
+
const available = readdirSync(options.directory)
|
|
75
|
+
.filter((file) => file.endsWith('.sql'))
|
|
76
|
+
.sort()
|
|
77
|
+
const names = options.files === undefined ? available : [...options.files].sort()
|
|
78
|
+
const availableNames = new Set(available)
|
|
79
|
+
for (const name of names) {
|
|
80
|
+
validateMigrationName(name)
|
|
81
|
+
if (!availableNames.has(name)) {
|
|
82
|
+
throw new Error(`Postgres migration ${options.name}/${name} is missing`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const bundle: PostgresMigrationBundle = {
|
|
86
|
+
name: options.name,
|
|
87
|
+
migrations: names.map((name) => ({
|
|
88
|
+
name,
|
|
89
|
+
sql: readFileSync(join(options.directory, name), 'utf8'),
|
|
90
|
+
})),
|
|
91
|
+
...(options.adoptLegacy === true ? { adoptLegacy: true } : {}),
|
|
92
|
+
}
|
|
93
|
+
validateBundle(bundle)
|
|
94
|
+
return bundle
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The generic migrations owned by the long-running Bun runtime. */
|
|
98
|
+
export function platformNodePostgresMigrationBundle(files?: readonly string[]): PostgresMigrationBundle {
|
|
99
|
+
return readPostgresMigrationBundle({
|
|
100
|
+
name: 'platform-node',
|
|
101
|
+
directory: PLATFORM_MIGRATIONS_DIR,
|
|
102
|
+
...(files === undefined ? {} : { files }),
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Validate a complete plan at composition time, before any SQL is executed. */
|
|
107
|
+
export function definePostgresMigrationPlan(plan: PostgresMigrationPlan): PostgresMigrationPlan {
|
|
108
|
+
validateIdentifier(plan.ledgerTable, 'migration ledger table')
|
|
109
|
+
if (!Number.isSafeInteger(plan.lockKey) || plan.lockKey < 1) {
|
|
110
|
+
throw new Error('Postgres migration lock key must be a positive safe integer')
|
|
111
|
+
}
|
|
112
|
+
if (plan.bundles.length === 0) throw new Error('Postgres migration plan must contain at least one bundle')
|
|
113
|
+
const bundleNames = new Set<string>()
|
|
114
|
+
const identities = new Set<string>()
|
|
115
|
+
for (const bundle of plan.bundles) {
|
|
116
|
+
validateBundle(bundle)
|
|
117
|
+
if (bundleNames.has(bundle.name)) throw new Error(`Duplicate Postgres migration bundle ${bundle.name}`)
|
|
118
|
+
bundleNames.add(bundle.name)
|
|
119
|
+
for (const migration of bundle.migrations) {
|
|
120
|
+
const identity = postgresMigrationIdentity(bundle.name, migration.name)
|
|
121
|
+
if (identities.has(identity)) throw new Error(`Duplicate Postgres migration identity ${identity}`)
|
|
122
|
+
identities.add(identity)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (plan.legacy !== undefined) {
|
|
126
|
+
validateIdentifier(plan.legacy.table, 'legacy migration ledger table')
|
|
127
|
+
if (plan.legacy.sentinelTables.length === 0) {
|
|
128
|
+
throw new Error('Legacy migration adoption requires at least one service sentinel')
|
|
129
|
+
}
|
|
130
|
+
for (const sentinel of plan.legacy.sentinelTables) {
|
|
131
|
+
validateIdentifier(sentinel, 'legacy migration sentinel table')
|
|
132
|
+
}
|
|
133
|
+
const adoptingBundles = plan.bundles.filter((bundle) => bundle.adoptLegacy === true)
|
|
134
|
+
if (adoptingBundles.length !== 1) {
|
|
135
|
+
throw new Error('Legacy migration adoption requires exactly one adopting bundle')
|
|
136
|
+
}
|
|
137
|
+
const adoptable = new Set(
|
|
138
|
+
adoptingBundles.flatMap((bundle) => bundle.migrations.map((migration) => migration.name)),
|
|
139
|
+
)
|
|
140
|
+
const effectNames = new Set<string>()
|
|
141
|
+
for (const effect of plan.legacy.effects ?? []) {
|
|
142
|
+
validateMigrationName(effect.migration)
|
|
143
|
+
if (!adoptable.has(effect.migration)) {
|
|
144
|
+
throw new Error(`Legacy migration effect references non-adoptable migration ${effect.migration}`)
|
|
145
|
+
}
|
|
146
|
+
if (effectNames.has(effect.migration)) {
|
|
147
|
+
throw new Error(`Duplicate legacy migration effect ${effect.migration}`)
|
|
148
|
+
}
|
|
149
|
+
if (effect.probeSql.trim() === '') {
|
|
150
|
+
throw new Error(`Legacy migration effect ${effect.migration} is empty`)
|
|
151
|
+
}
|
|
152
|
+
effectNames.add(effect.migration)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return plan
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function postgresMigrationIdentity(bundle: string, name: string): string {
|
|
159
|
+
validateBundleName(bundle)
|
|
160
|
+
validateMigrationName(name)
|
|
161
|
+
return `${bundle}/${name}`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Preserve a service wrapper's historical filename-only result while the core uses qualified ids. */
|
|
165
|
+
export function postgresMigrationFilenames(identities: readonly string[], bundle: string): string[] {
|
|
166
|
+
validateBundleName(bundle)
|
|
167
|
+
const prefix = `${bundle}/`
|
|
168
|
+
const names: string[] = []
|
|
169
|
+
for (const identity of identities) {
|
|
170
|
+
if (!identity.startsWith(prefix)) continue
|
|
171
|
+
const name = identity.slice(prefix.length)
|
|
172
|
+
postgresMigrationIdentity(bundle, name)
|
|
173
|
+
names.push(name)
|
|
174
|
+
}
|
|
175
|
+
return names
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Apply a plan over one Postgres session.
|
|
180
|
+
*
|
|
181
|
+
* The caller must supply a database backed by a single-connection pool. Advisory locks are session
|
|
182
|
+
* state; allowing acquire and release to land on different pooled connections would make the runner
|
|
183
|
+
* appear serialized while providing no serialization at all.
|
|
184
|
+
*/
|
|
185
|
+
export async function applyPostgresMigrations(
|
|
186
|
+
db: SqlDatabase,
|
|
187
|
+
input: PostgresMigrationPlan,
|
|
188
|
+
options: { now?: () => number } = {},
|
|
189
|
+
): Promise<string[]> {
|
|
190
|
+
const plan = definePostgresMigrationPlan(input)
|
|
191
|
+
await db.prepare(`SELECT pg_advisory_lock(${plan.lockKey})`).run()
|
|
192
|
+
let migrationFailed = false
|
|
193
|
+
let migrationError: unknown
|
|
194
|
+
const fresh: string[] = []
|
|
195
|
+
try {
|
|
196
|
+
await ensureLedger(db, plan.ledgerTable)
|
|
197
|
+
const beforeAdoption = await appliedIdentities(db, plan.ledgerTable)
|
|
198
|
+
if (beforeAdoption.size === 0) await adoptLegacyMigrations(db, plan)
|
|
199
|
+
const applied = await appliedIdentities(db, plan.ledgerTable)
|
|
200
|
+
for (const bundle of plan.bundles) {
|
|
201
|
+
for (const migration of bundle.migrations) {
|
|
202
|
+
const identity = postgresMigrationIdentity(bundle.name, migration.name)
|
|
203
|
+
if (applied.has(identity)) continue
|
|
204
|
+
await db.batch([
|
|
205
|
+
db.prepare(migration.sql),
|
|
206
|
+
db
|
|
207
|
+
.prepare(`INSERT INTO ${plan.ledgerTable} (bundle, name, applied_at) VALUES (?, ?, ?)`)
|
|
208
|
+
.bind(bundle.name, migration.name, (options.now ?? unixNow)()),
|
|
209
|
+
])
|
|
210
|
+
applied.add(identity)
|
|
211
|
+
fresh.push(identity)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
migrationFailed = true
|
|
216
|
+
migrationError = error
|
|
217
|
+
}
|
|
218
|
+
let unlockFailed = false
|
|
219
|
+
try {
|
|
220
|
+
const unlocked = await db
|
|
221
|
+
.prepare(`SELECT pg_advisory_unlock(${plan.lockKey}) AS unlocked`)
|
|
222
|
+
.first<{ unlocked: boolean }>()
|
|
223
|
+
unlockFailed = unlocked?.unlocked !== true
|
|
224
|
+
} catch {
|
|
225
|
+
unlockFailed = true
|
|
226
|
+
}
|
|
227
|
+
if (migrationFailed) throw migrationError
|
|
228
|
+
if (unlockFailed) throw new Error('Postgres migration advisory unlock failed')
|
|
229
|
+
return fresh
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function validateBundle(bundle: PostgresMigrationBundle): void {
|
|
233
|
+
validateBundleName(bundle.name)
|
|
234
|
+
if (bundle.migrations.length === 0) throw new Error(`Postgres migration bundle ${bundle.name} is empty`)
|
|
235
|
+
const names = new Set<string>()
|
|
236
|
+
let previous = ''
|
|
237
|
+
for (const migration of bundle.migrations) {
|
|
238
|
+
validateMigrationName(migration.name)
|
|
239
|
+
if (migration.sql.trim() === '') throw new Error(`Postgres migration ${bundle.name}/${migration.name} is empty`)
|
|
240
|
+
if (names.has(migration.name)) throw new Error(`Duplicate Postgres migration ${bundle.name}/${migration.name}`)
|
|
241
|
+
if (previous !== '' && migration.name.localeCompare(previous) < 0) {
|
|
242
|
+
throw new Error(`Postgres migration bundle ${bundle.name} is not ordered by filename`)
|
|
243
|
+
}
|
|
244
|
+
names.add(migration.name)
|
|
245
|
+
previous = migration.name
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function validateIdentifier(value: string, label: string): void {
|
|
250
|
+
if (!IDENTIFIER.test(value)) throw new Error(`Invalid ${label}: ${value}`)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function validateBundleName(value: string): void {
|
|
254
|
+
if (!BUNDLE_NAME.test(value)) throw new Error(`Invalid Postgres migration bundle name: ${value}`)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function validateMigrationName(value: string): void {
|
|
258
|
+
if (!MIGRATION_NAME.test(value)) throw new Error(`Invalid Postgres migration filename: ${value}`)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function ensureLedger(db: SqlDatabase, ledgerTable: string): Promise<void> {
|
|
262
|
+
await db.prepare(`CREATE TABLE IF NOT EXISTS ${ledgerTable} (
|
|
263
|
+
bundle TEXT NOT NULL,
|
|
264
|
+
name TEXT NOT NULL,
|
|
265
|
+
applied_at INTEGER NOT NULL,
|
|
266
|
+
PRIMARY KEY (bundle, name)
|
|
267
|
+
)`).run()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function appliedIdentities(db: SqlDatabase, ledgerTable: string): Promise<Set<string>> {
|
|
271
|
+
const { results } = await db.prepare(`SELECT bundle, name FROM ${ledgerTable}`).all<LedgerRow>()
|
|
272
|
+
return new Set(results.map((row) => postgresMigrationIdentity(row.bundle, row.name)))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function adoptLegacyMigrations(db: SqlDatabase, plan: PostgresMigrationPlan): Promise<void> {
|
|
276
|
+
const legacy = plan.legacy
|
|
277
|
+
if (legacy === undefined) return
|
|
278
|
+
const sentinelStates = await Promise.all(legacy.sentinelTables.map((sentinel) => relationExists(db, sentinel)))
|
|
279
|
+
const presentSentinels = sentinelStates.filter(Boolean).length
|
|
280
|
+
if (presentSentinels === 0) return
|
|
281
|
+
if (presentSentinels !== sentinelStates.length) {
|
|
282
|
+
throw new Error('Legacy migration service sentinels are inconsistent')
|
|
283
|
+
}
|
|
284
|
+
if (!(await relationExists(db, legacy.table))) {
|
|
285
|
+
throw new Error('Legacy migration ledger is missing for an existing service schema')
|
|
286
|
+
}
|
|
287
|
+
for (const sentinel of legacy.sentinelTables) {
|
|
288
|
+
if (!(await relationExists(db, sentinel))) {
|
|
289
|
+
throw new Error('Legacy migration service sentinels changed during adoption')
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const { results } = await db.prepare(`SELECT name, applied_at FROM ${legacy.table}`).all<LegacyLedgerRow>()
|
|
293
|
+
const legacyApplied = new Map(results.map((row) => [row.name, appliedAt(row.applied_at)]))
|
|
294
|
+
const adoptingBundle = plan.bundles.find((bundle) => bundle.adoptLegacy === true)
|
|
295
|
+
if (adoptingBundle === undefined) throw new Error('Legacy migration adopting bundle is missing')
|
|
296
|
+
const firstMigration = adoptingBundle.migrations[0]
|
|
297
|
+
if (firstMigration === undefined || !legacyApplied.has(firstMigration.name)) {
|
|
298
|
+
throw new Error(`Legacy migration ledger does not prove ${adoptingBundle.name}'s first migration`)
|
|
299
|
+
}
|
|
300
|
+
for (const effect of legacy.effects ?? []) {
|
|
301
|
+
if (!legacyApplied.has(effect.migration)) continue
|
|
302
|
+
const row = await db.prepare(effect.probeSql).first<ProvenRow>()
|
|
303
|
+
if (row === null || typeof row.proven !== 'boolean') {
|
|
304
|
+
throw new Error(`Legacy migration effect ${effect.migration} did not return a boolean proven column`)
|
|
305
|
+
}
|
|
306
|
+
if (!row.proven) {
|
|
307
|
+
throw new Error(`Legacy migration effect ${effect.migration} is missing`)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const statements: SqlStatement[] = []
|
|
311
|
+
for (const bundle of plan.bundles) {
|
|
312
|
+
if (bundle.adoptLegacy !== true) continue
|
|
313
|
+
for (const migration of bundle.migrations) {
|
|
314
|
+
const timestamp = legacyApplied.get(migration.name)
|
|
315
|
+
if (timestamp === undefined) continue
|
|
316
|
+
statements.push(
|
|
317
|
+
db
|
|
318
|
+
.prepare(`INSERT INTO ${plan.ledgerTable} (bundle, name, applied_at)
|
|
319
|
+
VALUES (?, ?, ?)
|
|
320
|
+
ON CONFLICT (bundle, name) DO NOTHING`)
|
|
321
|
+
.bind(
|
|
322
|
+
bundle.name,
|
|
323
|
+
migration.name,
|
|
324
|
+
timestamp,
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (statements.length > 0) await db.batch(statements)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function relationExists(db: SqlDatabase, name: string): Promise<boolean> {
|
|
333
|
+
const row = await db
|
|
334
|
+
.prepare(`SELECT EXISTS (
|
|
335
|
+
SELECT 1
|
|
336
|
+
FROM pg_catalog.pg_class relation
|
|
337
|
+
JOIN pg_catalog.pg_namespace namespace ON namespace.oid = relation.relnamespace
|
|
338
|
+
WHERE namespace.nspname = current_schema()
|
|
339
|
+
AND relation.relname = ?
|
|
340
|
+
AND relation.relkind IN ('r', 'p')
|
|
341
|
+
) AS present`)
|
|
342
|
+
.bind(name)
|
|
343
|
+
.first<PresenceRow>()
|
|
344
|
+
return row?.present === true
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function appliedAt(value: number | string): number {
|
|
348
|
+
const parsed = typeof value === 'number' ? value : Number(value)
|
|
349
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error('Legacy migration ledger contains an invalid timestamp')
|
|
350
|
+
return parsed
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function unixNow(): number {
|
|
354
|
+
return Math.floor(Date.now() / 1_000)
|
|
355
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { SqlDatabase } from '@fabrika/platform'
|
|
2
|
+
import {
|
|
3
|
+
applyPostgresMigrations,
|
|
4
|
+
definePostgresMigrationPlan,
|
|
5
|
+
type PostgresLegacyMigrationLedger,
|
|
6
|
+
type PostgresMigration,
|
|
7
|
+
type PostgresMigrationBundle,
|
|
8
|
+
postgresMigrationFilenames,
|
|
9
|
+
type PostgresMigrationPlan,
|
|
10
|
+
readPostgresMigrationBundle,
|
|
11
|
+
} from './postgres-migrations'
|
|
12
|
+
|
|
13
|
+
export interface PostgresServiceMigrationsConfig {
|
|
14
|
+
/** Stable service-owned bundle name. */
|
|
15
|
+
readonly name: string
|
|
16
|
+
/** Stable service-owned ledger table. */
|
|
17
|
+
readonly ledgerTable: string
|
|
18
|
+
/** Stable service advisory lock. */
|
|
19
|
+
readonly lockKey: number
|
|
20
|
+
readonly directory: string
|
|
21
|
+
readonly legacy: PostgresLegacyMigrationLedger
|
|
22
|
+
/** Runtime-owned bundles applied before the service schema. */
|
|
23
|
+
readonly dependencies?: readonly PostgresMigrationBundle[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PostgresServiceMigrations {
|
|
27
|
+
read(directory?: string): PostgresMigration[]
|
|
28
|
+
plan(migrations?: readonly PostgresMigration[]): PostgresMigrationPlan
|
|
29
|
+
apply(db: SqlDatabase, migrations?: readonly PostgresMigration[]): Promise<string[]>
|
|
30
|
+
applyQualified(db: SqlDatabase, migrations?: readonly PostgresMigration[]): Promise<string[]>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Define the repeated service migration wrapper while keeping service identity and lifecycle local.
|
|
35
|
+
* Bundle names, ledgers, lock ids, dependency order, and legacy evidence remain explicit inputs.
|
|
36
|
+
*/
|
|
37
|
+
export function definePostgresServiceMigrations(config: PostgresServiceMigrationsConfig): PostgresServiceMigrations {
|
|
38
|
+
const read = (directory: string = config.directory): PostgresMigration[] => [
|
|
39
|
+
...readPostgresMigrationBundle({
|
|
40
|
+
name: config.name,
|
|
41
|
+
directory,
|
|
42
|
+
adoptLegacy: true,
|
|
43
|
+
}).migrations,
|
|
44
|
+
]
|
|
45
|
+
const plan = (migrations: readonly PostgresMigration[] = read()): PostgresMigrationPlan =>
|
|
46
|
+
definePostgresMigrationPlan({
|
|
47
|
+
ledgerTable: config.ledgerTable,
|
|
48
|
+
lockKey: config.lockKey,
|
|
49
|
+
bundles: [
|
|
50
|
+
...(config.dependencies ?? []),
|
|
51
|
+
{ name: config.name, migrations, adoptLegacy: true },
|
|
52
|
+
],
|
|
53
|
+
legacy: config.legacy,
|
|
54
|
+
})
|
|
55
|
+
const applyQualified = (
|
|
56
|
+
db: SqlDatabase,
|
|
57
|
+
migrations: readonly PostgresMigration[] = read(),
|
|
58
|
+
): Promise<string[]> => applyPostgresMigrations(db, plan(migrations))
|
|
59
|
+
const apply = async (
|
|
60
|
+
db: SqlDatabase,
|
|
61
|
+
migrations: readonly PostgresMigration[] = read(),
|
|
62
|
+
): Promise<string[]> => postgresMigrationFilenames(await applyQualified(db, migrations), config.name)
|
|
63
|
+
|
|
64
|
+
return { read, plan, apply, applyQualified }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function postgresMigrationResultMessage(applied: readonly string[]): string {
|
|
68
|
+
return applied.length === 0 ? 'migrations: already up to date' : `migrations applied: ${applied.join(', ')}`
|
|
69
|
+
}
|