@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
package/src/sync/load.ts
ADDED
|
@@ -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>
|