@bakery-framework/orm 2.0.0-alpha.4 → 2.0.0-alpha.6

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": "2.0.0-alpha.4",
3
+ "version": "2.0.0-alpha.6",
4
4
  "description": "Bakery database layer: adapters, query builder, schema sync and backup.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -54,6 +54,6 @@
54
54
  "bun": ">=1.3.14"
55
55
  },
56
56
  "dependencies": {
57
- "@bakery-framework/core": "^2.0.0-alpha.4"
57
+ "@bakery-framework/core": "^2.0.0-alpha.6"
58
58
  }
59
59
  }
@@ -849,20 +849,69 @@ export abstract class SQLAdapter {
849
849
  return new DatabaseStatement(this, sqlText)
850
850
  }
851
851
 
852
+ /**
853
+ * The `LIKE` escape character — `!`, and deliberately **not** a backslash.
854
+ *
855
+ * A backslash is the obvious choice and is unusable here. MySQL processes
856
+ * backslash escapes inside string literals where SQLite and Postgres do not,
857
+ * so the clause would need to differ per dialect; and worse, Postgres never
858
+ * receives what is written. `normalizePostgresSQL` treats `\'` as an escaped
859
+ * quote — MySQL's rule, applied to every dialect — so `ESCAPE '\'` leaves the
860
+ * string literal *open*, swallows the rest of the statement, and the driver
861
+ * reports `syntax error at or near "OFFSET"` from a clause several tokens
862
+ * later.
863
+ *
864
+ * That normalizer behaviour is a real defect and is not fixed here: a test
865
+ * pins it (`a backslash escape keeps the character it escapes`), and changing
866
+ * it would change every literal the framework emits. Choosing a character
867
+ * with no meaning to any of the three parsers sidesteps it entirely, and one
868
+ * spelling for all dialects beats a capability getter per dialect.
869
+ */
870
+ protected readonly likeEscape = '!'
871
+
872
+ get likeEscapeClause(): string {
873
+ return ` ESCAPE '${this.likeEscape}'`
874
+ }
875
+
876
+ /**
877
+ * Make a value match literally under `LIKE`.
878
+ *
879
+ * Without this a search for `50%` matches every row and one for `a_b` matches
880
+ * `axb` — `%` and `_` are the wildcards, and the filter passed user input
881
+ * through untouched. The escape character itself goes first, or escaping it
882
+ * afterwards would double the ones this method just added.
883
+ */
884
+ protected escapeLike(value: string): string {
885
+ const e = this.likeEscape
886
+ return value
887
+ .split(e)
888
+ .join(e + e)
889
+ .replace(/[%_]/g, m => `${e}${m}`)
890
+ }
891
+
892
+ /**
893
+ * `WHERE` and `ORDER BY` for a browsable table listing.
894
+ *
895
+ * A filter is either a **bare scalar**, which means `contains` and is what
896
+ * every caller sent before operators existed, or **`{op, value}`**. Keeping
897
+ * the scalar form meaningful is not politeness: `getData` is public and the
898
+ * dashboard still calls it that way.
899
+ *
900
+ * Columns are intersected with the real column set by the caller, so an
901
+ * unknown column disappears rather than reaching SQL. Values always bind.
902
+ */
852
903
  protected buildFilterSort(
853
904
  options: SQLAdapter.FilterSortOptions,
854
905
  validCols: Set<string>,
855
906
  ) {
856
907
  const whereParams: unknown[] = []
857
- const whereClauses = Object.entries(options.filters || {})
858
- .filter(
859
- ([col, val]) =>
860
- validCols.has(col) && val !== undefined && val !== null && val !== '',
861
- )
862
- .map(([col, val]) => {
863
- whereParams.push(`%${val}%`)
864
- return `${this.quote(col)} LIKE ?`
865
- })
908
+ const whereClauses: string[] = []
909
+
910
+ for (const [col, raw] of Object.entries(options.filters || {})) {
911
+ if (!validCols.has(col)) continue
912
+ const clause = this.filterClause(col, raw, whereParams)
913
+ if (clause) whereClauses.push(clause)
914
+ }
866
915
 
867
916
  const whereSql = whereClauses.length
868
917
  ? ` WHERE ${whereClauses.join(' AND ')}`
@@ -880,6 +929,67 @@ export abstract class SQLAdapter {
880
929
  return { whereSql, orderSql, whereParams }
881
930
  }
882
931
 
932
+ /**
933
+ * One filter, as SQL. Returns `null` when the filter says nothing.
934
+ *
935
+ * `params` is appended to rather than returned, because an operator may bind
936
+ * one value, two, or none at all — `IS NULL` has nothing to bind, and
937
+ * pretending otherwise is how a placeholder count drifts from its arguments.
938
+ */
939
+ private filterClause(
940
+ col: string,
941
+ raw: unknown,
942
+ params: unknown[],
943
+ ): string | null {
944
+ const quoted = this.quote(col)
945
+
946
+ // The pre-operator form. An empty string means "no filter" here, which is
947
+ // what a cleared text box sends and what every caller has relied on.
948
+ if (raw === null || raw === undefined || typeof raw !== 'object') {
949
+ if (raw === undefined || raw === null || raw === '') return null
950
+ params.push(`%${this.escapeLike(String(raw))}%`)
951
+ return `${quoted} LIKE ?${this.likeEscapeClause}`
952
+ }
953
+
954
+ const { op, value } = raw as { op?: string; value?: unknown }
955
+
956
+ // No value to bind, and none expected — these two are the whole reason a
957
+ // filter cannot be modelled as a plain column/value pair.
958
+ if (op === 'null') return `${quoted} IS NULL`
959
+ if (op === 'notnull') return `${quoted} IS NOT NULL`
960
+
961
+ if (value === undefined || value === null) return null
962
+
963
+ const comparison: Record<string, string> = {
964
+ eq: '=',
965
+ ne: '<>',
966
+ gt: '>',
967
+ gte: '>=',
968
+ lt: '<',
969
+ lte: '<=',
970
+ }
971
+ if (op && comparison[op]) {
972
+ params.push(value)
973
+ return `${quoted} ${comparison[op]} ?`
974
+ }
975
+
976
+ const pattern: Record<string, (v: string) => string> = {
977
+ contains: v => `%${v}%`,
978
+ starts: v => `${v}%`,
979
+ ends: v => `%${v}`,
980
+ }
981
+ const shape = op ? pattern[op] : undefined
982
+ if (shape) {
983
+ params.push(shape(this.escapeLike(String(value))))
984
+ return `${quoted} LIKE ?${this.likeEscapeClause}`
985
+ }
986
+
987
+ // An operator this dialect does not know is dropped rather than guessed
988
+ // at. Guessing would mean answering a question nobody asked, and the
989
+ // caller validates the vocabulary before it gets here.
990
+ return null
991
+ }
992
+
883
993
  protected formatDefault(
884
994
  def: unknown,
885
995
  boolTrue: string,
@@ -267,13 +267,22 @@ export class PGAdapter extends SQLAdapter {
267
267
  " AND table_schema NOT IN ('pg_catalog', 'information_schema')" +
268
268
  ' ORDER BY ordinal_position',
269
269
  ).all(t.name),
270
+ // `::regclass` casts a **string**, so the table name binds as a
271
+ // parameter. It used to interpolate `qName` — a double-quoted
272
+ // *identifier* — which Postgres reads as a column reference, so this
273
+ // threw `column "<table>" does not exist` for every table and took the
274
+ // whole of `getSchema()` down with it on this dialect.
275
+ //
276
+ // The quiet case was worse than the loud one: a table with a column of
277
+ // the same name resolved, casting that column's *value* to a regclass
278
+ // and reporting some other table's primary key as this one's.
270
279
  this.query(
271
280
  'SELECT a.attname AS name' +
272
281
  ' FROM pg_index i' +
273
282
  ' JOIN pg_attribute a ON a.attrelid = i.indrelid' +
274
283
  ' AND a.attnum = ANY(i.indkey)' +
275
- ` WHERE i.indisprimary AND i.indrelid = ${qName}::regclass`,
276
- ).all(),
284
+ ' WHERE i.indisprimary AND i.indrelid = ?::regclass',
285
+ ).all(t.name),
277
286
  this.query(
278
287
  'SELECT indexname AS name, indexdef AS def' +
279
288
  ' FROM pg_indexes' +
@@ -304,7 +304,18 @@ export class SQLiteAdapter extends SQLAdapter {
304
304
  name: c.name,
305
305
  type: c.type,
306
306
  notnull: c.notnull === 1,
307
- pk: c.pk === 1,
307
+ // `pk` is the column's **1-based position within the primary key**,
308
+ // not a boolean — `PRAGMA table_info` reports 0 for "not part of the
309
+ // key", 1 for the first key column, 2 for the second. So `=== 1`
310
+ // reported a composite `PRIMARY KEY (a, b)` as a single-column key on
311
+ // `a`, silently, and only on SQLite: MySQL reads `column_key = 'PRI'`
312
+ // and Postgres reads `pg_index.indisprimary`, both of which are set on
313
+ // every member.
314
+ //
315
+ // `parseConstraints` a few hundred lines down already had this right
316
+ // (`col.pk > 0`), which is why `getConstraints()` disagreed with
317
+ // `getSchema()` about the same table.
318
+ pk: c.pk > 0,
308
319
  })),
309
320
  indexes: idxs.map(i => ({ name: i.name, unique: i.unique === 1 })),
310
321
  })
@@ -0,0 +1,339 @@
1
+ import type { Logger } from '@bakery-framework/core/logger'
2
+ import { Case } from '@bakery-framework/core/utils'
3
+ import { findBestMatchAndPrompt } from './rename'
4
+ import type * as SyncTypes from './types'
5
+ import { normalizeViewBody } from './view-sql'
6
+
7
+ type SyncPlan = SyncTypes.SyncPlan
8
+
9
+ function diffColumnMismatch(
10
+ plan: SyncPlan,
11
+ dbName: string,
12
+ camelCol: string,
13
+ tsCol: any,
14
+ dbCol: any,
15
+ MESSAGES: any,
16
+ ) {
17
+ const tsNullable = tsCol.primary ? false : tsCol.nullable === true
18
+ const dbNullable = dbCol.primary ? false : dbCol.nullable === true
19
+ const tsDefault = tsCol.default === undefined ? null : tsCol.default
20
+ const dbDefault = dbCol.default === undefined ? null : dbCol.default
21
+ const isTypeMatch =
22
+ tsCol.type === dbCol.type ||
23
+ (tsCol.type === 'boolean' && dbCol.type === 'integer')
24
+ const norm = (v: any) =>
25
+ v === null
26
+ ? 'null'
27
+ : String(v)
28
+ .replace(/^\(+|\)+$/g, '')
29
+ .trim()
30
+
31
+ // Width joins the diff, so widening a Varchar migrates instead of silently
32
+ // doing nothing. It stayed out until all three adapters could be *measured*
33
+ // reporting it back exactly; see `SQLAdapter.sizedTextLength` for the MySQL
34
+ // TEXT trap that made this dangerous to add blind.
35
+ //
36
+ // Driven by the *schema* side only. When the schema declares a width, any
37
+ // other answer from the database differs — including no width at all, which
38
+ // is a real `TEXT` column that should become `VARCHAR(n)`. Requiring both
39
+ // sides to be sized meant sizing an existing TEXT column silently did
40
+ // nothing, and `db:sync` then reported a perfectly synced database whose
41
+ // columns did not match the schema it had just read.
42
+ //
43
+ // Converges because all three dialects report a `VARCHAR` width back exactly
44
+ // (measured, see `SQLAdapter.sizedTextLength`): after one rebuild the two
45
+ // agree. A column that reports *no* width really is unsized.
46
+ //
47
+ // When the schema declares no width, nothing differs — `Field.Text()` against
48
+ // an existing `VARCHAR` is not a request to shrink it.
49
+ const lengthDiffers =
50
+ typeof tsCol.length === 'number' && tsCol.length !== dbCol.length
51
+
52
+ // Enum members join the diff, so changing them migrates instead of silently
53
+ // doing nothing — but **only when the current state came from the ledger**.
54
+ //
55
+ // `_enum` is emitted as an inline `CHECK (col IN (...))` by all three
56
+ // dialects, and all three *will* report that constraint back — in three
57
+ // incompatible shapes. Measured:
58
+ //
59
+ // sqlite CHECK (status IN ('draft','live')) in the table DDL
60
+ // mysql (`status` in (_utf8mb4'draft',_utf8mb4'live')) charset prefixes
61
+ // pgsql CHECK (((status)::text = ANY ((ARRAY[...]))) re-rendered
62
+ //
63
+ // Postgres does not store the text it was given, it re-renders a parsed
64
+ // expression — the same trap that turned `EXTRACT` into `date_part` and
65
+ // rebuilt a table on every sync forever. Three parsers, each an opportunity
66
+ // for that bug, is the wrong trade when the ledger already holds the members
67
+ // exactly as declared.
68
+ //
69
+ // So under introspection this stays out of the diff. A schema-side-only
70
+ // comparison would find `_enum` on one side and nothing on the other, differ
71
+ // every time, and rebuild the table on every sync — which is precisely what
72
+ // the `length` note above says it waited to rule out before shipping.
73
+ const enumDiffers =
74
+ plan.ledgerSource === 'ledger' &&
75
+ !Bun.deepEquals(tsCol._enum ?? null, dbCol._enum ?? null)
76
+
77
+ if (
78
+ !isTypeMatch ||
79
+ tsNullable !== dbNullable ||
80
+ lengthDiffers ||
81
+ enumDiffers ||
82
+ norm(tsDefault) !== norm(dbDefault)
83
+ ) {
84
+ MESSAGES.COL_MISMATCH({ table: dbName, column: camelCol })
85
+ MESSAGES.COL_MISMATCH_TS({
86
+ tsType: tsCol.type,
87
+ tsNullable: String(tsNullable),
88
+ tsDefault: String(tsDefault),
89
+ })
90
+ MESSAGES.COL_MISMATCH_DB({
91
+ dbType: dbCol.type,
92
+ dbNullable: String(dbNullable),
93
+ dbDefault: String(dbDefault),
94
+ })
95
+ plan.tablesToRebuild.add(dbName)
96
+ }
97
+ }
98
+
99
+ function resolveColumnRenames(
100
+ plan: SyncPlan,
101
+ camelTable: string,
102
+ dbName: string,
103
+ constraints: any,
104
+ unmappedDbCols: Set<string>,
105
+ unmappedTsCols: Set<string>,
106
+ existingDbCamelCols: Set<string>,
107
+ ) {
108
+ for (const newCamel of [...unmappedTsCols]) {
109
+ const tsColObj = constraints[camelTable][newCamel]
110
+ if (!tsColObj?._oldColumn) continue
111
+ const oldCamel = Case.camel(tsColObj._oldColumn)
112
+ if (!existingDbCamelCols.has(oldCamel)) continue
113
+ plan.columnsToRename.push({
114
+ table: dbName,
115
+ oldColumn: Case.snake(tsColObj._oldColumn),
116
+ newColumn: Case.snake(newCamel),
117
+ })
118
+ unmappedDbCols.delete(Case.snake(tsColObj._oldColumn))
119
+ unmappedTsCols.delete(newCamel)
120
+ if (plan.dbConstraintsForDiff[camelTable]?.[oldCamel]) {
121
+ plan.dbConstraintsForDiff[camelTable][newCamel] =
122
+ plan.dbConstraintsForDiff[camelTable][oldCamel]
123
+ delete plan.dbConstraintsForDiff[camelTable][oldCamel]
124
+ }
125
+ existingDbCamelCols.delete(oldCamel)
126
+ existingDbCamelCols.add(newCamel)
127
+ }
128
+ }
129
+
130
+ function resolveUnmappedDbCols(
131
+ plan: SyncPlan,
132
+ camelTable: string,
133
+ dbName: string,
134
+ logger: Logger,
135
+ unmappedDbCols: Set<string>,
136
+ unmappedTsCols: Set<string>,
137
+ existingDbCamelCols: Set<string>,
138
+ ) {
139
+ for (const oldDbCol of [...unmappedDbCols]) {
140
+ const bestMatch = unmappedTsCols.size
141
+ ? findBestMatchAndPrompt(
142
+ oldDbCol,
143
+ unmappedTsCols,
144
+ 'column',
145
+ dbName,
146
+ logger,
147
+ )
148
+ : null
149
+ if (bestMatch) {
150
+ plan.columnsToRename.push({
151
+ table: dbName,
152
+ oldColumn: oldDbCol,
153
+ newColumn: Case.snake(bestMatch),
154
+ })
155
+ unmappedDbCols.delete(oldDbCol)
156
+ unmappedTsCols.delete(bestMatch)
157
+ const oldCamel = Case.camel(oldDbCol)
158
+ if (plan.dbConstraintsForDiff[camelTable]?.[oldCamel]) {
159
+ plan.dbConstraintsForDiff[camelTable][bestMatch] =
160
+ plan.dbConstraintsForDiff[camelTable][oldCamel]
161
+ delete plan.dbConstraintsForDiff[camelTable][oldCamel]
162
+ }
163
+ existingDbCamelCols.delete(oldCamel)
164
+ existingDbCamelCols.add(bestMatch)
165
+ } else {
166
+ plan.columnsToDrop.push({ table: dbName, column: oldDbCol })
167
+ }
168
+ }
169
+ }
170
+
171
+ function diffTableColumns(
172
+ plan: SyncPlan,
173
+ camelTable: string,
174
+ dbName: string,
175
+ constraints: any,
176
+ logger: Logger,
177
+ MESSAGES: any,
178
+ ) {
179
+ const existingDbCamelCols = new Set(
180
+ Object.keys(plan.dbConstraintsForDiff[camelTable] || {}).filter(
181
+ k => k !== '_view',
182
+ ),
183
+ )
184
+ const unmappedDbCols = new Set(
185
+ [...existingDbCamelCols]
186
+ .filter(c => !constraints[camelTable][c])
187
+ .map(Case.snake),
188
+ )
189
+ const unmappedTsCols = new Set(
190
+ Object.keys(constraints[camelTable]).filter(
191
+ c =>
192
+ !existingDbCamelCols.has(c) &&
193
+ !['_view', '_oldTable', '_transform'].includes(c),
194
+ ),
195
+ )
196
+
197
+ resolveColumnRenames(
198
+ plan,
199
+ camelTable,
200
+ dbName,
201
+ constraints,
202
+ unmappedDbCols,
203
+ unmappedTsCols,
204
+ existingDbCamelCols,
205
+ )
206
+ resolveUnmappedDbCols(
207
+ plan,
208
+ camelTable,
209
+ dbName,
210
+ logger,
211
+ unmappedDbCols,
212
+ unmappedTsCols,
213
+ existingDbCamelCols,
214
+ )
215
+
216
+ plan.columnsToAdd.push(
217
+ ...[...unmappedTsCols].map(newCamel => ({
218
+ table: dbName,
219
+ column: Case.snake(newCamel),
220
+ def: constraints[camelTable][newCamel],
221
+ })),
222
+ )
223
+
224
+ for (const camelCol of existingDbCamelCols) {
225
+ if (unmappedDbCols.has(Case.snake(camelCol))) continue
226
+ const tsCol = constraints[camelTable][camelCol]
227
+ const dbCol = plan.dbConstraintsForDiff[camelTable]?.[camelCol]
228
+ if (tsCol && dbCol) {
229
+ diffColumnMismatch(plan, dbName, camelCol, tsCol, dbCol, MESSAGES)
230
+ }
231
+ }
232
+ }
233
+
234
+ function diffViewStrings(
235
+ plan: SyncPlan,
236
+ camelTable: string,
237
+ dbName: string,
238
+ constraints: any,
239
+ database?: string,
240
+ ): boolean {
241
+ // Both sides through the same canonicaliser, which is the only thing that
242
+ // makes a text comparison viable: you write `SELECT id FROM users` and MySQL
243
+ // returns it fully qualified, fully quoted and aliased column by column.
244
+ //
245
+ // Symmetry is the whole requirement. Normalising the *generated file* while
246
+ // comparing raw — or stripping the schema on one side only — recreates the
247
+ // view on every sync, which is the same churn the column diff has hit twice.
248
+ const tsViewStr = normalizeViewBody(
249
+ String(constraints[camelTable]._view || ''),
250
+ database,
251
+ )
252
+ const dbViewStr = normalizeViewBody(
253
+ String(plan.dbConstraintsForDiff[camelTable]?._view || ''),
254
+ database,
255
+ )
256
+ if (tsViewStr || dbViewStr) {
257
+ if (tsViewStr !== dbViewStr) plan.viewsToUpdate.push(dbName)
258
+ if (tsViewStr && !dbViewStr) plan.tablesToDrop.push(dbName)
259
+ return true
260
+ }
261
+ return false
262
+ }
263
+
264
+ /**
265
+ * The view lifecycle: create, recreate, drop.
266
+ *
267
+ * Views were invisible to the planner. `initDbTablesMap` skips `_view` entries,
268
+ * and every existing comparison iterates that map — so a declared view never
269
+ * reached `diffViewStrings`, and nothing about a view ever reached
270
+ * `hasChanges`. The consequences, all three measured:
271
+ *
272
+ * - a **new** view was never planned,
273
+ * - an **edited** `SELECT` was never detected, so a view could not be changed,
274
+ * - a view the schema no longer declares was never dropped.
275
+ *
276
+ * They were invisible rather than broken: `syncViewsAndTablesPhase` recreates
277
+ * every declared view whenever a sync happens to run, so a view kept up to date
278
+ * as a side effect of unrelated work. With nothing else to do, `db:sync`
279
+ * reported a perfectly synced database and left the view alone.
280
+ *
281
+ * Bodies are compared through `normalizeViewBody` on both sides. That converges
282
+ * on SQLite, which stores the text verbatim, and via the ledger everywhere —
283
+ * the ledger records what was *applied*, so it holds the authored SELECT.
284
+ * Diffing against live introspection on MySQL or Postgres will still see a
285
+ * difference, because both re-render the body (MySQL re-qualifies every column,
286
+ * Postgres adds parentheses), and no amount of text normalisation short of a
287
+ * parser fixes that. It costs a recreate, and a view holds no data.
288
+ */
289
+ export function diffViews(
290
+ plan: SyncPlan,
291
+ constraints: SyncTypes.DBConstraints,
292
+ database?: string,
293
+ ) {
294
+ const dbSide = plan.dbConstraintsForDiff
295
+ const declared = new Set<string>()
296
+
297
+ for (const [name, cols] of Object.entries(constraints)) {
298
+ const body = (cols as SyncTypes.TableConstraints)?._view
299
+ if (!body) continue
300
+ const camel = Case.camel(name)
301
+ declared.add(camel)
302
+
303
+ const dbBody = (dbSide[camel] as SyncTypes.TableConstraints | undefined)
304
+ ?._view
305
+ const want = normalizeViewBody(String(body), database)
306
+ const have = dbBody ? normalizeViewBody(String(dbBody), database) : null
307
+ if (have !== want) plan.viewsToUpdate.push(Case.snake(name))
308
+ }
309
+
310
+ for (const [name, cols] of Object.entries(dbSide)) {
311
+ if (!(cols as SyncTypes.TableConstraints)?._view) continue
312
+ if (declared.has(Case.camel(name))) continue
313
+ // Same rule tables follow: what the schema does not declare, the database
314
+ // does not keep. Dropping is announced before it happens.
315
+ plan.tablesToDrop.push(Case.snake(name))
316
+ }
317
+ }
318
+
319
+ export function diffTableViewsAndColumns(
320
+ plan: SyncPlan,
321
+ dbTables: any,
322
+ constraints: any,
323
+ logger: Logger,
324
+ MESSAGES: any,
325
+ database?: string,
326
+ ) {
327
+ for (const camelTable of Object.keys(dbTables)) {
328
+ if (!constraints[camelTable]) continue
329
+ const dbName = dbTables[camelTable]!.dbName
330
+ if (
331
+ plan.tablesToRebuild.has(dbName) ||
332
+ plan.tablesToRebuild.has(Case.snake(camelTable))
333
+ )
334
+ continue
335
+ if (diffViewStrings(plan, camelTable, dbName, constraints, database))
336
+ continue
337
+ diffTableColumns(plan, camelTable, dbName, constraints, logger, MESSAGES)
338
+ }
339
+ }
@@ -3,16 +3,14 @@ import { Case } from '@bakery-framework/core/utils'
3
3
  import type { SQLAdapter } from '../adapters/base'
4
4
  import { SchemaBuilder } from './builder'
5
5
  import {
6
- buildSyncPlan,
7
6
  calculateForeignKeyDiff,
8
- calculateIndexDiff,
9
7
  collectForeignKeys,
10
8
  executeSyncPlan,
11
9
  hasOldWrappers,
12
- logPlannedChanges,
13
- } from './helpers'
10
+ } from './execute'
14
11
  import { writeLedger } from './ledger'
15
12
  import type { SchemaLayout } from './load'
13
+ import { buildSyncPlan, calculateIndexDiff, logPlannedChanges } from './plan'
16
14
  import type * as SyncTypes from './types'
17
15
 
18
16
  // prettier-ignore
@@ -70,6 +68,15 @@ export const syncMsgs = {
70
68
  EXEC_SYNC_VIEW: 'D Syncing view: %y{view}%*...',
71
69
  EXEC_SYNC_CONS: 'D Syncing constraints for: %y{table}%*...',
72
70
  EXEC_ADD_INDEX: 'I Creating %y{type}%* index: %g{name}%*...',
71
+ // Declared late. `executeSyncPlan` has always called these two, and
72
+ // `messageLogger`'s proxy answers an undeclared key with a live emitter that
73
+ // prints `E Error message not found: EXEC_ADD_FK` — so every foreign key
74
+ // added or dropped on a dialect that can ALTER one logged an *error* where
75
+ // its ten sibling operations logged progress. Only reachable with
76
+ // `supportsAlterForeignKey`, which is why SQLite-only local runs never
77
+ // showed it.
78
+ EXEC_DROP_FK: 'I Dropping foreign key: %r{table}.{name}%*...',
79
+ EXEC_ADD_FK: 'I Adding foreign key: %g{table}%* -> %g{ref}%*...',
73
80
  CATCH_UP_SUCCESS: 'I %gDatabase successfully caught up%*!',
74
81
  PROD_FORCE_REQUIRED: 'E %rProduction requires %y--force-sync%* to proceed.%*',
75
82
  BACKUP_REQUIRED:
@@ -274,7 +281,7 @@ export class SyncEngine {
274
281
  {
275
282
  await using _session = new SyncSession(adapter)
276
283
  await adapter.transaction(tx =>
277
- executeSyncPlan(
284
+ executeSyncPlan({
278
285
  tx,
279
286
  plan,
280
287
  constraints,
@@ -284,7 +291,7 @@ export class SyncEngine {
284
291
  tsFks,
285
292
  fksToAdd,
286
293
  fksToDrop,
287
- ),
294
+ }),
288
295
  )
289
296
  }
290
297