@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,368 @@
1
+ import { Case, fs } from '@bakery-framework/core/utils'
2
+ import { Try } from '@bakery-framework/core/utils/common'
3
+ import { collectConstraints } from '../define'
4
+ import type * as SyncTypes from './types'
5
+
6
+ /**
7
+ * Where a project's data model lives, and how it is read.
8
+ *
9
+ * Two layouts are supported. `orm/` is the newer one: tables in `schema.ts`,
10
+ * indexes in `indexes.ts`, foreign keys in `foreign.ts`, re-exported from
11
+ * `orm/index.ts`. A single `schema.ts` at the root still works, so nothing
12
+ * has to move.
13
+ *
14
+ * The folder is not only tidier — it separates what the generator owns from
15
+ * what a person wrote. `--choose=db` regenerates tables; with everything in
16
+ * one file it has to rewrite indexes and foreign keys too, and anything
17
+ * hand-authored alongside them is collateral.
18
+ *
19
+ * Both of those are *defaults*, probed in that order from the app's cwd. An
20
+ * app that keeps its model somewhere else — `db/`, `src/database/`, a path
21
+ * shared with another tool — sets `schema` in `server.config.ts` and this
22
+ * stops guessing:
23
+ *
24
+ * ```ts
25
+ * export default defineConfig({ schema: 'db/orm' }) // folder layout
26
+ * export default defineConfig({ schema: 'db/model.ts' }) // single file
27
+ * ```
28
+ *
29
+ * A configured path is resolved against the app's cwd and is **not** a hint.
30
+ * If it does not exist the load fails (`missing`) instead of quietly falling
31
+ * back to the defaults: a typo in that one string would otherwise leave the
32
+ * app running against no schema at all, and `--choose=db` would then generate
33
+ * a fresh one at the typo'd path while the real model sits untouched
34
+ * somewhere else.
35
+ */
36
+ /**
37
+ * Which shape the generator must write back.
38
+ *
39
+ * `folder` means `table()` values in `<dir>/schema.ts`, with `<dir>/index.ts`
40
+ * owning the re-exports and the schema registration. `file` and `none` mean the
41
+ * single-file `DBInfo` namespace, which carries its own registration block.
42
+ * Emitting the wrong one is not cosmetic — see `SchemaBuilder.generate`.
43
+ */
44
+ export type SchemaLayout = 'folder' | 'file' | 'none'
45
+
46
+ export interface LoadedSchema {
47
+ constraints: SyncTypes.DBConstraints
48
+ indexes: SyncTypes.DBIndexes
49
+ /**
50
+ * `Field.Foreign()` references whose target is neither a primary key nor
51
+ * uniquely indexed, as `child.col -> parent.col`.
52
+ *
53
+ * SQL forbids those. MySQL and Postgres refuse the CREATE outright; SQLite
54
+ * accepts the DDL and then fails *every insert* with `foreign key mismatch`,
55
+ * naming two tables and nothing else — so the caller aborts on this rather
56
+ * than letting it surface at runtime.
57
+ */
58
+ unreferenceable?: string[]
59
+ /** The file the generator should write tables back to. */
60
+ targetPath: string
61
+ layout: SchemaLayout
62
+ /**
63
+ * The path a configured `schema` pointed at that does not exist. Set only
64
+ * for that case — never when nothing is configured, since absence is a
65
+ * supported state for the defaults. The caller must abort rather than sync.
66
+ */
67
+ missing?: string
68
+ }
69
+
70
+ const CONSTRAINT_TYPES = new Set(['index', 'unique', 'foreign'])
71
+
72
+ /**
73
+ * Foreign keys are declarable but not implemented, and the failure is nasty:
74
+ * no adapter emits FOREIGN KEY DDL, so a declaration is created as an ordinary
75
+ * index. The next diff then compares `foreign` in TypeScript against `index`
76
+ * in the database, decides to drop and re-add it, and — because index drops
77
+ * count as destructive — aborts the sync. The dev server stops starting, with
78
+ * nothing pointing at the cause.
79
+ *
80
+ * Failing here converts that into one clear message. The alternative would be
81
+ * to keep quietly creating an index, which is worse than useless: it looks
82
+ * like referential integrity and enforces nothing.
83
+ */
84
+ export function findUnsupportedForeignKeys(
85
+ indexes: Record<string, unknown>,
86
+ ): string[] {
87
+ return Object.entries(indexes)
88
+ .filter(([, entry]) => (entry as { type?: string })?.type === 'foreign')
89
+ .map(([name]) => name)
90
+ }
91
+
92
+ /** Pull index/unique/foreign declarations out of a module's exports. */
93
+ function collectIndexes(module: Record<string, unknown>) {
94
+ const indexes: Record<string, unknown> = {}
95
+
96
+ for (const [name, exported] of Object.entries(module)) {
97
+ if (!exported || typeof exported !== 'object') continue
98
+ const entry = exported as { type?: string; table?: string }
99
+ if (entry.type && CONSTRAINT_TYPES.has(entry.type) && entry.table) {
100
+ indexes[name] = entry
101
+ }
102
+ }
103
+
104
+ return indexes
105
+ }
106
+
107
+ /**
108
+ * The app's configured schema location, or `undefined` for auto-detection.
109
+ *
110
+ * Takes the whole config object rather than the field so the one place that
111
+ * knows the config's shape is here. `unknown` because core's `AppConfig` does
112
+ * not declare `schema` yet — `defaultConfig` in `core/core/config.ts` is
113
+ * annotated `Required<AppConfig>`, so declaring the field there forces a
114
+ * default value for it, and there is no single path to give: the default is
115
+ * the *probe*, not a location. Core spreads unknown keys from
116
+ * `server.config.ts` through verbatim, so the option works today, and adding
117
+ * the two lines that type it changes nothing here.
118
+ */
119
+ export function schemaFromConfig(config: unknown): string | undefined {
120
+ const value = (config as { schema?: unknown } | null | undefined)?.schema
121
+ if (typeof value !== 'string') return undefined
122
+ return value.trim() || undefined
123
+ }
124
+
125
+ async function importFresh(path: string) {
126
+ // Cache-busted: db:sync runs repeatedly in a dev process that has already
127
+ // imported the previous version of these modules.
128
+ return Try.catch(import(`${path}?t=${Date.now()}`))
129
+ }
130
+
131
+ function emptyAt(targetPath: string): LoadedSchema {
132
+ return { constraints: {}, indexes: {}, targetPath, layout: 'none' }
133
+ }
134
+
135
+ /**
136
+ * Import `entry` and pull the schema out of it.
137
+ *
138
+ * `targetPath` is passed rather than derived: for the folder layout the
139
+ * generator writes to `schema.ts` *beside* the entry, because `index.ts`
140
+ * re-exports hand-authored `indexes.ts` / `foreign.ts` and must survive
141
+ * `--choose=db` untouched.
142
+ */
143
+ async function readSchema(
144
+ entry: string,
145
+ layout: 'folder' | 'file',
146
+ targetPath: string,
147
+ ): Promise<LoadedSchema> {
148
+ const [error, module] = await importFresh(entry)
149
+ if (error || !module) return emptyAt(targetPath)
150
+
151
+ if (layout === 'folder') {
152
+ const resolved = resolveColumnForeignKeys(
153
+ collectConstraints(module) as SyncTypes.DBConstraints,
154
+ collectIndexes(module) as SyncTypes.DBIndexes,
155
+ )
156
+ return { ...resolved, targetPath, layout: 'folder' }
157
+ }
158
+
159
+ // The single-file layout may declare tables either way: the original
160
+ // `DBInfo` namespace, or `table()` values once a project starts migrating.
161
+ const fromDbInfo = module.DBInfo?.constraints
162
+ const constraints = fromDbInfo ?? collectConstraints(module)
163
+ const indexes =
164
+ module.DBInfo?.indexes ?? module.indexes ?? collectIndexes(module)
165
+
166
+ const resolved = resolveColumnForeignKeys(
167
+ constraints as SyncTypes.DBConstraints,
168
+ indexes as SyncTypes.DBIndexes,
169
+ )
170
+ return {
171
+ ...resolved,
172
+ targetPath,
173
+ layout: Object.keys(constraints).length ? 'file' : 'none',
174
+ }
175
+ }
176
+
177
+ type Resolved =
178
+ | { entry: string; layout: 'folder' | 'file'; targetPath: string }
179
+ | { missing: string; targetPath: string }
180
+
181
+ /**
182
+ * Turn a configured `schema` value into an entry file and a write target.
183
+ *
184
+ * Relative paths resolve against the app's cwd — the framework's own location
185
+ * is irrelevant and, once it is an installed package, meaningless.
186
+ *
187
+ * A directory means the folder layout. So does a path ending in `index.ts`,
188
+ * which is the same folder addressed by its entry file; pointing the
189
+ * generator at that file would have it overwrite the re-exports.
190
+ */
191
+
192
+ /**
193
+ * Where the folder layout's table declarations live.
194
+ *
195
+ * `tables.ts`, beside `views.ts` and `indexes.ts` — one file per kind of
196
+ * declaration, which is the separation the folder layout exists for. It was
197
+ * `schema.ts`, which read oddly next to its siblings and collided with the
198
+ * single-file layout's `schema.ts` in conversation.
199
+ *
200
+ * The old name is still honoured when it is the one on disk. Loading never
201
+ * cared — that goes through `index.ts`'s re-exports, so any filename works —
202
+ * but *generation* writes here, and writing `tables.ts` beside someone's
203
+ * existing `schema.ts` would leave two files declaring the same tables.
204
+ */
205
+ async function folderTarget(dir: string): Promise<string> {
206
+ const tables = `${dir}/tables.ts`
207
+ if (await Bun.file(tables).exists()) return tables
208
+ const legacy = `${dir}/schema.ts`
209
+ return (await Bun.file(legacy).exists()) ? legacy : tables
210
+ }
211
+
212
+ async function resolveConfigured(
213
+ cwd: string,
214
+ configured: string,
215
+ ): Promise<Resolved> {
216
+ const path = fs.resolve(cwd, configured)
217
+
218
+ if (await fs.isDir(path)) {
219
+ const entry = `${path}/index.ts`
220
+ const target = await folderTarget(path)
221
+ if (!(await Bun.file(entry).exists())) {
222
+ return { missing: entry, targetPath: target }
223
+ }
224
+ return { entry, layout: 'folder', targetPath: target }
225
+ }
226
+
227
+ if (!(await Bun.file(path).exists()))
228
+ return { missing: path, targetPath: path }
229
+
230
+ if (fs.parse(path).base === 'index.ts') {
231
+ return {
232
+ entry: path,
233
+ layout: 'folder',
234
+ targetPath: await folderTarget(fs.dirname(path)),
235
+ }
236
+ }
237
+
238
+ return { entry: path, layout: 'file', targetPath: path }
239
+ }
240
+
241
+ export async function loadSchema(
242
+ cwd: string,
243
+ configured?: string,
244
+ ): Promise<LoadedSchema> {
245
+ if (configured) {
246
+ const resolved = await resolveConfigured(cwd, configured)
247
+ if ('missing' in resolved) {
248
+ return { ...emptyAt(resolved.targetPath), missing: resolved.missing }
249
+ }
250
+ return readSchema(resolved.entry, resolved.layout, resolved.targetPath)
251
+ }
252
+
253
+ const empty = emptyAt(`${cwd}/schema.ts`)
254
+
255
+ const folderEntry = `${cwd}/orm/index.ts`
256
+ if (await Bun.file(folderEntry).exists()) {
257
+ const loaded = await readSchema(
258
+ folderEntry,
259
+ 'folder',
260
+ await folderTarget(`${cwd}/orm`),
261
+ )
262
+ // Unchanged from before this file learned about `config.schema`: when the
263
+ // folder entry exists but cannot be imported, the generator stays pointed
264
+ // at <cwd>/schema.ts. Where a *broken* module leaves the write target is a
265
+ // separate question from where the schema is found.
266
+ return loaded.layout === 'none' ? empty : loaded
267
+ }
268
+
269
+ const filePath = `${cwd}/schema.ts`
270
+ if (!(await Bun.file(filePath).exists())) return empty
271
+
272
+ return readSchema(filePath, 'file', filePath)
273
+ }
274
+
275
+ /**
276
+ * Turn `Field.Foreign(users.id)` columns into real foreign key declarations.
277
+ *
278
+ * Runs once, where the whole schema is in scope, and does two things a column
279
+ * cannot do on its own:
280
+ *
281
+ * 1. **Copies the referenced column's type onto the child.** MySQL rejects a
282
+ * foreign key whose types do not match exactly — an `INT` child against a
283
+ * `BIGINT` parent is refused — and the two declarations are usually pages
284
+ * apart. Copying makes the mismatch unrepresentable rather than merely
285
+ * unlikely. `length` comes along for a `Varchar` key, for the same reason.
286
+ * 2. **Emits the key into the index map**, which is where `collectForeignKeys`
287
+ * and the diff already look. `foreign()` still exists and is unchanged; this
288
+ * is a second way to declare the same thing, for the single-column case.
289
+ *
290
+ * A reference to a table or column that does not exist is left alone rather
291
+ * than guessed at: the column keeps its placeholder type and no key is emitted,
292
+ * so the failure surfaces as a missing constraint rather than a silently wrong
293
+ * one.
294
+ */
295
+ function isUnique(
296
+ indexes: SyncTypes.DBIndexes,
297
+ table: string,
298
+ column: string,
299
+ ): boolean {
300
+ return Object.values(indexes).some(
301
+ (i: any) =>
302
+ i?.type === 'unique' &&
303
+ Case.snake(i.table) === Case.snake(table) &&
304
+ i.cols?.length === 1 &&
305
+ Case.snake(i.cols[0]) === Case.snake(column),
306
+ )
307
+ }
308
+
309
+ export function resolveColumnForeignKeys(
310
+ constraints: SyncTypes.DBConstraints,
311
+ indexes: SyncTypes.DBIndexes,
312
+ ): {
313
+ constraints: SyncTypes.DBConstraints
314
+ indexes: SyncTypes.DBIndexes
315
+ /** References whose target is neither a primary key nor uniquely indexed. */
316
+ unreferenceable: string[]
317
+ } {
318
+ const out = { ...indexes } as Record<string, unknown>
319
+ const unreferenceable: string[] = []
320
+
321
+ for (const [tableName, cols] of Object.entries(constraints)) {
322
+ // A view cannot carry a foreign key, and `view(name, sourceTable, body)`
323
+ // borrows the source table's columns — `_references` included. Without this
324
+ // the view gets a key of its own, which no dialect will create, so every
325
+ // sync plans to add it again: an empty printed plan and a run that never
326
+ // reports a perfectly synced database.
327
+ if ((cols as any)?._view) continue
328
+ for (const [colName, col] of Object.entries(cols as Record<string, any>)) {
329
+ const ref = col?._references
330
+ if (!ref) continue
331
+
332
+ const target = (constraints as any)[ref.table]?.[ref.column]
333
+ if (target) {
334
+ col.type = target.type
335
+ if (typeof target.length === 'number') col.length = target.length
336
+ // Never the parent's primary/autoIncrement: the child is a plain
337
+ // column that happens to point at one.
338
+
339
+ // SQL requires a foreign key's target to be a primary key or carry a
340
+ // unique index. MySQL and Postgres refuse the CREATE outright; SQLite
341
+ // *accepts the DDL* and then fails every insert with
342
+ // `foreign key mismatch`, naming the two tables and nothing else.
343
+ // Saying so here, against the schema, beats debugging that at runtime.
344
+ if (!target.primary && !isUnique(indexes, ref.table, ref.column)) {
345
+ unreferenceable.push(
346
+ `${tableName}.${colName} -> ${ref.table}.${ref.column}`,
347
+ )
348
+ }
349
+ }
350
+
351
+ out[`fk_${Case.snake(tableName)}_${Case.snake(colName)}`] = {
352
+ type: 'foreign',
353
+ table: tableName,
354
+ cols: [colName],
355
+ refTable: ref.table,
356
+ refCols: [ref.column],
357
+ onDelete: ref.onDelete,
358
+ onUpdate: ref.onUpdate,
359
+ }
360
+ }
361
+ }
362
+
363
+ return {
364
+ constraints,
365
+ indexes: out as SyncTypes.DBIndexes,
366
+ unreferenceable,
367
+ }
368
+ }
@@ -0,0 +1,200 @@
1
+ import '@bakery-framework/core/core/init'
2
+
3
+ import { Logger, messageLogger } from '@bakery-framework/core/logger'
4
+ import type { SQLAdapter } from '../adapters/base'
5
+ import { SchemaBuilder } from './builder'
6
+ import { MESSAGES as SYNC_MESSAGES, SyncEngine } from './engine'
7
+ import { type LedgerEntry, readLedgerEntries } from './ledger'
8
+ import { loadSchema, schemaFromConfig } from './load'
9
+ import type * as SyncTypes from './types'
10
+
11
+ const logger = new Logger('db-rollback')
12
+
13
+ // prettier-ignore
14
+ const rollbackMsgs = {
15
+ NO_HISTORY:
16
+ 'E %rNothing to roll back to%*: this database has no schema history. The ledger fills up as %ydb:sync%* applies changes, so there is nothing recorded before the current state.',
17
+ ONLY_ONE:
18
+ 'E %rNothing to roll back to%*: only one schema has ever been applied (#{id}). A rollback needs a previous state to restore.',
19
+ NO_SUCH_ENTRY:
20
+ 'E %rNo schema #{id} in this database’s history%*. Run %ydb:history%* to see what is there.',
21
+ IS_CURRENT:
22
+ 'E %rSchema #{id} is already the current one%*. Rolling back to it would do nothing.',
23
+ TARGET:
24
+ 'I Rolling back to schema %y#{id}%* ({when} UTC), applied {steps} change(s) ago.',
25
+ NO_INDEX_RECORD:
26
+ 'W %ySchema #{id} predates the ledger recording indexes%*, so this rollback leaves indexes exactly as they are. Tables and columns roll back; index changes made since then do not.',
27
+ SCHEMA_REWRITTEN: 'I Rewrote %y{path}%* to match the restored schema.',
28
+ SCHEMA_KEPT:
29
+ 'W %y--keep-schema: schema.ts still describes the newer schema.%* The next %ydb:sync%* — including the one a dev boot runs — will apply it again and undo this rollback. Revert the file yourself, or re-run without the flag.',
30
+ DONE: 'I %gRollback complete%*.',
31
+ } as const
32
+
33
+ const MESSAGES = messageLogger(logger, rollbackMsgs)
34
+
35
+ /**
36
+ * Which recorded schema to restore.
37
+ *
38
+ * Default is one step back, which is what "roll back" means with no argument.
39
+ * `entries` is newest first, so index 1 is the previous state and index 0 is
40
+ * where we are now.
41
+ */
42
+ export function pickTarget(
43
+ entries: LedgerEntry[],
44
+ toId?: number,
45
+ ):
46
+ | { ok: true; target: LedgerEntry; steps: number }
47
+ | { ok: false; code: keyof typeof rollbackMsgs; id?: number } {
48
+ if (!entries.length) return { ok: false, code: 'NO_HISTORY' }
49
+ if (toId === undefined) {
50
+ if (entries.length < 2)
51
+ return { ok: false, code: 'ONLY_ONE', id: entries[0]!.id }
52
+ return { ok: true, target: entries[1]!, steps: 1 }
53
+ }
54
+ const index = entries.findIndex(e => e.id === toId)
55
+ if (index < 0) return { ok: false, code: 'NO_SUCH_ENTRY', id: toId }
56
+ if (index === 0) return { ok: false, code: 'IS_CURRENT', id: toId }
57
+ return { ok: true, target: entries[index]!, steps: index }
58
+ }
59
+
60
+ export class RollbackService {
61
+ protected constructor() {}
62
+
63
+ static helpRequested(argv: string[] = process.argv.slice(2)): boolean {
64
+ return argv.includes('--help') || argv.includes('-h')
65
+ }
66
+
67
+ static printHelp(): void {
68
+ console.log(`
69
+ Usage: bun run db:rollback [--to=<id>] [--dry-run] [--force-sync] [--keep-schema]
70
+
71
+ Restores a schema this database previously had. The state is read from the
72
+ ledger Bakery writes on every sync, so there are no migration files and no
73
+ hand-written down-migrations.
74
+
75
+ Flags:
76
+ --to=<id> Roll back to a specific schema (see db:history). Default is
77
+ one step back.
78
+ --dry-run Preview the changes without applying them
79
+ --force-sync In production, allow destructive changes
80
+ --keep-schema Do not rewrite schema.ts. The next sync will undo the
81
+ rollback -- see the warning it prints.
82
+ --help, -h Show this help message
83
+
84
+ A rollback is a migration like any other: it can drop columns, and dropping a
85
+ column drops its data. It takes a backup first and asks before destructive
86
+ changes, exactly as db:sync does.
87
+ `)
88
+ }
89
+
90
+ /** `--to=12`, or undefined. A non-numeric value is treated as absent. */
91
+ static parseTo(argv: string[] = process.argv.slice(2)): number | undefined {
92
+ const raw = argv.find(a => a.startsWith('--to='))?.slice('--to='.length)
93
+ if (raw === undefined) return undefined
94
+ const n = Number(raw)
95
+ return Number.isInteger(n) ? n : undefined
96
+ }
97
+
98
+ static async run(): Promise<void> {
99
+ if (RollbackService.helpRequested()) return RollbackService.printHelp()
100
+
101
+ const { initConfig } = await import('@bakery-framework/core/core/config')
102
+ const { closeDB, connection, initDB } = await import('../connection')
103
+ const config = await initConfig()
104
+ await initDB()
105
+
106
+ const entries = await readLedgerEntries(connection)
107
+ const picked = pickTarget(entries, RollbackService.parseTo())
108
+ if (!picked.ok) {
109
+ // Switched rather than indexed: `MESSAGES[code]` types as the
110
+ // intersection of every message's parameters, so it demands `when` and
111
+ // `steps` from a message that has neither.
112
+ const id = picked.id ?? 0
113
+ if (picked.code === 'NO_HISTORY') MESSAGES.NO_HISTORY()
114
+ else if (picked.code === 'ONLY_ONE') MESSAGES.ONLY_ONE({ id })
115
+ else if (picked.code === 'NO_SUCH_ENTRY') MESSAGES.NO_SUCH_ENTRY({ id })
116
+ else MESSAGES.IS_CURRENT({ id })
117
+ await closeDB()
118
+ return process.exit(1)
119
+ }
120
+
121
+ const { target, steps } = picked
122
+ const { formatWhen } = await import('./history')
123
+ MESSAGES.TARGET({
124
+ id: target.id,
125
+ when: formatWhen(target.appliedAt),
126
+ steps,
127
+ })
128
+
129
+ // A v1 row recorded no indexes, and replaying it with an empty index set
130
+ // would read as "drop every index" — a silent, permanent performance
131
+ // change dressed up as a rollback. Restoring the *live* indexes instead
132
+ // means the plan contains no index work at all, which is the honest
133
+ // degradation: it does less than asked, and says so.
134
+ let indexes: SyncTypes.DBIndexes
135
+ if (target.indexes === undefined) {
136
+ MESSAGES.NO_INDEX_RECORD({ id: target.id })
137
+ indexes = await connection.getIndexes()
138
+ } else {
139
+ indexes = target.indexes
140
+ }
141
+
142
+ // Only for the path and layout. The constraints in schema.ts describe where
143
+ // we are now, which is precisely what a rollback is leaving behind.
144
+ const loaded = await loadSchema(process.cwd(), schemaFromConfig(config))
145
+
146
+ await RollbackService.apply(
147
+ connection,
148
+ target,
149
+ indexes,
150
+ loaded.targetPath,
151
+ loaded.layout,
152
+ )
153
+ await closeDB()
154
+ }
155
+
156
+ private static async apply(
157
+ adapter: SQLAdapter,
158
+ target: LedgerEntry,
159
+ indexes: SyncTypes.DBIndexes,
160
+ schemaPath: string,
161
+ layout: Parameters<typeof SchemaBuilder.generate>[4],
162
+ ): Promise<void> {
163
+ // The whole point of the ledger: a rollback is just a sync whose target is
164
+ // a schema we already stored. Backups, the destructive-change prompt,
165
+ // `--dry-run`, `--force-sync` and the new ledger row all come from
166
+ // `SyncEngine.run` unchanged — there is no second migration path to keep
167
+ // correct, which is the only reason this file is as short as it is.
168
+ await SyncEngine.run(
169
+ adapter,
170
+ target.constraints,
171
+ indexes,
172
+ schemaPath,
173
+ layout,
174
+ )
175
+
176
+ if (process.argv.includes('--dry-run')) return
177
+
178
+ // Without this the rollback is undone by the next thing that syncs, and a
179
+ // dev boot syncs. schema.ts still says what the *newer* schema was, so it
180
+ // would be applied straight back over the top.
181
+ if (process.argv.includes('--keep-schema')) {
182
+ MESSAGES.SCHEMA_KEPT()
183
+ return
184
+ }
185
+ await SchemaBuilder.generate(
186
+ adapter,
187
+ schemaPath,
188
+ SYNC_MESSAGES,
189
+ target.constraints,
190
+ layout,
191
+ )
192
+ MESSAGES.SCHEMA_REWRITTEN({ path: schemaPath })
193
+ MESSAGES.DONE()
194
+ }
195
+ }
196
+
197
+ if (import.meta.main) {
198
+ await RollbackService.run()
199
+ process.exit(0)
200
+ }
@@ -0,0 +1,101 @@
1
+ export type ColumnType =
2
+ | 'integer'
3
+ | 'string'
4
+ | 'number'
5
+ | 'boolean'
6
+ | 'buffer'
7
+ | 'bigint'
8
+ | 'json'
9
+
10
+ export interface ColumnConstraint {
11
+ type: ColumnType
12
+ /**
13
+ * Character length, for a sized text column (`VARCHAR(n)`).
14
+ *
15
+ * **Now part of the column diff**, so widening a `Varchar` migrates. This
16
+ * used to say the opposite, and the reason was sound at the time: it needed
17
+ * every adapter to report the width back exactly, and any that did not would
18
+ * rebuild the table on every sync. That is no longer a guess — all three
19
+ * were measured against live servers, and the one real trap (MySQL reporting
20
+ * `character_maximum_length = 65535` for an unsized `TEXT`) is handled in
21
+ * `SQLAdapter.sizedTextLength`.
22
+ *
23
+ * Compared only when both the schema and the database declare a width. An
24
+ * unsized column in the schema is not a request to shrink whatever is there.
25
+ */
26
+ length?: number
27
+ /**
28
+ * The permitted values of an enum column — `Field.Enum([...])`.
29
+ *
30
+ * Not part of the column diff, for exactly the reason `length` is not:
31
+ * MySQL reports an ENUM's members back in its own spelling, Postgres reports
32
+ * a CHECK constraint from a different catalog altogether, and any adapter
33
+ * that reported them even slightly differently would rebuild the table on
34
+ * every sync. Consequence to know, and it is the same one: **changing an
35
+ * enum's members does not migrate on its own.**
36
+ */
37
+ _enum?: string[]
38
+ primary?: boolean
39
+ autoIncrement?: boolean
40
+ nullable?: boolean
41
+ default?: unknown
42
+ _oldColumn?: string
43
+ _transform?: (oldValue: unknown, oldRow?: Record<string, unknown>) => unknown
44
+ }
45
+
46
+ export type TableConstraints = {
47
+ [column: string]: ColumnConstraint
48
+ } & {
49
+ _view?: string
50
+ _oldTable?: string
51
+ _transform?: (oldRow: Record<string, unknown>) => unknown
52
+ }
53
+
54
+ export interface IndexConstraint {
55
+ type: 'index' | 'unique' | 'foreign'
56
+ table: string
57
+ cols: string[]
58
+ /** Set only when `type` is 'foreign'. */
59
+ refTable?: string
60
+ refCols?: string[]
61
+ }
62
+
63
+ /**
64
+ * A foreign key as the database reports it.
65
+ *
66
+ * Identity is the tuple, not the name: SQLite's `PRAGMA foreign_key_list`
67
+ * does not return a constraint name at all, so keying on one would make every
68
+ * SQLite foreign key look new on every sync — the perpetual-rebuild failure
69
+ * this project keeps hitting.
70
+ */
71
+ /**
72
+ * Referential actions, normalised to the SQL spelling.
73
+ *
74
+ * One vocabulary for three dialects: MySQL and SQLite report these words back
75
+ * verbatim, Postgres reports single characters (`c`, `a`, `r`, `n`, `d`) which
76
+ * the adapter maps. Without one normal form the diff would compare `CASCADE`
77
+ * against `c` and drop-and-recreate the key on every sync.
78
+ */
79
+ export type ForeignKeyAction =
80
+ | 'NO ACTION'
81
+ | 'RESTRICT'
82
+ | 'CASCADE'
83
+ | 'SET NULL'
84
+ | 'SET DEFAULT'
85
+
86
+ export interface ForeignKeyInfo {
87
+ table: string
88
+ cols: string[]
89
+ refTable: string
90
+ refCols: string[]
91
+ name?: string
92
+ /** Defaults to `NO ACTION`, which is what every dialect emits when omitted. */
93
+ onDelete?: ForeignKeyAction
94
+ onUpdate?: ForeignKeyAction
95
+ }
96
+
97
+ export type DBForeignKeys = Record<string, ForeignKeyInfo>
98
+
99
+ export type DBConstraints = Record<string, TableConstraints>
100
+
101
+ export type DBIndexes = Record<string, IndexConstraint>