@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.
@@ -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
+ }