@bakery-framework/orm 2.0.0-alpha.1 → 2.0.0-alpha.12

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.
@@ -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,6 +55,22 @@ 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[]
@@ -93,6 +109,52 @@ export interface ForeignKeyInfo {
93
109
  onUpdate?: ForeignKeyAction
94
110
  }
95
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
+
96
158
  export type DBForeignKeys = Record<string, ForeignKeyInfo>
97
159
 
98
160
  export type DBConstraints = Record<string, TableConstraints>