@bakery-framework/orm 1.2.3 → 2.0.0-alpha.11
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 +3 -3
- package/src/adapters/base.ts +140 -19
- package/src/adapters/mysql.ts +11 -4
- package/src/adapters/pgsql.ts +76 -57
- package/src/adapters/sqlite.ts +64 -36
- package/src/orm/index.ts +2 -3
- package/src/orm/query.ts +10 -0
- package/src/schema-registry.ts +4 -7
- package/src/sync/builder.ts +10 -15
- package/src/sync/diff.ts +339 -0
- package/src/sync/engine.ts +21 -22
- package/src/sync/execute.ts +522 -0
- package/src/sync/load.ts +6 -5
- package/src/sync/plan.ts +148 -0
- package/src/sync/rename.ts +188 -0
- package/src/sync/types.ts +69 -8
- package/src/sync/helpers.ts +0 -1145
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { Logger } from '@bakery-framework/core/logger'
|
|
2
|
+
import { Case } from '@bakery-framework/core/utils'
|
|
3
|
+
import type * as SyncTypes from './types'
|
|
4
|
+
|
|
5
|
+
type SyncPlan = SyncTypes.SyncPlan
|
|
6
|
+
|
|
7
|
+
function getStringSimilarity(str1: string, str2: string) {
|
|
8
|
+
const getBigrams = (str: string) =>
|
|
9
|
+
new Set(
|
|
10
|
+
Array.from({ length: str.length - 1 }, (_, i) => str.slice(i, i + 2)),
|
|
11
|
+
)
|
|
12
|
+
const bg1 = getBigrams(str1.toLowerCase())
|
|
13
|
+
const bg2 = getBigrams(str2.toLowerCase())
|
|
14
|
+
const intersection = bg1.intersection(bg2).size
|
|
15
|
+
const union = bg1.union(bg2).size
|
|
16
|
+
return union === 0 ? (str1 === str2 ? 1 : 0) : intersection / union
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shared with `sync/diff.ts`, which asks the same question about a column.
|
|
21
|
+
*
|
|
22
|
+
* The prompt and the bigram matcher behind it are the same for both; only the
|
|
23
|
+
* wording of the question and the default threshold differ.
|
|
24
|
+
*/
|
|
25
|
+
export function findBestMatchAndPrompt(
|
|
26
|
+
oldName: string,
|
|
27
|
+
unmappedSet: Set<string>,
|
|
28
|
+
itemType: 'table' | 'column',
|
|
29
|
+
contextName: string,
|
|
30
|
+
logger: Logger,
|
|
31
|
+
threshold = 0.3,
|
|
32
|
+
): string | null {
|
|
33
|
+
let bestAutoMatch: string | null = null
|
|
34
|
+
let bestScore = 0
|
|
35
|
+
|
|
36
|
+
for (const newCamel of unmappedSet) {
|
|
37
|
+
const score = getStringSimilarity(oldName, Case.snake(newCamel))
|
|
38
|
+
if (score > bestScore && score >= threshold) {
|
|
39
|
+
bestScore = score
|
|
40
|
+
bestAutoMatch = newCamel
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const unmappedArr = Array.from(unmappedSet)
|
|
45
|
+
const options = [
|
|
46
|
+
bestAutoMatch
|
|
47
|
+
? `Pick automatically (${Case.snake(bestAutoMatch)}: ${Math.round(bestScore * 100)}%)`
|
|
48
|
+
: 'Pick automatically (none)',
|
|
49
|
+
...unmappedArr.map(t => `Use ${itemType}: ${Case.snake(t)}`),
|
|
50
|
+
`Drop ${itemType}`,
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const promptMsg =
|
|
54
|
+
itemType === 'table'
|
|
55
|
+
? `Unmapped database table: '${contextName}'. What should we do?`
|
|
56
|
+
: `Unmapped column '${oldName}' in table '${contextName}'. What should we do?`
|
|
57
|
+
const sel = logger.selectIndex(promptMsg, options)
|
|
58
|
+
|
|
59
|
+
if (sel === 0) return bestAutoMatch
|
|
60
|
+
if (sel === options.length - 1) return null
|
|
61
|
+
return unmappedArr[sel - 1]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function initDbTablesMap(dbConstraints: SyncTypes.DBConstraints) {
|
|
65
|
+
const dbTables: Record<
|
|
66
|
+
string,
|
|
67
|
+
{ dbName: string; camelName: string; cols: Set<string> }
|
|
68
|
+
> = {}
|
|
69
|
+
for (const [rawTable, tableObj] of Object.entries(dbConstraints)) {
|
|
70
|
+
if (tableObj._view) continue
|
|
71
|
+
const camelTable = Case.camel(rawTable)
|
|
72
|
+
dbTables[camelTable] = {
|
|
73
|
+
dbName: Case.snake(rawTable),
|
|
74
|
+
camelName: camelTable,
|
|
75
|
+
cols: new Set(Object.keys(tableObj).filter(k => k !== '_view')),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return dbTables
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function tableHasTransform(tsTableObj: SyncTypes.TableConstraints): boolean {
|
|
82
|
+
return (
|
|
83
|
+
!!tsTableObj._transform ||
|
|
84
|
+
Object.values(tsTableObj).some(
|
|
85
|
+
col => col && (col as SyncTypes.ColumnConstraint)._transform,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveOldTableMapping(
|
|
91
|
+
plan: SyncPlan,
|
|
92
|
+
dbTables: any,
|
|
93
|
+
unmappedDbTables: Set<string>,
|
|
94
|
+
newCamel: string,
|
|
95
|
+
tsTableObj: SyncTypes.TableConstraints,
|
|
96
|
+
hasTransform: boolean,
|
|
97
|
+
) {
|
|
98
|
+
if (!tsTableObj._oldTable || !plan.unmappedTsTables.has(newCamel)) return
|
|
99
|
+
const oldCamel = Case.camel(tsTableObj._oldTable)
|
|
100
|
+
if (!dbTables[oldCamel]) return
|
|
101
|
+
if (!hasTransform) {
|
|
102
|
+
plan.tablesToRename.push({
|
|
103
|
+
oldName: dbTables[oldCamel].dbName,
|
|
104
|
+
newName: Case.snake(newCamel),
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
unmappedDbTables.delete(oldCamel)
|
|
108
|
+
plan.unmappedTsTables.delete(newCamel)
|
|
109
|
+
dbTables[newCamel] = { ...dbTables[oldCamel], camelName: newCamel }
|
|
110
|
+
delete dbTables[oldCamel]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function handleTableRenames(
|
|
114
|
+
plan: SyncPlan,
|
|
115
|
+
constraints: SyncTypes.DBConstraints,
|
|
116
|
+
dbTables: any,
|
|
117
|
+
) {
|
|
118
|
+
const normalizedConstraints: SyncTypes.DBConstraints = {}
|
|
119
|
+
for (const [k, v] of Object.entries(constraints)) {
|
|
120
|
+
normalizedConstraints[Case.camel(k)] = v
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const unmappedDbTables = new Set(
|
|
124
|
+
Object.keys(dbTables).filter(camel => !normalizedConstraints[camel]),
|
|
125
|
+
)
|
|
126
|
+
// Views are excluded, not merely absent. `initDbTablesMap` skips `_view`
|
|
127
|
+
// entries, so a view is never in `dbTables` and would look like a table that
|
|
128
|
+
// still needs creating — on every run, forever. `evaluateChanges` counts
|
|
129
|
+
// `unmappedTsTables`, so a schema with a view could never report a perfectly
|
|
130
|
+
// synced database. The view phase creates them; this set is about tables.
|
|
131
|
+
plan.unmappedTsTables = new Set(
|
|
132
|
+
Object.keys(normalizedConstraints).filter(
|
|
133
|
+
camel =>
|
|
134
|
+
!dbTables[camel] && !(normalizedConstraints[camel] as any)?._view,
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
for (const [newRaw, tsTableObj] of Object.entries(constraints)) {
|
|
139
|
+
if (!tsTableObj) continue
|
|
140
|
+
const newCamel = Case.camel(newRaw)
|
|
141
|
+
const hasTransform = tableHasTransform(tsTableObj)
|
|
142
|
+
if (hasTransform) plan.tablesToRebuild.add(Case.snake(newCamel))
|
|
143
|
+
resolveOldTableMapping(
|
|
144
|
+
plan,
|
|
145
|
+
dbTables,
|
|
146
|
+
unmappedDbTables,
|
|
147
|
+
newCamel,
|
|
148
|
+
tsTableObj,
|
|
149
|
+
hasTransform,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
return unmappedDbTables
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function promptAndRenameTables(
|
|
156
|
+
plan: SyncPlan,
|
|
157
|
+
dbTables: any,
|
|
158
|
+
unmappedDbTables: Set<string>,
|
|
159
|
+
logger: Logger,
|
|
160
|
+
) {
|
|
161
|
+
for (const oldCamel of [...unmappedDbTables]) {
|
|
162
|
+
const dbName = dbTables[oldCamel]!.dbName
|
|
163
|
+
const bestMatch = findBestMatchAndPrompt(
|
|
164
|
+
dbName,
|
|
165
|
+
plan.unmappedTsTables,
|
|
166
|
+
'table',
|
|
167
|
+
dbName,
|
|
168
|
+
logger,
|
|
169
|
+
0.5,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if (bestMatch) {
|
|
173
|
+
plan.tablesToRename.push({
|
|
174
|
+
oldName: dbName,
|
|
175
|
+
newName: Case.snake(bestMatch),
|
|
176
|
+
})
|
|
177
|
+
unmappedDbTables.delete(oldCamel)
|
|
178
|
+
plan.unmappedTsTables.delete(bestMatch)
|
|
179
|
+
dbTables[bestMatch] = {
|
|
180
|
+
...dbTables[oldCamel]!,
|
|
181
|
+
camelName: bestMatch,
|
|
182
|
+
}
|
|
183
|
+
delete dbTables[oldCamel]
|
|
184
|
+
} else {
|
|
185
|
+
plan.tablesToDrop.push(dbName)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/sync/types.ts
CHANGED
|
@@ -55,19 +55,27 @@ export interface IndexConstraint {
|
|
|
55
55
|
type: 'index' | 'unique' | 'foreign'
|
|
56
56
|
table: string
|
|
57
57
|
cols: string[]
|
|
58
|
+
/**
|
|
59
|
+
* The column names exactly as the database reported them, aligned with
|
|
60
|
+
* `cols` by position. Introspection always fills this; a TS-declared index
|
|
61
|
+
* (`Field.Unique`, `Field.Index`) has no database spelling to carry, which
|
|
62
|
+
* is why it is optional.
|
|
63
|
+
*
|
|
64
|
+
* It exists because `Case.snake` is not the inverse of `Case.camel`
|
|
65
|
+
* (`SKU_code` → `sKUCode` → `s_k_u_code`), so a consumer that needs the raw
|
|
66
|
+
* spelling — a statement, a display — cannot recover it from `cols` and had
|
|
67
|
+
* to rebuild a camel→raw map from the schema's column list. That rebuild was
|
|
68
|
+
* first-wins over collisions: a table holding both `user_id` and `userId`
|
|
69
|
+
* files them under one camel key, and an index over the *second* resolved to
|
|
70
|
+
* the first — a predicate over the wrong column. The adapter knows the real
|
|
71
|
+
* names at parse time; carrying them costs an array.
|
|
72
|
+
*/
|
|
73
|
+
rawCols?: string[]
|
|
58
74
|
/** Set only when `type` is 'foreign'. */
|
|
59
75
|
refTable?: string
|
|
60
76
|
refCols?: string[]
|
|
61
77
|
}
|
|
62
78
|
|
|
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
79
|
/**
|
|
72
80
|
* Referential actions, normalised to the SQL spelling.
|
|
73
81
|
*
|
|
@@ -83,6 +91,13 @@ export type ForeignKeyAction =
|
|
|
83
91
|
| 'SET NULL'
|
|
84
92
|
| 'SET DEFAULT'
|
|
85
93
|
|
|
94
|
+
/**
|
|
95
|
+
* A foreign key as the database reports it.
|
|
96
|
+
*
|
|
97
|
+
* Identity is the tuple, not the name: SQLite's `PRAGMA foreign_key_list` does
|
|
98
|
+
* not return a constraint name at all, so keying on one would make every SQLite
|
|
99
|
+
* foreign key look new on every sync.
|
|
100
|
+
*/
|
|
86
101
|
export interface ForeignKeyInfo {
|
|
87
102
|
table: string
|
|
88
103
|
cols: string[]
|
|
@@ -94,6 +109,52 @@ export interface ForeignKeyInfo {
|
|
|
94
109
|
onUpdate?: ForeignKeyAction
|
|
95
110
|
}
|
|
96
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The diff, as one value: what `sync/plan.ts` decided and `sync/execute.ts`
|
|
114
|
+
* applies.
|
|
115
|
+
*
|
|
116
|
+
* It lives here rather than beside `buildSyncPlan` because `sync/rename.ts` and
|
|
117
|
+
* `sync/diff.ts` both mutate a plan and are both imported *by* the planner. A
|
|
118
|
+
* home in `plan.ts` would make that pair of edges circular — type-only, and so
|
|
119
|
+
* erased under `verbatimModuleSyntax`, but this repo has been bitten by import
|
|
120
|
+
* cycles often enough that not creating one is worth more than the adjacency.
|
|
121
|
+
*/
|
|
122
|
+
export namespace SyncPlan {
|
|
123
|
+
export interface TableRename {
|
|
124
|
+
oldName: string
|
|
125
|
+
newName: string
|
|
126
|
+
}
|
|
127
|
+
export interface ColumnDrop {
|
|
128
|
+
table: string
|
|
129
|
+
column: string
|
|
130
|
+
}
|
|
131
|
+
export interface ColumnAdd {
|
|
132
|
+
table: string
|
|
133
|
+
column: string
|
|
134
|
+
def: ColumnConstraint
|
|
135
|
+
}
|
|
136
|
+
export interface ColumnRename {
|
|
137
|
+
table: string
|
|
138
|
+
oldColumn: string
|
|
139
|
+
newColumn: string
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface SyncPlan {
|
|
144
|
+
tablesToDrop: string[]
|
|
145
|
+
tablesToRename: SyncPlan.TableRename[]
|
|
146
|
+
columnsToDrop: SyncPlan.ColumnDrop[]
|
|
147
|
+
columnsToAdd: SyncPlan.ColumnAdd[]
|
|
148
|
+
columnsToRename: SyncPlan.ColumnRename[]
|
|
149
|
+
tablesToRebuild: Set<string>
|
|
150
|
+
/** Which source `dbConstraintsForDiff` came from, so the run can say so. */
|
|
151
|
+
ledgerSource?: 'ledger' | 'introspection'
|
|
152
|
+
ledgerReason?: string
|
|
153
|
+
viewsToUpdate: string[]
|
|
154
|
+
unmappedTsTables: Set<string>
|
|
155
|
+
dbConstraintsForDiff: DBConstraints
|
|
156
|
+
}
|
|
157
|
+
|
|
97
158
|
export type DBForeignKeys = Record<string, ForeignKeyInfo>
|
|
98
159
|
|
|
99
160
|
export type DBConstraints = Record<string, TableConstraints>
|