@bakery-framework/orm 2.0.0-alpha.3 → 2.0.0-alpha.5

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
@@ -93,6 +93,52 @@ export interface ForeignKeyInfo {
93
93
  onUpdate?: ForeignKeyAction
94
94
  }
95
95
 
96
+ /**
97
+ * The diff, as one value: what `sync/plan.ts` decided and `sync/execute.ts`
98
+ * applies.
99
+ *
100
+ * It lives here rather than beside `buildSyncPlan` because `sync/rename.ts` and
101
+ * `sync/diff.ts` both mutate a plan and are both imported *by* the planner. A
102
+ * home in `plan.ts` would make that pair of edges circular — type-only, and so
103
+ * erased under `verbatimModuleSyntax`, but this repo has been bitten by import
104
+ * cycles often enough that not creating one is worth more than the adjacency.
105
+ */
106
+ export namespace SyncPlan {
107
+ export interface TableRename {
108
+ oldName: string
109
+ newName: string
110
+ }
111
+ export interface ColumnDrop {
112
+ table: string
113
+ column: string
114
+ }
115
+ export interface ColumnAdd {
116
+ table: string
117
+ column: string
118
+ def: ColumnConstraint
119
+ }
120
+ export interface ColumnRename {
121
+ table: string
122
+ oldColumn: string
123
+ newColumn: string
124
+ }
125
+ }
126
+
127
+ export interface SyncPlan {
128
+ tablesToDrop: string[]
129
+ tablesToRename: SyncPlan.TableRename[]
130
+ columnsToDrop: SyncPlan.ColumnDrop[]
131
+ columnsToAdd: SyncPlan.ColumnAdd[]
132
+ columnsToRename: SyncPlan.ColumnRename[]
133
+ tablesToRebuild: Set<string>
134
+ /** Which source `dbConstraintsForDiff` came from, so the run can say so. */
135
+ ledgerSource?: 'ledger' | 'introspection'
136
+ ledgerReason?: string
137
+ viewsToUpdate: string[]
138
+ unmappedTsTables: Set<string>
139
+ dbConstraintsForDiff: DBConstraints
140
+ }
141
+
96
142
  export type DBForeignKeys = Record<string, ForeignKeyInfo>
97
143
 
98
144
  export type DBConstraints = Record<string, TableConstraints>