@bakery-framework/orm 1.0.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 +19 -0
- package/README.md +88 -0
- package/package.json +59 -0
- package/src/adapters/base.ts +1128 -0
- package/src/adapters/mysql.ts +619 -0
- package/src/adapters/observe.ts +261 -0
- package/src/adapters/pgsql.ts +611 -0
- package/src/adapters/registry.ts +204 -0
- package/src/adapters/sqlite.ts +588 -0
- package/src/adapters.ts +72 -0
- package/src/backup.ts +37 -0
- package/src/connection.ts +69 -0
- package/src/define.ts +380 -0
- package/src/field.ts +595 -0
- package/src/globals.d.ts +22 -0
- package/src/index.ts +63 -0
- package/src/orm/index.ts +24 -0
- package/src/orm/mutation.ts +692 -0
- package/src/orm/query.ts +1680 -0
- package/src/pool.ts +83 -0
- package/src/schema-registry.ts +75 -0
- package/src/schema-util.ts +467 -0
- package/src/sync/builder.ts +618 -0
- package/src/sync/engine.ts +399 -0
- package/src/sync/helpers.ts +1119 -0
- package/src/sync/history.ts +169 -0
- package/src/sync/index.ts +113 -0
- package/src/sync/ledger.ts +335 -0
- package/src/sync/load.ts +368 -0
- package/src/sync/rollback.ts +200 -0
- package/src/sync/types.ts +101 -0
- package/src/sync/view-sql.ts +160 -0
- package/templates/schema.example.ts +94 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import '@bakery-framework/core/core/init'
|
|
2
|
+
|
|
3
|
+
import { Logger } from '@bakery-framework/core/logger'
|
|
4
|
+
import { type LedgerEntry, readLedgerEntries } from './ledger'
|
|
5
|
+
import type * as SyncTypes from './types'
|
|
6
|
+
|
|
7
|
+
const logger = new Logger('db-history')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What changed between two applied schemas, by name only.
|
|
11
|
+
*
|
|
12
|
+
* Names, not types — the same restraint `shapesMatch` uses and for the same
|
|
13
|
+
* reason: a type comparison here would need dialect normalisation, which is the
|
|
14
|
+
* thing the ledger exists to avoid depending on. A summary that says "column
|
|
15
|
+
* changed" when a Postgres default merely re-rendered itself would be worse
|
|
16
|
+
* than saying nothing.
|
|
17
|
+
*/
|
|
18
|
+
export interface HistoryDiff {
|
|
19
|
+
tablesAdded: string[]
|
|
20
|
+
tablesRemoved: string[]
|
|
21
|
+
columnsAdded: string[]
|
|
22
|
+
columnsRemoved: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const meta = (k: string) => k.startsWith('_')
|
|
26
|
+
const namesOf = (o: unknown) => Object.keys(o ?? {}).filter(k => !meta(k))
|
|
27
|
+
|
|
28
|
+
export function diffEntries(
|
|
29
|
+
older: SyncTypes.DBConstraints | null,
|
|
30
|
+
newer: SyncTypes.DBConstraints,
|
|
31
|
+
): HistoryDiff {
|
|
32
|
+
const out: HistoryDiff = {
|
|
33
|
+
tablesAdded: [],
|
|
34
|
+
tablesRemoved: [],
|
|
35
|
+
columnsAdded: [],
|
|
36
|
+
columnsRemoved: [],
|
|
37
|
+
}
|
|
38
|
+
const before = namesOf(older ?? {})
|
|
39
|
+
const after = namesOf(newer)
|
|
40
|
+
out.tablesAdded = after.filter(t => !before.includes(t))
|
|
41
|
+
out.tablesRemoved = before.filter(t => !after.includes(t))
|
|
42
|
+
|
|
43
|
+
// Only tables present in both: a column of a table that was added whole is
|
|
44
|
+
// already accounted for by `tablesAdded`, and listing it again would make a
|
|
45
|
+
// one-table migration read like dozens of changes.
|
|
46
|
+
for (const table of after.filter(t => before.includes(t))) {
|
|
47
|
+
const b = namesOf((older as any)?.[table])
|
|
48
|
+
const a = namesOf((newer as any)[table])
|
|
49
|
+
for (const c of a.filter(c => !b.includes(c)))
|
|
50
|
+
out.columnsAdded.push(`${table}.${c}`)
|
|
51
|
+
for (const c of b.filter(c => !a.includes(c)))
|
|
52
|
+
out.columnsRemoved.push(`${table}.${c}`)
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isEmptyDiff(d: HistoryDiff): boolean {
|
|
58
|
+
return (
|
|
59
|
+
!d.tablesAdded.length &&
|
|
60
|
+
!d.tablesRemoved.length &&
|
|
61
|
+
!d.columnsAdded.length &&
|
|
62
|
+
!d.columnsRemoved.length
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `applied_at` is stored as whole seconds — see `writeLedger`. */
|
|
67
|
+
export function formatWhen(appliedAt: number): string {
|
|
68
|
+
if (!Number.isFinite(appliedAt) || appliedAt <= 0) return 'unknown'
|
|
69
|
+
return new Date(appliedAt * 1000).toISOString().replace('T', ' ').slice(0, 19)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function formatEntry(
|
|
73
|
+
entry: LedgerEntry,
|
|
74
|
+
previous: LedgerEntry | undefined,
|
|
75
|
+
isCurrent: boolean,
|
|
76
|
+
): string[] {
|
|
77
|
+
const lines: string[] = []
|
|
78
|
+
const marker = isCurrent ? ' (current)' : ''
|
|
79
|
+
const tables = namesOf(entry.constraints).length
|
|
80
|
+
lines.push(
|
|
81
|
+
` #${entry.id} ${formatWhen(entry.appliedAt)} UTC ${tables} table${
|
|
82
|
+
tables === 1 ? '' : 's'
|
|
83
|
+
}${marker}`,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const diff = diffEntries(previous?.constraints ?? null, entry.constraints)
|
|
87
|
+
if (!previous) {
|
|
88
|
+
lines.push(' initial schema')
|
|
89
|
+
} else if (isEmptyDiff(diff)) {
|
|
90
|
+
// Reachable and not a bug: a sync that only changed a column's *type* or an
|
|
91
|
+
// index moves no names. Saying so is better than printing an empty block.
|
|
92
|
+
lines.push(' no table or column names changed')
|
|
93
|
+
} else {
|
|
94
|
+
const show = (label: string, items: string[]) => {
|
|
95
|
+
if (items.length) lines.push(` ${label} ${items.join(', ')}`)
|
|
96
|
+
}
|
|
97
|
+
show('+ tables ', diff.tablesAdded)
|
|
98
|
+
show('- tables ', diff.tablesRemoved)
|
|
99
|
+
show('+ columns', diff.columnsAdded)
|
|
100
|
+
show('- columns', diff.columnsRemoved)
|
|
101
|
+
}
|
|
102
|
+
// Only worth saying on rows that predate the payload carrying indexes,
|
|
103
|
+
// because those are exactly the rows `db:rollback` will refuse.
|
|
104
|
+
if (entry.indexes === undefined) {
|
|
105
|
+
lines.push(' (no index record — written before ledger v2)')
|
|
106
|
+
}
|
|
107
|
+
return lines
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class HistoryService {
|
|
111
|
+
protected constructor() {}
|
|
112
|
+
|
|
113
|
+
static helpRequested(argv: string[] = process.argv.slice(2)): boolean {
|
|
114
|
+
return argv.includes('--help') || argv.includes('-h')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
static printHelp(): void {
|
|
118
|
+
// Program output, not a log line — the same call the other commands' usage
|
|
119
|
+
// text makes, and one of the two documented `console` exceptions.
|
|
120
|
+
console.log(`
|
|
121
|
+
Usage: bun run db:history
|
|
122
|
+
|
|
123
|
+
Lists every schema Bakery has applied to this database, newest first, with
|
|
124
|
+
what changed between each one and the one before it.
|
|
125
|
+
|
|
126
|
+
Read-only. Nothing here writes to the database.
|
|
127
|
+
`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static async run(): Promise<void> {
|
|
131
|
+
if (HistoryService.helpRequested()) return HistoryService.printHelp()
|
|
132
|
+
|
|
133
|
+
const { initConfig } = await import('@bakery-framework/core/core/config')
|
|
134
|
+
const { closeDB, connection, initDB } = await import('../connection')
|
|
135
|
+
await initConfig()
|
|
136
|
+
await initDB()
|
|
137
|
+
|
|
138
|
+
const entries = await readLedgerEntries(connection)
|
|
139
|
+
if (!entries.length) {
|
|
140
|
+
// The level is `logger.log`'s second argument. A leading 'I ' is the
|
|
141
|
+
// `messageLogger` table syntax and would be printed verbatim here.
|
|
142
|
+
logger.log(
|
|
143
|
+
'No schema history yet. The ledger fills up as %ydb:sync%* applies changes.',
|
|
144
|
+
'info',
|
|
145
|
+
)
|
|
146
|
+
await closeDB()
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
logger.log(
|
|
151
|
+
`%g${entries.length}%* applied schema${entries.length === 1 ? '' : 's'}, newest first:`,
|
|
152
|
+
'info',
|
|
153
|
+
)
|
|
154
|
+
// Newest first, and each row is compared against the row *after* it in the
|
|
155
|
+
// list, which is the one that came before it in time.
|
|
156
|
+
entries.forEach((entry, i) => {
|
|
157
|
+
for (const line of formatEntry(entry, entries[i + 1], i === 0)) {
|
|
158
|
+
logger.log(line, 'info')
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
await closeDB()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (import.meta.main) {
|
|
167
|
+
await HistoryService.run()
|
|
168
|
+
process.exit(0)
|
|
169
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import '@bakery-framework/core/core/init'
|
|
2
|
+
|
|
3
|
+
import { Logger, messageLogger } from '@bakery-framework/core/logger'
|
|
4
|
+
import { closeDB, connection, initDB } from '../connection'
|
|
5
|
+
import { loadSchema, schemaFromConfig } from './load'
|
|
6
|
+
|
|
7
|
+
const logger = new Logger('db-sync')
|
|
8
|
+
|
|
9
|
+
const syncMsgs = {
|
|
10
|
+
INVALID_SCHEMA: 'W %yschema.ts is invalid or corrupt. Treating as new.%*',
|
|
11
|
+
NO_DBINFO: 'W %yDBInfo namespace not found in schema.ts!%*',
|
|
12
|
+
FOREIGN_TARGET:
|
|
13
|
+
'E %rForeign key target is not a primary key or unique%*: {refs}. SQL requires the referenced column to be a PRIMARY KEY or carry a UNIQUE index. MySQL and Postgres refuse the CREATE; SQLite accepts it and then fails every insert with "foreign key mismatch". Add unique() on the target column.',
|
|
14
|
+
FOREIGN_UNSUPPORTED:
|
|
15
|
+
'E %rforeign() is declared but not implemented%*: {names}. No adapter emits FOREIGN KEY DDL, so it would be created as a plain index and then re-diffed on every sync. Use index() on the column and enforce the reference in your application.',
|
|
16
|
+
SCHEMA_NOT_FOUND:
|
|
17
|
+
'E %rConfigured schema path not found%*: {path}. %yschema%* in server.config.ts must name a file or an orm/ folder that exists; remove it to auto-detect. Generating one from the database? Create the (empty) file first.',
|
|
18
|
+
} as const
|
|
19
|
+
|
|
20
|
+
const MESSAGES = messageLogger(logger, syncMsgs)
|
|
21
|
+
|
|
22
|
+
export class SyncService {
|
|
23
|
+
protected constructor() {}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Usage text, and whether `--help` was asked for.
|
|
27
|
+
*
|
|
28
|
+
* Separated from `run()` so the answer is available before anything is
|
|
29
|
+
* opened. It used to be the last check in `run()`, after `initConfig`,
|
|
30
|
+
* `initDB`, `loadSchema` and both fatal guards — so the one flag whose whole
|
|
31
|
+
* job is to explain the others exited 1 on an unreachable database or a
|
|
32
|
+
* single `foreign()` declaration, and creating `bakery/server.db` as a side
|
|
33
|
+
* effect of asking for help.
|
|
34
|
+
*/
|
|
35
|
+
static helpRequested(argv: string[] = process.argv.slice(2)): boolean {
|
|
36
|
+
return argv.includes('--help') || argv.includes('-h')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static printHelp(): void {
|
|
40
|
+
// CLI usage text goes to stdout verbatim — it is program output, not a
|
|
41
|
+
// log line, so it deliberately bypasses the structured logger.
|
|
42
|
+
console.log(`
|
|
43
|
+
Usage: bun run db:sync [--choose=db|ts] [--dry-run] [--force-sync] [--help]
|
|
44
|
+
|
|
45
|
+
Flags:
|
|
46
|
+
--choose=db Generate schema.ts from the database (DB wins)
|
|
47
|
+
--choose=ts Apply schema.ts to the database (TS wins, default)
|
|
48
|
+
--dry-run Preview planned changes without applying them
|
|
49
|
+
--force-sync In production, allow destructive changes
|
|
50
|
+
--no-ledger Diff against live introspection, ignoring the recorded schema
|
|
51
|
+
--help, -h Show this help message
|
|
52
|
+
`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static async run() {
|
|
56
|
+
// Before initConfig/initDB/loadSchema: help must not depend on a working
|
|
57
|
+
// connection, a loadable schema, or the absence of a `foreign()`.
|
|
58
|
+
if (SyncService.helpRequested()) return SyncService.printHelp()
|
|
59
|
+
|
|
60
|
+
const { initConfig } = await import('@bakery-framework/core/core/config')
|
|
61
|
+
const config = await initConfig()
|
|
62
|
+
await initDB()
|
|
63
|
+
// `schema` in server.config.ts when the app sets one; otherwise prefers an
|
|
64
|
+
// orm/ folder and falls back to a single schema.ts.
|
|
65
|
+
const loaded = await loadSchema(process.cwd(), schemaFromConfig(config))
|
|
66
|
+
const schemaPath = loaded.targetPath
|
|
67
|
+
const constraints = loaded.constraints
|
|
68
|
+
const tsIndexes = loaded.indexes
|
|
69
|
+
|
|
70
|
+
// A configured path that does not exist is a config error, not an empty
|
|
71
|
+
// project. Continuing would sync against no schema and then generate a new
|
|
72
|
+
// one at the wrong location — with the app's real model still sitting
|
|
73
|
+
// where the typo missed it.
|
|
74
|
+
if (loaded.missing) {
|
|
75
|
+
MESSAGES.SCHEMA_NOT_FOUND({ path: loaded.missing })
|
|
76
|
+
await closeDB()
|
|
77
|
+
return process.exit(1)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (loaded.unreferenceable?.length) {
|
|
81
|
+
MESSAGES.FOREIGN_TARGET({ refs: loaded.unreferenceable.join(', ') })
|
|
82
|
+
await closeDB()
|
|
83
|
+
return process.exit(1)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// `foreign()` used to abort here, because no adapter emitted FOREIGN KEY
|
|
87
|
+
// DDL and the declaration would have become a plain index — referential
|
|
88
|
+
// integrity in appearance only. All three adapters now emit and read back
|
|
89
|
+
// real foreign keys, so the guard is gone.
|
|
90
|
+
//
|
|
91
|
+
// `findUnsupportedForeignKeys` is kept and still exported *from
|
|
92
|
+
// `sync/load`* — it is what a future adapter without support would use to
|
|
93
|
+
// refuse rather than pretend. It is no longer imported here, which is the
|
|
94
|
+
// distinction: the function has a reason to exist, the dead import did not.
|
|
95
|
+
|
|
96
|
+
if (loaded.layout === 'none' && (await Bun.file(schemaPath).exists())) {
|
|
97
|
+
MESSAGES.NO_DBINFO()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await connection.syncSchema(
|
|
101
|
+
constraints,
|
|
102
|
+
tsIndexes,
|
|
103
|
+
schemaPath,
|
|
104
|
+
loaded.layout,
|
|
105
|
+
)
|
|
106
|
+
await closeDB()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (import.meta.main) {
|
|
111
|
+
await SyncService.run()
|
|
112
|
+
process.exit(0)
|
|
113
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { Case } from '@bakery-framework/core/utils'
|
|
2
|
+
import type { SQLAdapter } from '../adapters/base'
|
|
3
|
+
import type * as SyncTypes from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A record, inside the database, of the schema Bakery last applied to it.
|
|
7
|
+
*
|
|
8
|
+
* **Why this exists.** Sync has always compared your TypeScript schema against
|
|
9
|
+
* live introspection, which means reconstructing *intent* from whatever the
|
|
10
|
+
* dialect reports back. Nearly every sync bug this project has had is that
|
|
11
|
+
* reconstruction going wrong: Postgres re-rendering `''` as
|
|
12
|
+
* `''::character varying`, MySQL reporting `information_schema` columns
|
|
13
|
+
* uppercase, `tinyint(1)` versus `data_type`, `EXTRACT` rewritten to
|
|
14
|
+
* `date_part` on older servers, an empty-string default parsed into the number
|
|
15
|
+
* zero. Each one produced a column that differed from itself forever, rebuilding
|
|
16
|
+
* the table on every single sync.
|
|
17
|
+
*
|
|
18
|
+
* Comparing against what we *wrote down* removes that whole class. Two JSON
|
|
19
|
+
* objects, no dialect spellings, no normalisation — and it can carry things
|
|
20
|
+
* introspection cannot report reliably, such as a `VARCHAR` width.
|
|
21
|
+
*
|
|
22
|
+
* **Why it is not simply trusted.** Introspection cannot lie about what exists;
|
|
23
|
+
* a ledger can. If someone alters the database outside Bakery, the ledger is
|
|
24
|
+
* stale, and migrating from a stale premise is worse than a spurious rebuild —
|
|
25
|
+
* it can drop a column somebody added. So the ledger is used only when its
|
|
26
|
+
* *shape* still matches the live database, and shape is checked by comparing
|
|
27
|
+
* table and column names, which needs no normalisation and therefore cannot
|
|
28
|
+
* suffer the bugs above. Anything else falls back to introspection.
|
|
29
|
+
*
|
|
30
|
+
* One consequence to know: **MySQL commits DDL implicitly**, so the schema
|
|
31
|
+
* change and the ledger write cannot be one atomic unit there. A crash between
|
|
32
|
+
* them leaves the ledger behind, which the shape check then catches — it fails
|
|
33
|
+
* safe, toward introspection.
|
|
34
|
+
*/
|
|
35
|
+
export const LEDGER_TABLE = '__bakery_schema'
|
|
36
|
+
|
|
37
|
+
/** Append-only: each successful sync adds a row, so history comes free. */
|
|
38
|
+
async function ensureTable(adapter: SQLAdapter): Promise<void> {
|
|
39
|
+
const q = (s: string) => adapter.quote(s)
|
|
40
|
+
const id = adapter.colDef({
|
|
41
|
+
type: 'integer',
|
|
42
|
+
autoIncrement: true,
|
|
43
|
+
primary: true,
|
|
44
|
+
})
|
|
45
|
+
const at = adapter.colDef({ type: 'integer' })
|
|
46
|
+
const payload = adapter.colDef({ type: 'string' })
|
|
47
|
+
await adapter
|
|
48
|
+
.query(
|
|
49
|
+
`CREATE TABLE IF NOT EXISTS ${q(LEDGER_TABLE)} (` +
|
|
50
|
+
`${q('id')} ${id}, ${q('applied_at')} ${at}, ${q('payload')} ${payload})`,
|
|
51
|
+
)
|
|
52
|
+
.run()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One applied schema, as stored. */
|
|
56
|
+
export interface LedgerEntry {
|
|
57
|
+
id: number
|
|
58
|
+
appliedAt: number
|
|
59
|
+
constraints: SyncTypes.DBConstraints
|
|
60
|
+
/**
|
|
61
|
+
* Absent on rows written before the payload carried indexes — see
|
|
62
|
+
* {@link parsePayload}. `undefined` means "not recorded", which is not the
|
|
63
|
+
* same as `{}` ("recorded, and there were none"), and `db:rollback` refuses
|
|
64
|
+
* the difference rather than guessing.
|
|
65
|
+
*/
|
|
66
|
+
indexes?: SyncTypes.DBIndexes
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A stored payload, in either shape it can have.
|
|
71
|
+
*
|
|
72
|
+
* v1 rows are a bare `DBConstraints` object; v2 wraps constraints alongside the
|
|
73
|
+
* indexes that were applied with them. The version is detected by the `v` key
|
|
74
|
+
* rather than by a column, so existing rows keep working with no migration of
|
|
75
|
+
* the migration table — which would be a fine joke and a bad idea.
|
|
76
|
+
*
|
|
77
|
+
* Indexes were missing from v1 because the ledger only ever fed the *diff*,
|
|
78
|
+
* which reads constraints. `db:rollback` replays a stored schema as a target,
|
|
79
|
+
* and a target with no indexes means "drop every index" — so the payload had to
|
|
80
|
+
* grow before rollback could be honest.
|
|
81
|
+
*/
|
|
82
|
+
function parsePayload(
|
|
83
|
+
raw: unknown,
|
|
84
|
+
): Omit<LedgerEntry, 'id' | 'appliedAt'> | null {
|
|
85
|
+
if (typeof raw !== 'string') return null
|
|
86
|
+
let parsed: any
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(raw)
|
|
89
|
+
} catch {
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
93
|
+
if (
|
|
94
|
+
parsed.v === 2 &&
|
|
95
|
+
parsed.constraints &&
|
|
96
|
+
typeof parsed.constraints === 'object'
|
|
97
|
+
) {
|
|
98
|
+
return {
|
|
99
|
+
constraints: parsed.constraints,
|
|
100
|
+
indexes:
|
|
101
|
+
parsed.indexes && typeof parsed.indexes === 'object'
|
|
102
|
+
? parsed.indexes
|
|
103
|
+
: undefined,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// v1: the object *is* the constraints. No `indexes` key to report.
|
|
107
|
+
return 'v' in parsed ? null : { constraints: parsed }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every applied schema, newest first.
|
|
112
|
+
*
|
|
113
|
+
* Never throws, for the same reason {@link readLedger} does not: an absent
|
|
114
|
+
* table is the normal state of a database Bakery has not synced, and a row that
|
|
115
|
+
* will not parse must not take a read-only command down. Unparseable rows are
|
|
116
|
+
* skipped rather than nulled into the list, so a caller counting entries counts
|
|
117
|
+
* usable ones.
|
|
118
|
+
*/
|
|
119
|
+
export async function readLedgerEntries(
|
|
120
|
+
adapter: SQLAdapter,
|
|
121
|
+
): Promise<LedgerEntry[]> {
|
|
122
|
+
try {
|
|
123
|
+
const q = (s: string) => adapter.quote(s)
|
|
124
|
+
const rows = (await adapter
|
|
125
|
+
.query(
|
|
126
|
+
`SELECT ${q('id')}, ${q('applied_at')}, ${q('payload')} ` +
|
|
127
|
+
`FROM ${q(LEDGER_TABLE)} ORDER BY ${q('id')} DESC`,
|
|
128
|
+
)
|
|
129
|
+
.all()) as any[]
|
|
130
|
+
const out: LedgerEntry[] = []
|
|
131
|
+
for (const row of rows ?? []) {
|
|
132
|
+
const payload = parsePayload(row?.payload)
|
|
133
|
+
if (!payload) continue
|
|
134
|
+
out.push({
|
|
135
|
+
id: Number(row.id),
|
|
136
|
+
appliedAt: Number(row.applied_at ?? row.appliedAt ?? 0),
|
|
137
|
+
...payload,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
return out
|
|
141
|
+
} catch {
|
|
142
|
+
return []
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The most recently applied schema, or `null` when there is none.
|
|
148
|
+
*
|
|
149
|
+
* Never throws: an unreadable or absent ledger is the normal state for a
|
|
150
|
+
* database Bakery has not synced yet, and for one it has, a corrupt row must
|
|
151
|
+
* degrade to introspection rather than take `db:sync` down.
|
|
152
|
+
*/
|
|
153
|
+
export async function readLedger(
|
|
154
|
+
adapter: SQLAdapter,
|
|
155
|
+
): Promise<SyncTypes.DBConstraints | null> {
|
|
156
|
+
const entries = await readLedgerEntries(adapter)
|
|
157
|
+
return entries[0]?.constraints ?? null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Record what was just applied. Best-effort: a sync that worked still worked. */
|
|
161
|
+
export async function writeLedger(
|
|
162
|
+
adapter: SQLAdapter,
|
|
163
|
+
constraints: SyncTypes.DBConstraints,
|
|
164
|
+
indexes: SyncTypes.DBIndexes = {},
|
|
165
|
+
): Promise<boolean> {
|
|
166
|
+
try {
|
|
167
|
+
await ensureTable(adapter)
|
|
168
|
+
const q = (s: string) => adapter.quote(s)
|
|
169
|
+
const payload = JSON.stringify({
|
|
170
|
+
v: 2,
|
|
171
|
+
constraints: stripLedger(constraints),
|
|
172
|
+
indexes,
|
|
173
|
+
})
|
|
174
|
+
await adapter
|
|
175
|
+
.query(
|
|
176
|
+
`INSERT INTO ${q(LEDGER_TABLE)} (${q('applied_at')}, ${q('payload')}) VALUES (?, ?)`,
|
|
177
|
+
)
|
|
178
|
+
.run(Math.floor(Date.now() / 1000), payload)
|
|
179
|
+
return true
|
|
180
|
+
} catch {
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Every spelling of the ledger's name that can appear in a constraints object.
|
|
187
|
+
*
|
|
188
|
+
* `getConstraints()` camelCases table names, so the table created as
|
|
189
|
+
* `__bakery_schema` comes back as `bakerySchema` — matching only the literal
|
|
190
|
+
* name silently stripped nothing, and the ledger showed up in its own diff as
|
|
191
|
+
* a table the schema did not declare. Which then made the shape check fail
|
|
192
|
+
* forever and the ledger never get used at all.
|
|
193
|
+
*/
|
|
194
|
+
const LEDGER_ALIASES = new Set([
|
|
195
|
+
LEDGER_TABLE,
|
|
196
|
+
Case.camel(LEDGER_TABLE),
|
|
197
|
+
Case.snake(LEDGER_TABLE),
|
|
198
|
+
])
|
|
199
|
+
|
|
200
|
+
/** The ledger must never appear in a diff, or sync would try to manage itself. */
|
|
201
|
+
export function stripLedger(
|
|
202
|
+
constraints: SyncTypes.DBConstraints,
|
|
203
|
+
): SyncTypes.DBConstraints {
|
|
204
|
+
const hit = Object.keys(constraints).filter(k => LEDGER_ALIASES.has(k))
|
|
205
|
+
if (!hit.length) return constraints
|
|
206
|
+
const out = { ...constraints }
|
|
207
|
+
for (const k of hit) delete (out as any)[k]
|
|
208
|
+
return out
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Does the ledger still describe the same set of tables and columns as the
|
|
213
|
+
* live database?
|
|
214
|
+
*
|
|
215
|
+
* **Names only, deliberately.** Comparing types or defaults here would need the
|
|
216
|
+
* same normalisation the ledger exists to avoid, and a disagreement there is
|
|
217
|
+
* exactly what the ledger is more trustworthy about. Names are names in every
|
|
218
|
+
* dialect, so this check cannot itself be wrong in the way the others were.
|
|
219
|
+
*/
|
|
220
|
+
export function shapesMatch(
|
|
221
|
+
ledger: SyncTypes.DBConstraints,
|
|
222
|
+
live: SyncTypes.DBConstraints,
|
|
223
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
224
|
+
const meta = (k: string) => k.startsWith('_')
|
|
225
|
+
const tablesOf = (c: SyncTypes.DBConstraints) =>
|
|
226
|
+
Object.keys(c)
|
|
227
|
+
.filter(t => !meta(t))
|
|
228
|
+
.sort()
|
|
229
|
+
const colsOf = (t: any) =>
|
|
230
|
+
Object.keys(t ?? {})
|
|
231
|
+
.filter(c => !meta(c))
|
|
232
|
+
.sort()
|
|
233
|
+
|
|
234
|
+
const a = tablesOf(ledger)
|
|
235
|
+
const b = tablesOf(live)
|
|
236
|
+
if (a.join() !== b.join()) {
|
|
237
|
+
const added = b.filter(t => !a.includes(t))
|
|
238
|
+
const gone = a.filter(t => !b.includes(t))
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
reason: `tables differ (${added.length ? `+${added.join(', ')}` : ''}${
|
|
242
|
+
added.length && gone.length ? '; ' : ''
|
|
243
|
+
}${gone.length ? `-${gone.join(', ')}` : ''})`,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const t of a) {
|
|
248
|
+
const lc = colsOf((ledger as any)[t])
|
|
249
|
+
const dc = colsOf((live as any)[t])
|
|
250
|
+
if (lc.join() !== dc.join()) {
|
|
251
|
+
return { ok: false, reason: `columns of ${t} differ` }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { ok: true }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The state sync should diff against: the ledger when it is still true of the
|
|
259
|
+
* database, introspection otherwise.
|
|
260
|
+
*
|
|
261
|
+
* Returning the *source* as well as the constraints is what lets the caller say
|
|
262
|
+
* out loud which one it used — a sync that quietly changed its mind about where
|
|
263
|
+
* truth lives would be very hard to debug later.
|
|
264
|
+
*/
|
|
265
|
+
export async function resolveCurrentState(
|
|
266
|
+
adapter: SQLAdapter,
|
|
267
|
+
options: { ignoreLedger?: boolean } = {},
|
|
268
|
+
): Promise<{
|
|
269
|
+
constraints: SyncTypes.DBConstraints
|
|
270
|
+
source: 'ledger' | 'introspection'
|
|
271
|
+
reason?: string
|
|
272
|
+
}> {
|
|
273
|
+
const live = stripLedger(await adapter.getConstraints())
|
|
274
|
+
// `--no-ledger`, and it exists because the ledger can be *wrong about types*
|
|
275
|
+
// in a way `shapesMatch` cannot see. That check compares names only, on
|
|
276
|
+
// purpose — comparing types would need the dialect normalisation the ledger
|
|
277
|
+
// exists to avoid — so a ledger claiming `VARCHAR(64)` where the column is
|
|
278
|
+
// really `TEXT` matches its shape and wins the diff. That happens whenever a
|
|
279
|
+
// sync legitimately concluded "no change needed" and the *rule* it used later
|
|
280
|
+
// got stricter, which is exactly what adding width to the diff did.
|
|
281
|
+
//
|
|
282
|
+
// Without an escape hatch the only recovery is deleting the ledger table by
|
|
283
|
+
// hand, and the symptom is `db:sync` insisting a database is perfectly synced
|
|
284
|
+
// against a schema it does not match.
|
|
285
|
+
if (options.ignoreLedger) {
|
|
286
|
+
return {
|
|
287
|
+
constraints: live,
|
|
288
|
+
source: 'introspection',
|
|
289
|
+
reason: 'ledger ignored (--no-ledger)',
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const ledger = await readLedger(adapter)
|
|
293
|
+
if (!ledger)
|
|
294
|
+
return {
|
|
295
|
+
constraints: live,
|
|
296
|
+
source: 'introspection',
|
|
297
|
+
reason: 'no ledger yet',
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const match = shapesMatch(ledger, live)
|
|
301
|
+
if (!match.ok) {
|
|
302
|
+
return { constraints: live, source: 'introspection', reason: match.reason }
|
|
303
|
+
}
|
|
304
|
+
return { constraints: ledger, source: 'ledger' }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Has the database stopped matching what Bakery last applied?
|
|
309
|
+
*
|
|
310
|
+
* The same comparison {@link resolveCurrentState} makes, exposed on its own so
|
|
311
|
+
* a caller can *report* it. Sync already handles drift correctly — it quietly
|
|
312
|
+
* falls back to introspection — but quietly is the problem: a column added by
|
|
313
|
+
* hand in production is indistinguishable, from the outside, from a normal run.
|
|
314
|
+
* This is how you find out.
|
|
315
|
+
*
|
|
316
|
+
* `null` when there is nothing to say: no ledger yet (a database Bakery has not
|
|
317
|
+
* synced is not "drifted"), or the shape still matches.
|
|
318
|
+
*
|
|
319
|
+
* Never throws. It runs at boot, and a database that cannot answer must not be
|
|
320
|
+
* the reason a server fails to start — the sync path will raise anything that
|
|
321
|
+
* genuinely matters.
|
|
322
|
+
*/
|
|
323
|
+
export async function detectDrift(
|
|
324
|
+
adapter: SQLAdapter,
|
|
325
|
+
): Promise<{ reason: string } | null> {
|
|
326
|
+
try {
|
|
327
|
+
const ledger = await readLedger(adapter)
|
|
328
|
+
if (!ledger) return null
|
|
329
|
+
const live = stripLedger(await adapter.getConstraints())
|
|
330
|
+
const match = shapesMatch(ledger, live)
|
|
331
|
+
return match.ok ? null : { reason: match.reason }
|
|
332
|
+
} catch {
|
|
333
|
+
return null
|
|
334
|
+
}
|
|
335
|
+
}
|