@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 +2 -2
- package/src/adapters/base.ts +119 -9
- package/src/adapters/pgsql.ts +11 -2
- package/src/adapters/sqlite.ts +12 -1
- package/src/sync/diff.ts +339 -0
- package/src/sync/engine.ts +13 -6
- package/src/sync/execute.ts +522 -0
- package/src/sync/plan.ts +148 -0
- package/src/sync/rename.ts +188 -0
- package/src/sync/types.ts +46 -0
- package/src/sync/helpers.ts +0 -1145
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/orm",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
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.
|
|
57
|
+
"@bakery-framework/core": "^2.0.0-alpha.6"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/adapters/base.ts
CHANGED
|
@@ -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 =
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
)
|
|
862
|
-
.
|
|
863
|
-
|
|
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,
|
package/src/adapters/pgsql.ts
CHANGED
|
@@ -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
|
-
|
|
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' +
|
package/src/adapters/sqlite.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
})
|
package/src/sync/diff.ts
ADDED
|
@@ -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
|
+
}
|
package/src/sync/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|