@bakery-framework/orm 1.1.0 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/orm",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Bakery database layer: adapters, query builder, schema sync and backup.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -260,10 +260,36 @@ function diffColumnMismatch(
260
260
  const lengthDiffers =
261
261
  typeof tsCol.length === 'number' && tsCol.length !== dbCol.length
262
262
 
263
+ // Enum members join the diff, so changing them migrates instead of silently
264
+ // doing nothing — but **only when the current state came from the ledger**.
265
+ //
266
+ // `_enum` is emitted as an inline `CHECK (col IN (...))` by all three
267
+ // dialects, and all three *will* report that constraint back — in three
268
+ // incompatible shapes. Measured:
269
+ //
270
+ // sqlite CHECK (status IN ('draft','live')) in the table DDL
271
+ // mysql (`status` in (_utf8mb4'draft',_utf8mb4'live')) charset prefixes
272
+ // pgsql CHECK (((status)::text = ANY ((ARRAY[...]))) re-rendered
273
+ //
274
+ // Postgres does not store the text it was given, it re-renders a parsed
275
+ // expression — the same trap that turned `EXTRACT` into `date_part` and
276
+ // rebuilt a table on every sync forever. Three parsers, each an opportunity
277
+ // for that bug, is the wrong trade when the ledger already holds the members
278
+ // exactly as declared.
279
+ //
280
+ // So under introspection this stays out of the diff. A schema-side-only
281
+ // comparison would find `_enum` on one side and nothing on the other, differ
282
+ // every time, and rebuild the table on every sync — which is precisely what
283
+ // the `length` note above says it waited to rule out before shipping.
284
+ const enumDiffers =
285
+ plan.ledgerSource === 'ledger' &&
286
+ !Bun.deepEquals(tsCol._enum ?? null, dbCol._enum ?? null)
287
+
263
288
  if (
264
289
  !isTypeMatch ||
265
290
  tsNullable !== dbNullable ||
266
291
  lengthDiffers ||
292
+ enumDiffers ||
267
293
  norm(tsDefault) !== norm(dbDefault)
268
294
  ) {
269
295
  MESSAGES.COL_MISMATCH({ table: dbName, column: camelCol })
@@ -216,26 +216,50 @@ export function stripLedger(
216
216
  * same normalisation the ledger exists to avoid, and a disagreement there is
217
217
  * exactly what the ledger is more trustworthy about. Names are names in every
218
218
  * dialect, so this check cannot itself be wrong in the way the others were.
219
+ *
220
+ * **But a name has two spellings, and the two sides do not use the same one.**
221
+ * The ledger stores the keys of your TypeScript schema — whatever you passed to
222
+ * `table()` / `view()` — while `getConstraints()` camelCases everything it reads
223
+ * back. So a table declared `view('published_posts', …)` is `published_posts` in
224
+ * the ledger and `publishedPosts` from introspection, and this check called that
225
+ * a drifted database: *"tables differ (+publishedPosts; -published_posts)"*, on
226
+ * a database nothing had touched. Permanently — the spellings never converge, so
227
+ * every later sync re-reported it and the ledger was never used again. Every app
228
+ * `bun create bakery` generated hit it on the first `db:sync`, because the
229
+ * generated schema declares exactly that view.
230
+ *
231
+ * The damage is quieter than the warning. Falling back to introspection is
232
+ * *safe*, so nothing breaks loudly — it just silently withdraws the thing the
233
+ * ledger is for. Enum member changes, for one, only migrate when the diff runs
234
+ * against the ledger (`plan.ledgerSource === 'ledger'`), so on any such app that
235
+ * feature was inert.
236
+ *
237
+ * Comparing camel-normalised names fixes it at the one place the two spellings
238
+ * meet. `LEDGER_ALIASES` below is the same bug, found earlier and patched for a
239
+ * single known name; this is the general form of it.
219
240
  */
220
241
  export function shapesMatch(
221
242
  ledger: SyncTypes.DBConstraints,
222
243
  live: SyncTypes.DBConstraints,
223
244
  ): { ok: true } | { ok: false; reason: string } {
224
245
  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()
246
+ // Compare on the normalised spelling, report the declared one — a diff that
247
+ // named tables the reader cannot find in either their schema or their database
248
+ // would trade one confusion for another.
249
+ const namesOf = (o: object) => {
250
+ const out = new Map<string, string>()
251
+ for (const k of Object.keys(o ?? {})) {
252
+ if (!meta(k)) out.set(Case.camel(k), k)
253
+ }
254
+ return out
255
+ }
256
+ const keysOf = (o: object) => [...namesOf(o).keys()].sort()
233
257
 
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))
258
+ const a = namesOf(ledger)
259
+ const b = namesOf(live)
260
+ if (keysOf(ledger).join() !== keysOf(live).join()) {
261
+ const added = [...b].filter(([k]) => !a.has(k)).map(([, name]) => name)
262
+ const gone = [...a].filter(([k]) => !b.has(k)).map(([, name]) => name)
239
263
  return {
240
264
  ok: false,
241
265
  reason: `tables differ (${added.length ? `+${added.join(', ')}` : ''}${
@@ -244,16 +268,52 @@ export function shapesMatch(
244
268
  }
245
269
  }
246
270
 
247
- for (const t of a) {
248
- const lc = colsOf((ledger as any)[t])
249
- const dc = colsOf((live as any)[t])
271
+ for (const [key, name] of a) {
272
+ const lc = keysOf((ledger as any)[name])
273
+ const dc = keysOf((live as any)[b.get(key) as string])
250
274
  if (lc.join() !== dc.join()) {
251
- return { ok: false, reason: `columns of ${t} differ` }
275
+ return { ok: false, reason: `columns of ${name} differ` }
252
276
  }
253
277
  }
254
278
  return { ok: true }
255
279
  }
256
280
 
281
+ /**
282
+ * Re-key a ledger payload the way introspection keys its own.
283
+ *
284
+ * The ledger stores the keys of your TypeScript schema verbatim; every consumer
285
+ * of "current state" downstream looks tables up by `Case.camel(name)`, because
286
+ * that is what `getConstraints()` produces. Handing the raw ledger to the
287
+ * planner therefore made every lookup miss — `diffViews` asked for
288
+ * `publishedPosts`, the ledger held `published_posts`, and a miss reads as "the
289
+ * database does not have this view", so the view was recreated on every single
290
+ * sync. Silent and harmless-looking; a view holds no data, so the only symptom
291
+ * is churn in the log.
292
+ *
293
+ * Normalising on read rather than on write is deliberate: it repairs the ledgers
294
+ * already written by earlier versions, which a write-side fix could not.
295
+ *
296
+ * Meta keys (`_view`, `_references`, …) are values, not identifiers, and are
297
+ * copied through untouched.
298
+ */
299
+ function normalizeLedgerKeys(
300
+ constraints: SyncTypes.DBConstraints,
301
+ ): SyncTypes.DBConstraints {
302
+ const out: any = {}
303
+ for (const [table, cols] of Object.entries(constraints)) {
304
+ if (table.startsWith('_') || !cols || typeof cols !== 'object') {
305
+ out[table] = cols
306
+ continue
307
+ }
308
+ const next: any = {}
309
+ for (const [col, def] of Object.entries(cols)) {
310
+ next[col.startsWith('_') ? col : Case.camel(col)] = def
311
+ }
312
+ out[Case.camel(table)] = next
313
+ }
314
+ return out
315
+ }
316
+
257
317
  /**
258
318
  * The state sync should diff against: the ledger when it is still true of the
259
319
  * database, introspection otherwise.
@@ -289,14 +349,15 @@ export async function resolveCurrentState(
289
349
  reason: 'ledger ignored (--no-ledger)',
290
350
  }
291
351
  }
292
- const ledger = await readLedger(adapter)
293
- if (!ledger)
352
+ const raw = await readLedger(adapter)
353
+ if (!raw)
294
354
  return {
295
355
  constraints: live,
296
356
  source: 'introspection',
297
357
  reason: 'no ledger yet',
298
358
  }
299
359
 
360
+ const ledger = normalizeLedgerKeys(raw)
300
361
  const match = shapesMatch(ledger, live)
301
362
  if (!match.ok) {
302
363
  return { constraints: live, source: 'introspection', reason: match.reason }