@prisma-next/target-sqlite 0.14.0-dev.49 → 0.14.0-dev.50
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/dist/control.mjs +6 -18
- package/dist/control.mjs.map +1 -1
- package/dist/migration.mjs +2 -2
- package/dist/native-type-normalizer.mjs +13 -1
- package/dist/native-type-normalizer.mjs.map +1 -0
- package/dist/{op-factory-call-CMBTpb4M.mjs → op-factory-call-D_eYsHTp.mjs} +82 -65
- package/dist/op-factory-call-D_eYsHTp.mjs.map +1 -0
- package/dist/op-factory-call.mjs +1 -1
- package/dist/planner-DhvFrgPT.mjs +854 -0
- package/dist/planner-DhvFrgPT.mjs.map +1 -0
- package/dist/{planner-produced-sqlite-migration-BSjMKJhI.mjs → planner-produced-sqlite-migration-DyRmcteq.mjs} +2 -2
- package/dist/{planner-produced-sqlite-migration-BSjMKJhI.mjs.map → planner-produced-sqlite-migration-DyRmcteq.mjs.map} +1 -1
- package/dist/planner-produced-sqlite-migration.mjs +1 -1
- package/dist/planner.d.mts +46 -10
- package/dist/planner.d.mts.map +1 -1
- package/dist/planner.mjs +1 -1
- package/dist/{sqlite-migration-BdT_3SlM.mjs → sqlite-migration-D93wCdSD.mjs} +2 -2
- package/dist/{sqlite-migration-BdT_3SlM.mjs.map → sqlite-migration-D93wCdSD.mjs.map} +1 -1
- package/package.json +18 -18
- package/src/core/control-target.ts +9 -22
- package/src/core/migrations/column-ddl-rendering.ts +177 -0
- package/src/core/migrations/diff-database-schema.ts +197 -31
- package/src/core/migrations/issue-planner.ts +359 -488
- package/src/core/migrations/operations/tables.ts +107 -67
- package/src/core/migrations/planner-strategies.ts +118 -137
- package/src/core/migrations/planner.ts +112 -37
- package/src/core/sqlite-schema-verifier.ts +6 -3
- package/dist/native-type-normalizer-CiSyVmMP.mjs +0 -14
- package/dist/native-type-normalizer-CiSyVmMP.mjs.map +0 -1
- package/dist/op-factory-call-CMBTpb4M.mjs.map +0 -1
- package/dist/planner-EoQcJGzv.mjs +0 -676
- package/dist/planner-EoQcJGzv.mjs.map +0 -1
|
@@ -1,44 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SQLite migration issue planner.
|
|
3
3
|
*
|
|
4
|
-
* Takes schema issues (from
|
|
4
|
+
* Takes node-typed schema-diff issues (from the one differ — see
|
|
5
|
+
* `buildSqlitePlanDiff` in `diff-database-schema.ts`) and emits migration IR
|
|
5
6
|
* (`SqliteOpFactoryCall[]`). Strategies consume issues they recognize and
|
|
6
|
-
* produce specialized call sequences (e.g. recreateTableStrategy absorbs
|
|
7
|
+
* produce specialized call sequences (e.g. `recreateTableStrategy` absorbs
|
|
7
8
|
* type/nullability/default/constraint mismatches into a single recreate op);
|
|
8
|
-
* remaining issues flow through `
|
|
9
|
+
* remaining issues flow through `mapNodeIssueToCall` for the default case.
|
|
10
|
+
*
|
|
11
|
+
* Every branch reads the diff node the issue carries (`issue.expected` /
|
|
12
|
+
* `issue.actual`) — never the contract, never `storageTypes`, never codec
|
|
13
|
+
* hooks. Column DDL resolves from the column node's `codecRef`
|
|
14
|
+
* (`column-ddl-rendering.ts`), never a recomputation against the contract.
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
|
-
import type { Contract, JsonValue } from '@prisma-next/contract/types';
|
|
12
17
|
import type {
|
|
13
|
-
CodecControlHooks,
|
|
14
18
|
MigrationOperationPolicy,
|
|
15
19
|
SqlPlannerConflict,
|
|
16
20
|
SqlPlannerConflictLocation,
|
|
17
21
|
} from '@prisma-next/family-sql/control';
|
|
18
22
|
import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
|
|
19
|
-
import type {
|
|
20
|
-
import type {
|
|
21
|
-
SqlStorage,
|
|
22
|
-
StorageColumn,
|
|
23
|
-
StorageTable,
|
|
24
|
-
StorageTypeInstance,
|
|
25
|
-
} from '@prisma-next/sql-contract/types';
|
|
26
|
-
import type { CodecRef, DdlTableConstraint } from '@prisma-next/sql-relational-core/ast';
|
|
27
|
-
import {
|
|
28
|
-
DdlColumn,
|
|
29
|
-
ForeignKeyConstraint,
|
|
30
|
-
FunctionColumnDefault,
|
|
31
|
-
LiteralColumnDefault,
|
|
32
|
-
PrimaryKeyConstraint,
|
|
33
|
-
UniqueConstraint,
|
|
34
|
-
} from '@prisma-next/sql-relational-core/ast';
|
|
23
|
+
import type { SchemaDiffIssue } from '@prisma-next/framework-components/control';
|
|
35
24
|
import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming';
|
|
36
|
-
import
|
|
25
|
+
import {
|
|
26
|
+
RelationalSchemaNodeKind,
|
|
27
|
+
type SqlColumnIR,
|
|
28
|
+
type SqlIndexIR,
|
|
29
|
+
SqlSchemaIR,
|
|
30
|
+
type SqlSchemaIRNode,
|
|
31
|
+
type SqlTableIR,
|
|
32
|
+
} from '@prisma-next/sql-schema-ir/types';
|
|
37
33
|
import { blindCast } from '@prisma-next/utils/casts';
|
|
38
|
-
import { ifDefined } from '@prisma-next/utils/defined';
|
|
39
34
|
import type { Result } from '@prisma-next/utils/result';
|
|
40
35
|
import { notOk, ok } from '@prisma-next/utils/result';
|
|
41
36
|
import { CONTROL_TABLE_NAMES } from '../control-tables';
|
|
37
|
+
import {
|
|
38
|
+
columnSpecFromNode,
|
|
39
|
+
ddlColumnFromNode,
|
|
40
|
+
isInlineAutoincrementPrimaryKeyNode,
|
|
41
|
+
tableConstraintsFromNode,
|
|
42
|
+
} from './column-ddl-rendering';
|
|
42
43
|
import {
|
|
43
44
|
AddColumnCall,
|
|
44
45
|
CreateIndexCall,
|
|
@@ -54,210 +55,137 @@ import type {
|
|
|
54
55
|
SqliteTableSpec,
|
|
55
56
|
SqliteUniqueSpec,
|
|
56
57
|
} from './operations/shared';
|
|
57
|
-
import {
|
|
58
|
-
buildColumnDefaultSql,
|
|
59
|
-
buildColumnTypeSql,
|
|
60
|
-
isInlineAutoincrementPrimaryKey,
|
|
61
|
-
resolveColumnTypeMetadata,
|
|
62
|
-
} from './planner-ddl-builders';
|
|
63
58
|
import {
|
|
64
59
|
type CallMigrationStrategy,
|
|
65
|
-
resolveNamespaceIdForIssue,
|
|
66
60
|
type StrategyContext,
|
|
67
61
|
sqlitePlannerStrategies,
|
|
68
|
-
tableAt,
|
|
69
62
|
} from './planner-strategies';
|
|
70
63
|
|
|
71
64
|
export type { CallMigrationStrategy, StrategyContext };
|
|
72
65
|
|
|
73
66
|
// ============================================================================
|
|
74
|
-
//
|
|
67
|
+
// Node-keyed issue ordering (dependency order)
|
|
75
68
|
// ============================================================================
|
|
76
69
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Re-keys the legacy `ISSUE_KIND_ORDER` (kind string → priority number) on
|
|
72
|
+
* `(nodeKind, reason)`. Numbers are preserved from the legacy table so the
|
|
73
|
+
* dependency intent stays legible; the final emission order is actually
|
|
74
|
+
* fixed downstream by category bucketing (create-table → add-column →
|
|
75
|
+
* create-index → recreate → drop-column → drop-index → drop-table), so this
|
|
76
|
+
* only breaks ties within a single bucket.
|
|
77
|
+
*/
|
|
78
|
+
export function nodeIssueOrder(issue: SchemaDiffIssue): number {
|
|
79
|
+
const node = issueNode(issue);
|
|
80
|
+
if (node === undefined) return 99;
|
|
81
|
+
switch (node.nodeKind) {
|
|
82
|
+
case RelationalSchemaNodeKind.foreignKey:
|
|
83
|
+
return issue.reason === 'not-expected' ? 10 : 60;
|
|
84
|
+
case RelationalSchemaNodeKind.unique:
|
|
85
|
+
return issue.reason === 'not-expected' ? 11 : 51;
|
|
86
|
+
case RelationalSchemaNodeKind.primaryKey:
|
|
87
|
+
return issue.reason === 'not-expected' ? 12 : 50;
|
|
88
|
+
case RelationalSchemaNodeKind.index:
|
|
89
|
+
return issue.reason === 'not-expected' ? 13 : 52;
|
|
90
|
+
case RelationalSchemaNodeKind.columnDefault:
|
|
91
|
+
if (issue.reason === 'not-expected') return 14;
|
|
92
|
+
return issue.reason === 'not-found' ? 42 : 43;
|
|
93
|
+
case RelationalSchemaNodeKind.column:
|
|
94
|
+
if (issue.reason === 'not-expected') return 15;
|
|
95
|
+
return issue.reason === 'not-found' ? 30 : 40;
|
|
96
|
+
case RelationalSchemaNodeKind.table:
|
|
97
|
+
return issue.reason === 'not-expected' ? 16 : 20;
|
|
98
|
+
case RelationalSchemaNodeKind.check:
|
|
99
|
+
if (issue.reason === 'not-found') return 53;
|
|
100
|
+
return issue.reason === 'not-expected' ? 54 : 55;
|
|
101
|
+
default:
|
|
102
|
+
return 99;
|
|
103
|
+
}
|
|
108
104
|
}
|
|
109
105
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const name =
|
|
114
|
-
'indexOrConstraint' in issue && typeof issue.indexOrConstraint === 'string'
|
|
115
|
-
? issue.indexOrConstraint
|
|
116
|
-
: '';
|
|
117
|
-
return `${table}\u0000${column}\u0000${name}`;
|
|
106
|
+
/** Deterministic tiebreak within an order bucket: the diff path itself already encodes table → child → grandchild. */
|
|
107
|
+
export function nodeIssueKey(issue: SchemaDiffIssue): string {
|
|
108
|
+
return issue.path.join(' ');
|
|
118
109
|
}
|
|
119
110
|
|
|
120
111
|
// ============================================================================
|
|
121
|
-
//
|
|
112
|
+
// Subtree coalescing — the planner's responsibility per the differ's contract
|
|
122
113
|
// ============================================================================
|
|
123
114
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
115
|
+
/**
|
|
116
|
+
* The generic differ is total: a missing/extra table (or column) emits an
|
|
117
|
+
* issue for itself AND for every node in its subtree (columns, defaults,
|
|
118
|
+
* constraints, indexes). `CreateTableCall`/`DropTableCall` and
|
|
119
|
+
* `AddColumnCall`/`DropColumnCall` already account for the whole subtree
|
|
120
|
+
* (reading it directly off the table/column node), so the nested issues are
|
|
121
|
+
* redundant — coalescing them is "the planner's responsibility" the differ's
|
|
122
|
+
* own contract assigns (`schema-diff.ts`). Drops any issue whose path is a
|
|
123
|
+
* strict descendant of a `not-found`/`not-expected` issue's path.
|
|
124
|
+
*/
|
|
125
|
+
export function coalesceSubtreeIssues(
|
|
126
|
+
issues: readonly SchemaDiffIssue[],
|
|
127
|
+
): readonly SchemaDiffIssue[] {
|
|
128
|
+
const collapsingPaths = issues
|
|
129
|
+
.filter((issue) => issue.reason === 'not-found' || issue.reason === 'not-expected')
|
|
130
|
+
.map((issue) => issue.path);
|
|
131
|
+
if (collapsingPaths.length === 0) return issues;
|
|
132
|
+
return issues.filter(
|
|
133
|
+
(issue) => !collapsingPaths.some((ancestor) => isStrictDescendantPath(issue.path, ancestor)),
|
|
134
|
+
);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
function
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
case 'nullability_mismatch':
|
|
142
|
-
return 'nullabilityConflict';
|
|
143
|
-
case 'primary_key_mismatch':
|
|
144
|
-
case 'unique_constraint_mismatch':
|
|
145
|
-
case 'index_mismatch':
|
|
146
|
-
case 'extra_primary_key':
|
|
147
|
-
case 'extra_unique_constraint':
|
|
148
|
-
return 'indexIncompatible';
|
|
149
|
-
case 'foreign_key_mismatch':
|
|
150
|
-
case 'extra_foreign_key':
|
|
151
|
-
return 'foreignKeyConflict';
|
|
152
|
-
default:
|
|
153
|
-
return 'missingButNonAdditive';
|
|
137
|
+
function isStrictDescendantPath(path: readonly string[], ancestor: readonly string[]): boolean {
|
|
138
|
+
if (path.length <= ancestor.length) return false;
|
|
139
|
+
for (let i = 0; i < ancestor.length; i += 1) {
|
|
140
|
+
if (path[i] !== ancestor[i]) return false;
|
|
154
141
|
}
|
|
142
|
+
return true;
|
|
155
143
|
}
|
|
156
144
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
table?: string;
|
|
161
|
-
column?: string;
|
|
162
|
-
constraint?: string;
|
|
163
|
-
} = {};
|
|
164
|
-
if (issue.table) location.table = issue.table;
|
|
165
|
-
if (issue.column) location.column = issue.column;
|
|
166
|
-
if (issue.indexOrConstraint) location.constraint = issue.indexOrConstraint;
|
|
167
|
-
return Object.keys(location).length > 0 ? (location as SqlPlannerConflictLocation) : undefined;
|
|
168
|
-
}
|
|
145
|
+
// ============================================================================
|
|
146
|
+
// Node helpers
|
|
147
|
+
// ============================================================================
|
|
169
148
|
|
|
170
|
-
function
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
kind: conflictKindForCall(call),
|
|
178
|
-
summary,
|
|
179
|
-
why: 'Use `migration new` to author a custom migration for this change.',
|
|
180
|
-
...(location ? { location } : {}),
|
|
181
|
-
};
|
|
149
|
+
export function issueNode(issue: SchemaDiffIssue): SqlSchemaIRNode | undefined {
|
|
150
|
+
const node = issue.expected ?? issue.actual;
|
|
151
|
+
if (node === undefined) return undefined;
|
|
152
|
+
return blindCast<
|
|
153
|
+
SqlSchemaIRNode,
|
|
154
|
+
'every node in a SQL schema diff tree is a SqlSchemaIRNode; nodeKind is its required discriminant'
|
|
155
|
+
>(node);
|
|
182
156
|
}
|
|
183
157
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
return 'indexIncompatible';
|
|
189
|
-
default:
|
|
190
|
-
return 'missingButNonAdditive';
|
|
158
|
+
/** Whether the expected/actual native type (resolved, or raw+many fallback) differs — mirrors `SqlColumnIR.isEqualTo`'s type comparison. */
|
|
159
|
+
export function columnTypeChanged(expected: SqlColumnIR, actual: SqlColumnIR): boolean {
|
|
160
|
+
if (expected.resolvedNativeType !== undefined && actual.resolvedNativeType !== undefined) {
|
|
161
|
+
return expected.resolvedNativeType !== actual.resolvedNativeType;
|
|
191
162
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
function locationForCall(call: SqliteOpFactoryCall): SqlPlannerConflictLocation | undefined {
|
|
195
|
-
const location: { table?: string; column?: string; index?: string } = {};
|
|
196
|
-
if ('tableName' in call) location.table = call.tableName;
|
|
197
|
-
if ('columnName' in call) location.column = call.columnName;
|
|
198
|
-
if ('indexName' in call) location.index = call.indexName;
|
|
199
|
-
return Object.keys(location).length > 0 ? (location as SqlPlannerConflictLocation) : undefined;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function isMissing(issue: SchemaIssue): boolean {
|
|
203
|
-
if (issue.kind === 'enum_values_changed') return false;
|
|
204
|
-
return issue.actual === undefined;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// ============================================================================
|
|
208
|
-
// StorageTable / StorageColumn → flat SqliteTableSpec
|
|
209
|
-
// ============================================================================
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Resolves codec / `typeRef` / default rendering into a flat
|
|
213
|
-
* `SqliteColumnSpec`. Mirrors Postgres's `toColumnSpec`. Once a column is
|
|
214
|
-
* flattened, downstream Calls and operation factories never see
|
|
215
|
-
* `StorageColumn` again — they deal in pre-rendered SQL fragments.
|
|
216
|
-
*/
|
|
217
|
-
export function toColumnSpec(
|
|
218
|
-
name: string,
|
|
219
|
-
column: StorageColumn,
|
|
220
|
-
storageTypes: Readonly<Record<string, StorageTypeInstance>>,
|
|
221
|
-
inlineAutoincrementPrimaryKey = false,
|
|
222
|
-
): SqliteColumnSpec {
|
|
223
|
-
const typeSql = buildColumnTypeSql(
|
|
224
|
-
column,
|
|
225
|
-
blindCast<
|
|
226
|
-
Record<string, StorageTypeInstance>,
|
|
227
|
-
'buildColumnTypeSql declares its storageTypes parameter as mutable Record while the planner stores it readonly; the helper does not mutate, so the readonly→mutable narrowing is sound'
|
|
228
|
-
>(storageTypes),
|
|
163
|
+
return (
|
|
164
|
+
expected.nativeType !== actual.nativeType || Boolean(expected.many) !== Boolean(actual.many)
|
|
229
165
|
);
|
|
230
|
-
const defaultSql = buildColumnDefaultSql(column.default);
|
|
231
|
-
return {
|
|
232
|
-
name,
|
|
233
|
-
typeSql,
|
|
234
|
-
defaultSql,
|
|
235
|
-
nullable: column.nullable,
|
|
236
|
-
...(inlineAutoincrementPrimaryKey ? { inlineAutoincrementPrimaryKey: true } : {}),
|
|
237
|
-
};
|
|
238
166
|
}
|
|
239
167
|
|
|
240
168
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
169
|
+
* Builds the flat `SqliteTableSpec` `RecreateTableCall` needs from the
|
|
170
|
+
* expected table node — the node-sourced equivalent of the retired
|
|
171
|
+
* `toTableSpec` (which read a raw contract `StorageTable`). Every column's
|
|
172
|
+
* spec is resolved from its `codecRef` via `columnSpecFromNode`.
|
|
245
173
|
*/
|
|
246
|
-
export function
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
): SqliteTableSpec {
|
|
250
|
-
const columns: SqliteColumnSpec[] = Object.entries(table.columns).map(([name, column]) =>
|
|
251
|
-
toColumnSpec(name, column, storageTypes, isInlineAutoincrementPrimaryKey(table, name)),
|
|
174
|
+
export function tableSpecFromNode(table: SqlTableIR): SqliteTableSpec {
|
|
175
|
+
const columns: SqliteColumnSpec[] = Object.values(table.columns).map((c) =>
|
|
176
|
+
columnSpecFromNode(c, isInlineAutoincrementPrimaryKeyNode(table, c)),
|
|
252
177
|
);
|
|
253
178
|
const uniques: SqliteUniqueSpec[] = table.uniques.map((u) => ({
|
|
254
179
|
columns: u.columns,
|
|
255
180
|
...(u.name !== undefined ? { name: u.name } : {}),
|
|
256
181
|
}));
|
|
182
|
+
// Every FK node on the expected tree is constraint-bearing by construction
|
|
183
|
+
// (contractToSchemaIR filters `constraint: false` FKs out before they ever
|
|
184
|
+
// become nodes — those only ever contribute an index, never an FK node).
|
|
257
185
|
const foreignKeys: SqliteForeignKeySpec[] = table.foreignKeys.map((fk) => ({
|
|
258
|
-
columns: fk.
|
|
259
|
-
references: { table: fk.
|
|
260
|
-
constraint:
|
|
186
|
+
columns: fk.columns,
|
|
187
|
+
references: { table: fk.referencedTable, columns: fk.referencedColumns },
|
|
188
|
+
constraint: true,
|
|
261
189
|
...(fk.name !== undefined ? { name: fk.name } : {}),
|
|
262
190
|
...(fk.onDelete !== undefined ? { onDelete: fk.onDelete } : {}),
|
|
263
191
|
...(fk.onUpdate !== undefined ? { onUpdate: fk.onUpdate } : {}),
|
|
@@ -271,324 +199,216 @@ export function toTableSpec(
|
|
|
271
199
|
}
|
|
272
200
|
|
|
273
201
|
// ============================================================================
|
|
274
|
-
//
|
|
202
|
+
// Conflict helpers
|
|
275
203
|
// ============================================================================
|
|
276
204
|
|
|
277
|
-
function
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
throw new Error(
|
|
293
|
-
`sqliteDefaultToDdlColumnDefault: unhandled kind "${blindCast<{ kind: string }, 'exhaustiveness: surface the unhandled default kind'>(exhaustive).kind}"`,
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
205
|
+
function issueConflict(
|
|
206
|
+
kind: SqlPlannerConflict['kind'],
|
|
207
|
+
summary: string,
|
|
208
|
+
location?: SqlPlannerConflict['location'],
|
|
209
|
+
): SqlPlannerConflict {
|
|
210
|
+
return {
|
|
211
|
+
kind,
|
|
212
|
+
summary,
|
|
213
|
+
why: 'Use `migration new` to author a custom migration for this change.',
|
|
214
|
+
...(location ? { location } : {}),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function issueLocation(tableName: string, columnName?: string): SqlPlannerConflictLocation {
|
|
219
|
+
return columnName !== undefined ? { table: tableName, column: columnName } : { table: tableName };
|
|
297
220
|
}
|
|
298
221
|
|
|
299
222
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
* `
|
|
223
|
+
* Conflict kind for a node kind that `recreateTableStrategy` absorbs for
|
|
224
|
+
* every reachable production issue. Reaching `mapNodeIssueToCall` for one of
|
|
225
|
+
* these means the recreate strategy didn't run — mirrors the legacy
|
|
226
|
+
* `conflictKindForIssue` per-kind categorization.
|
|
304
227
|
*/
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
Record<string, StorageTypeInstance>,
|
|
315
|
-
'buildColumnTypeSql declares its storageTypes parameter as mutable Record while the planner stores it readonly; the helper does not mutate, so the readonly→mutable narrowing is sound'
|
|
316
|
-
>(storageTypes),
|
|
317
|
-
);
|
|
318
|
-
|
|
319
|
-
if (inlineAutoincrement) {
|
|
320
|
-
// `DdlColumn` has no SQLite-specific autoincrement flag, so the full
|
|
321
|
-
// `PRIMARY KEY AUTOINCREMENT` clause is embedded in the `type` string.
|
|
322
|
-
// The DDL renderer (`ddl-renderer.ts`) substring-detects `AUTOINCREMENT`
|
|
323
|
-
// to suppress the normal NOT NULL / PRIMARY KEY / DEFAULT clause rendering
|
|
324
|
-
// and emit the entire type string verbatim. Both sites must stay in sync.
|
|
325
|
-
// The structural fix (a SQLite-specific column option) is tracked in TML-2866.
|
|
326
|
-
return new DdlColumn({ name, type: `${typeSql} PRIMARY KEY AUTOINCREMENT` });
|
|
327
|
-
}
|
|
328
|
-
const colDefault = sqliteDefaultToDdlColumnDefault(column.default);
|
|
329
|
-
const resolved = resolveColumnTypeMetadata(
|
|
330
|
-
column,
|
|
331
|
-
blindCast<
|
|
332
|
-
Record<string, StorageTypeInstance>,
|
|
333
|
-
'resolveColumnTypeMetadata declares its storageTypes parameter as mutable Record while the planner stores it readonly; the helper does not mutate, so the readonly→mutable narrowing is sound'
|
|
334
|
-
>(storageTypes),
|
|
335
|
-
);
|
|
336
|
-
const codecRef: CodecRef | undefined = resolved.codecId
|
|
337
|
-
? {
|
|
338
|
-
codecId: resolved.codecId,
|
|
339
|
-
...(resolved.typeParams !== undefined
|
|
340
|
-
? {
|
|
341
|
-
typeParams: blindCast<
|
|
342
|
-
JsonValue,
|
|
343
|
-
'resolved.typeParams is JsonValue-shaped storage metadata; the narrowed (non-undefined) value lands in CodecRef.typeParams which is JsonValue'
|
|
344
|
-
>(resolved.typeParams),
|
|
345
|
-
}
|
|
346
|
-
: {}),
|
|
347
|
-
}
|
|
348
|
-
: undefined;
|
|
349
|
-
return new DdlColumn({
|
|
350
|
-
name,
|
|
351
|
-
type: typeSql,
|
|
352
|
-
...(!column.nullable ? { notNull: true } : {}),
|
|
353
|
-
...(colDefault !== undefined ? { default: colDefault } : {}),
|
|
354
|
-
...(codecRef !== undefined ? { codecRef } : {}),
|
|
355
|
-
});
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
const constraints: DdlTableConstraint[] = [];
|
|
359
|
-
|
|
360
|
-
const hasInlinePk = Object.entries(table.columns).some(([name]) =>
|
|
361
|
-
isInlineAutoincrementPrimaryKey(table, name),
|
|
362
|
-
);
|
|
363
|
-
if (table.primaryKey && !hasInlinePk) {
|
|
364
|
-
constraints.push(new PrimaryKeyConstraint({ columns: table.primaryKey.columns }));
|
|
228
|
+
function absorbedConflictKind(nodeKind: string): SqlPlannerConflict['kind'] {
|
|
229
|
+
switch (nodeKind) {
|
|
230
|
+
case RelationalSchemaNodeKind.primaryKey:
|
|
231
|
+
case RelationalSchemaNodeKind.unique:
|
|
232
|
+
return 'indexIncompatible';
|
|
233
|
+
case RelationalSchemaNodeKind.foreignKey:
|
|
234
|
+
return 'foreignKeyConflict';
|
|
235
|
+
default:
|
|
236
|
+
return 'missingButNonAdditive';
|
|
365
237
|
}
|
|
238
|
+
}
|
|
366
239
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
columns: u.columns,
|
|
371
|
-
...(u.name !== undefined ? { name: u.name } : {}),
|
|
372
|
-
}),
|
|
373
|
-
);
|
|
374
|
-
}
|
|
240
|
+
// ============================================================================
|
|
241
|
+
// StorageTable / StorageColumn → flat SqliteTableSpec / DdlColumn helpers
|
|
242
|
+
// ============================================================================
|
|
375
243
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
)
|
|
244
|
+
/**
|
|
245
|
+
* Builds the `CreateTableCall` + per-index `CreateIndexCall`s for a
|
|
246
|
+
* newly-expected table. Reads only the table node's own children — indexes
|
|
247
|
+
* (declared + FK-backing, deduped) are already merged and ordered at
|
|
248
|
+
* derivation (`contractToSchemaIR`'s `convertTable`).
|
|
249
|
+
*/
|
|
250
|
+
function buildCreateTableCalls(table: SqlTableIR): SqliteOpFactoryCall[] {
|
|
251
|
+
const columns = Object.values(table.columns).map((c) =>
|
|
252
|
+
ddlColumnFromNode(c, isInlineAutoincrementPrimaryKeyNode(table, c)),
|
|
253
|
+
);
|
|
254
|
+
const hasInlinePk = Object.values(table.columns).some((c) =>
|
|
255
|
+
isInlineAutoincrementPrimaryKeyNode(table, c),
|
|
256
|
+
);
|
|
257
|
+
const constraints = tableConstraintsFromNode(table, hasInlinePk);
|
|
258
|
+
const calls: SqliteOpFactoryCall[] = [
|
|
259
|
+
new CreateTableCall(table.name, columns, constraints.length > 0 ? constraints : undefined),
|
|
260
|
+
];
|
|
261
|
+
for (const index of table.indexes) {
|
|
262
|
+
const indexName = index.name ?? defaultIndexName(table.name, index.columns);
|
|
263
|
+
calls.push(new CreateIndexCall(table.name, indexName, index.columns));
|
|
388
264
|
}
|
|
389
|
-
|
|
390
|
-
return { columns, constraints };
|
|
265
|
+
return calls;
|
|
391
266
|
}
|
|
392
267
|
|
|
393
268
|
// ============================================================================
|
|
394
|
-
// Issue
|
|
269
|
+
// Issue → Call mapping (per-issue default path)
|
|
395
270
|
// ============================================================================
|
|
396
271
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
272
|
+
function mapTableIssue(
|
|
273
|
+
issue: SchemaDiffIssue,
|
|
274
|
+
): Result<readonly SqliteOpFactoryCall[], SqlPlannerConflict> {
|
|
275
|
+
if (issue.reason === 'not-found') {
|
|
276
|
+
const table = blindCast<
|
|
277
|
+
SqlTableIR,
|
|
278
|
+
'a not-found table issue always carries the expected table node'
|
|
279
|
+
>(issue.expected);
|
|
280
|
+
return ok(buildCreateTableCalls(table));
|
|
281
|
+
}
|
|
282
|
+
if (issue.reason === 'not-expected') {
|
|
283
|
+
const table = blindCast<
|
|
284
|
+
SqlTableIR,
|
|
285
|
+
'a not-expected table issue always carries the actual table node'
|
|
286
|
+
>(issue.actual);
|
|
287
|
+
// Runner-owned control tables must never be dropped.
|
|
288
|
+
if (CONTROL_TABLE_NAMES.has(table.name)) return ok([]);
|
|
289
|
+
return ok([new DropTableCall(table.name)]);
|
|
290
|
+
}
|
|
291
|
+
// Unreachable: SqlTableIR.isEqualTo is identity, so a paired table can
|
|
292
|
+
// never mismatch — kept for exhaustiveness against a future node change.
|
|
293
|
+
return notOk(issueConflict('unsupportedOperation', `Unexpected table drift: ${issue.message}`));
|
|
407
294
|
}
|
|
408
295
|
|
|
409
|
-
|
|
410
|
-
|
|
296
|
+
function mapColumnIssue(
|
|
297
|
+
issue: SchemaDiffIssue,
|
|
298
|
+
ctx: StrategyContext,
|
|
299
|
+
): Result<readonly SqliteOpFactoryCall[], SqlPlannerConflict> {
|
|
300
|
+
const tableName = issue.path[1];
|
|
301
|
+
if (tableName === undefined) {
|
|
302
|
+
return notOk(
|
|
303
|
+
issueConflict(
|
|
304
|
+
'unsupportedOperation',
|
|
305
|
+
`Column issue has no table in its path: ${issue.message}`,
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
if (issue.reason === 'not-found') {
|
|
310
|
+
const column = blindCast<
|
|
311
|
+
SqlColumnIR,
|
|
312
|
+
'a not-found column issue always carries the expected column node'
|
|
313
|
+
>(issue.expected);
|
|
314
|
+
// A sole-autoincrement-PK column is always part of the table's own
|
|
315
|
+
// CREATE (never a bare ADD COLUMN — PK changes go through
|
|
316
|
+
// `recreateTableStrategy`), but the check is cheap and keeps this path
|
|
317
|
+
// honest against the table node rather than assuming it.
|
|
318
|
+
const table = ctx.expected.tables[tableName];
|
|
319
|
+
const inline = table !== undefined && isInlineAutoincrementPrimaryKeyNode(table, column);
|
|
320
|
+
return ok([new AddColumnCall(tableName, columnSpecFromNode(column, inline))]);
|
|
321
|
+
}
|
|
322
|
+
if (issue.reason === 'not-expected') {
|
|
323
|
+
const column = blindCast<
|
|
324
|
+
SqlColumnIR,
|
|
325
|
+
'a not-expected column issue always carries the actual column node'
|
|
326
|
+
>(issue.actual);
|
|
327
|
+
return ok([new DropColumnCall(tableName, column.name)]);
|
|
328
|
+
}
|
|
329
|
+
// not-equal: absorbed by recreateTableStrategy for every reachable
|
|
330
|
+
// production issue (SQLite can't ALTER a column type/nullability in
|
|
331
|
+
// place). Reaching here means the strategy didn't run — conflict.
|
|
332
|
+
const expected = blindCast<
|
|
333
|
+
SqlColumnIR,
|
|
334
|
+
'a not-equal column issue always carries the expected column node'
|
|
335
|
+
>(issue.expected);
|
|
336
|
+
const actual = blindCast<
|
|
337
|
+
SqlColumnIR,
|
|
338
|
+
'a not-equal column issue always carries the actual column node'
|
|
339
|
+
>(issue.actual);
|
|
340
|
+
const kind = columnTypeChanged(expected, actual) ? 'typeMismatch' : 'nullabilityConflict';
|
|
341
|
+
return notOk(issueConflict(kind, issue.message, issueLocation(tableName, expected.name)));
|
|
411
342
|
}
|
|
412
343
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
344
|
+
function mapIndexIssue(
|
|
345
|
+
issue: SchemaDiffIssue,
|
|
346
|
+
): Result<readonly SqliteOpFactoryCall[], SqlPlannerConflict> {
|
|
347
|
+
const tableName = issue.path[1];
|
|
348
|
+
if (tableName === undefined) {
|
|
349
|
+
return notOk(
|
|
350
|
+
issueConflict(
|
|
351
|
+
'unsupportedOperation',
|
|
352
|
+
`Index issue has no table in its path: ${issue.message}`,
|
|
353
|
+
),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
if (issue.reason === 'not-found') {
|
|
357
|
+
const index = blindCast<
|
|
358
|
+
SqlIndexIR,
|
|
359
|
+
'a not-found index issue always carries the expected index node'
|
|
360
|
+
>(issue.expected);
|
|
361
|
+
const indexName = index.name ?? defaultIndexName(tableName, index.columns);
|
|
362
|
+
return ok([new CreateIndexCall(tableName, indexName, index.columns)]);
|
|
363
|
+
}
|
|
364
|
+
if (issue.reason === 'not-expected') {
|
|
365
|
+
const index = blindCast<
|
|
366
|
+
SqlIndexIR,
|
|
367
|
+
'a not-expected index issue always carries the actual index node'
|
|
368
|
+
>(issue.actual);
|
|
369
|
+
const indexName = index.name ?? defaultIndexName(tableName, index.columns);
|
|
370
|
+
return ok([new DropIndexCall(tableName, indexName)]);
|
|
371
|
+
}
|
|
372
|
+
// not-equal: index type/options/uniqueness drift. SQLite can't ALTER an
|
|
373
|
+
// index in place and the legacy planner never absorbed this into a
|
|
374
|
+
// recreate either — surfaces as a conflict, matching `index_mismatch`.
|
|
375
|
+
return notOk(issueConflict('indexIncompatible', issue.message, issueLocation(tableName)));
|
|
419
376
|
}
|
|
420
377
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
// ============================================================================
|
|
424
|
-
|
|
425
|
-
function mapIssueToCall(
|
|
426
|
-
issue: SchemaIssue,
|
|
378
|
+
export function mapNodeIssueToCall(
|
|
379
|
+
issue: SchemaDiffIssue,
|
|
427
380
|
ctx: StrategyContext,
|
|
428
381
|
): Result<readonly SqliteOpFactoryCall[], SqlPlannerConflict> {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
);
|
|
450
|
-
|
|
451
|
-
new CreateTableCall(
|
|
452
|
-
issue.table,
|
|
453
|
-
ddlColumns,
|
|
454
|
-
ddlConstraints.length > 0 ? ddlConstraints : undefined,
|
|
455
|
-
),
|
|
456
|
-
];
|
|
457
|
-
const declaredIndexColumnKeys = new Set<string>();
|
|
458
|
-
for (const index of contractTable.indexes) {
|
|
459
|
-
const indexName = index.name ?? defaultIndexName(issue.table, index.columns);
|
|
460
|
-
declaredIndexColumnKeys.add(index.columns.join(','));
|
|
461
|
-
calls.push(new CreateIndexCall(issue.table, indexName, index.columns));
|
|
462
|
-
}
|
|
463
|
-
for (const fk of contractTable.foreignKeys) {
|
|
464
|
-
if (fk.index === false) continue;
|
|
465
|
-
if (declaredIndexColumnKeys.has(fk.source.columns.join(','))) continue;
|
|
466
|
-
const indexName = defaultIndexName(issue.table, fk.source.columns);
|
|
467
|
-
calls.push(new CreateIndexCall(issue.table, indexName, fk.source.columns));
|
|
468
|
-
}
|
|
469
|
-
return ok(calls);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
case 'missing_column': {
|
|
473
|
-
if (!issue.table || !issue.column) {
|
|
474
|
-
return notOk(
|
|
475
|
-
issueConflict('unsupportedOperation', 'Missing column issue has no table/column name'),
|
|
476
|
-
);
|
|
477
|
-
}
|
|
478
|
-
const namespaceId = resolveNamespaceIdForIssue(issue);
|
|
479
|
-
const contractTable2 = tableAt(ctx.toContract.storage, namespaceId, issue.table);
|
|
480
|
-
const column = contractTable2?.columns[issue.column];
|
|
481
|
-
if (!column) {
|
|
482
|
-
return notOk(
|
|
483
|
-
issueConflict(
|
|
484
|
-
'unsupportedOperation',
|
|
485
|
-
`Column "${issue.table}"."${issue.column}" not in destination contract`,
|
|
486
|
-
),
|
|
487
|
-
);
|
|
488
|
-
}
|
|
489
|
-
const contractTable = contractTable2;
|
|
490
|
-
const columnSpec = toColumnSpec(
|
|
491
|
-
issue.column,
|
|
492
|
-
column,
|
|
493
|
-
ctx.storageTypes,
|
|
494
|
-
contractTable ? isInlineAutoincrementPrimaryKey(contractTable, issue.column) : false,
|
|
495
|
-
);
|
|
496
|
-
return ok([new AddColumnCall(issue.table, columnSpec)]);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
case 'index_mismatch': {
|
|
500
|
-
if (!issue.table) {
|
|
501
|
-
return notOk(issueConflict('indexIncompatible', 'Index issue has no table name'));
|
|
502
|
-
}
|
|
503
|
-
if (!isMissing(issue) || !issue.expected) {
|
|
504
|
-
return notOk(
|
|
505
|
-
issueConflict(
|
|
506
|
-
'indexIncompatible',
|
|
507
|
-
`Index on "${issue.table}" differs (expected: ${issue.expected}, actual: ${issue.actual})`,
|
|
508
|
-
{ table: issue.table },
|
|
509
|
-
),
|
|
510
|
-
);
|
|
511
|
-
}
|
|
512
|
-
const namespaceId = resolveNamespaceIdForIssue(issue);
|
|
513
|
-
const columns = issue.expected.split(', ');
|
|
514
|
-
const contractTable = tableAt(ctx.toContract.storage, namespaceId, issue.table);
|
|
515
|
-
if (!contractTable) {
|
|
516
|
-
return notOk(
|
|
517
|
-
issueConflict(
|
|
518
|
-
'unsupportedOperation',
|
|
519
|
-
`Table "${issue.table}" not found in destination contract`,
|
|
520
|
-
),
|
|
521
|
-
);
|
|
522
|
-
}
|
|
523
|
-
const explicitIndex = contractTable.indexes.find(
|
|
524
|
-
(idx) => idx.columns.join(',') === columns.join(','),
|
|
525
|
-
);
|
|
526
|
-
const indexName = explicitIndex?.name ?? defaultIndexName(issue.table, columns);
|
|
527
|
-
return ok([new CreateIndexCall(issue.table, indexName, columns)]);
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
case 'extra_table': {
|
|
531
|
-
if (!issue.table) {
|
|
532
|
-
return notOk(issueConflict('unsupportedOperation', 'Extra table issue has no table name'));
|
|
533
|
-
}
|
|
534
|
-
// Runner-owned control tables must never be dropped.
|
|
535
|
-
if (CONTROL_TABLE_NAMES.has(issue.table)) return ok([]);
|
|
536
|
-
return ok([new DropTableCall(issue.table)]);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
case 'extra_column': {
|
|
540
|
-
if (!issue.table || !issue.column) {
|
|
541
|
-
return notOk(
|
|
542
|
-
issueConflict('unsupportedOperation', 'Extra column issue has no table/column name'),
|
|
543
|
-
);
|
|
544
|
-
}
|
|
545
|
-
return ok([new DropColumnCall(issue.table, issue.column)]);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
case 'extra_index': {
|
|
549
|
-
if (!issue.table || !issue.indexOrConstraint) {
|
|
550
|
-
return notOk(
|
|
551
|
-
issueConflict('unsupportedOperation', 'Extra index issue has no table/index name'),
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
return ok([new DropIndexCall(issue.table, issue.indexOrConstraint)]);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// SQLite has no enum types (capability `sql.enums: false`). The verifier
|
|
558
|
-
// should never emit `enum_values_changed` against a SQLite schema, so if
|
|
559
|
-
// we receive one it is a verifier bug — surface it as an explicit
|
|
560
|
-
// conflict rather than silently dropping it.
|
|
561
|
-
case 'enum_values_changed':
|
|
382
|
+
const node = issueNode(issue);
|
|
383
|
+
if (node === undefined) {
|
|
384
|
+
return notOk(
|
|
385
|
+
issueConflict(
|
|
386
|
+
'unsupportedOperation',
|
|
387
|
+
`Issue carries neither an expected nor an actual node: ${issue.message}`,
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
switch (node.nodeKind) {
|
|
392
|
+
case RelationalSchemaNodeKind.table:
|
|
393
|
+
return mapTableIssue(issue);
|
|
394
|
+
case RelationalSchemaNodeKind.column:
|
|
395
|
+
return mapColumnIssue(issue, ctx);
|
|
396
|
+
case RelationalSchemaNodeKind.index:
|
|
397
|
+
return mapIndexIssue(issue);
|
|
398
|
+
case RelationalSchemaNodeKind.columnDefault:
|
|
399
|
+
case RelationalSchemaNodeKind.primaryKey:
|
|
400
|
+
case RelationalSchemaNodeKind.foreignKey:
|
|
401
|
+
case RelationalSchemaNodeKind.unique:
|
|
402
|
+
return notOk(issueConflict(absorbedConflictKind(node.nodeKind), issue.message));
|
|
403
|
+
case RelationalSchemaNodeKind.check:
|
|
562
404
|
return notOk(
|
|
563
405
|
issueConflict(
|
|
564
406
|
'unsupportedOperation',
|
|
565
|
-
|
|
407
|
+
`SQLite does not support CHECK constraint DDL: ${issue.message}`,
|
|
566
408
|
),
|
|
567
409
|
);
|
|
568
|
-
|
|
569
|
-
// Everything below is absorbed by recreateTableStrategy. If it falls
|
|
570
|
-
// through here, policy or context didn't allow the recreate — surface as
|
|
571
|
-
// a conflict.
|
|
572
|
-
case 'type_mismatch':
|
|
573
|
-
case 'nullability_mismatch':
|
|
574
|
-
case 'default_mismatch':
|
|
575
|
-
case 'default_missing':
|
|
576
|
-
case 'extra_default':
|
|
577
|
-
case 'primary_key_mismatch':
|
|
578
|
-
case 'unique_constraint_mismatch':
|
|
579
|
-
case 'foreign_key_mismatch':
|
|
580
|
-
case 'extra_foreign_key':
|
|
581
|
-
case 'extra_unique_constraint':
|
|
582
|
-
case 'extra_primary_key':
|
|
583
|
-
return notOk(issueConflict(conflictKindForIssue(issue), issue.message, issueLocation(issue)));
|
|
584
|
-
|
|
585
410
|
default:
|
|
586
|
-
return notOk(
|
|
587
|
-
issueConflict(
|
|
588
|
-
'unsupportedOperation',
|
|
589
|
-
`Unhandled issue kind: ${(issue as SchemaIssue).kind}`,
|
|
590
|
-
),
|
|
591
|
-
);
|
|
411
|
+
return notOk(issueConflict('unsupportedOperation', `Unhandled node kind: ${node.nodeKind}`));
|
|
592
412
|
}
|
|
593
413
|
}
|
|
594
414
|
|
|
@@ -630,20 +450,35 @@ function classifyCall(call: SqliteOpFactoryCall): CallCategory | null {
|
|
|
630
450
|
// Top-level planIssues
|
|
631
451
|
// ============================================================================
|
|
632
452
|
|
|
453
|
+
export interface IssuePlannerOptions {
|
|
454
|
+
readonly issues: readonly SchemaDiffIssue[];
|
|
455
|
+
/** The desired ("end") tree — resolved leaf values, incl. `codecRef`. */
|
|
456
|
+
readonly expected?: SqlSchemaIR;
|
|
457
|
+
/** The live ("start") tree. */
|
|
458
|
+
readonly actual?: SqlSchemaIR;
|
|
459
|
+
readonly policy?: MigrationOperationPolicy;
|
|
460
|
+
readonly frameworkComponents?: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
|
|
461
|
+
readonly strategies?: readonly CallMigrationStrategy[];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export interface IssuePlannerValue {
|
|
465
|
+
readonly calls: readonly SqliteOpFactoryCall[];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const DEFAULT_POLICY: MigrationOperationPolicy = {
|
|
469
|
+
allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'],
|
|
470
|
+
};
|
|
471
|
+
|
|
633
472
|
export function planIssues(
|
|
634
473
|
options: IssuePlannerOptions,
|
|
635
474
|
): Result<IssuePlannerValue, readonly SqlPlannerConflict[]> {
|
|
636
475
|
const policyProvided = options.policy !== undefined;
|
|
637
476
|
const policy = options.policy ?? DEFAULT_POLICY;
|
|
638
|
-
const schema = options.schema ?? emptySchemaIR();
|
|
639
477
|
const frameworkComponents = options.frameworkComponents ?? [];
|
|
640
478
|
|
|
641
479
|
const context: StrategyContext = {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
codecHooks: options.codecHooks,
|
|
645
|
-
storageTypes: options.storageTypes,
|
|
646
|
-
schema,
|
|
480
|
+
expected: options.expected ?? emptySchemaIR(),
|
|
481
|
+
actual: options.actual ?? emptySchemaIR(),
|
|
647
482
|
policy,
|
|
648
483
|
frameworkComponents,
|
|
649
484
|
};
|
|
@@ -667,10 +502,10 @@ export function planIssues(
|
|
|
667
502
|
}
|
|
668
503
|
|
|
669
504
|
const sorted = [...remaining].sort((a, b) => {
|
|
670
|
-
const kindDelta =
|
|
505
|
+
const kindDelta = nodeIssueOrder(a) - nodeIssueOrder(b);
|
|
671
506
|
if (kindDelta !== 0) return kindDelta;
|
|
672
|
-
const keyA =
|
|
673
|
-
const keyB =
|
|
507
|
+
const keyA = nodeIssueKey(a);
|
|
508
|
+
const keyB = nodeIssueKey(b);
|
|
674
509
|
return keyA < keyB ? -1 : keyA > keyB ? 1 : 0;
|
|
675
510
|
});
|
|
676
511
|
|
|
@@ -678,7 +513,7 @@ export function planIssues(
|
|
|
678
513
|
const conflicts: SqlPlannerConflict[] = [];
|
|
679
514
|
|
|
680
515
|
for (const issue of sorted) {
|
|
681
|
-
const result =
|
|
516
|
+
const result = mapNodeIssueToCall(issue, context);
|
|
682
517
|
if (result.ok) {
|
|
683
518
|
defaultCalls.push(...result.value);
|
|
684
519
|
} else {
|
|
@@ -732,3 +567,39 @@ export function planIssues(
|
|
|
732
567
|
|
|
733
568
|
return ok({ calls });
|
|
734
569
|
}
|
|
570
|
+
|
|
571
|
+
function emptySchemaIR(): SqlSchemaIR {
|
|
572
|
+
return new SqlSchemaIR({ tables: {} });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function conflictForDisallowedCall(
|
|
576
|
+
call: SqliteOpFactoryCall,
|
|
577
|
+
allowed: readonly string[],
|
|
578
|
+
): SqlPlannerConflict {
|
|
579
|
+
const summary = `Operation "${call.label}" requires class "${call.operationClass}", but policy allows only: ${allowed.join(', ')}`;
|
|
580
|
+
const location = locationForCall(call);
|
|
581
|
+
return {
|
|
582
|
+
kind: conflictKindForCall(call),
|
|
583
|
+
summary,
|
|
584
|
+
why: 'Use `migration new` to author a custom migration for this change.',
|
|
585
|
+
...(location ? { location } : {}),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function conflictKindForCall(call: SqliteOpFactoryCall): SqlPlannerConflict['kind'] {
|
|
590
|
+
switch (call.factoryName) {
|
|
591
|
+
case 'createIndex':
|
|
592
|
+
case 'dropIndex':
|
|
593
|
+
return 'indexIncompatible';
|
|
594
|
+
default:
|
|
595
|
+
return 'missingButNonAdditive';
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function locationForCall(call: SqliteOpFactoryCall): SqlPlannerConflictLocation | undefined {
|
|
600
|
+
const location: { table?: string; column?: string; index?: string } = {};
|
|
601
|
+
if ('tableName' in call) location.table = call.tableName;
|
|
602
|
+
if ('columnName' in call) location.column = call.columnName;
|
|
603
|
+
if ('indexName' in call) location.index = call.indexName;
|
|
604
|
+
return Object.keys(location).length > 0 ? (location as SqlPlannerConflictLocation) : undefined;
|
|
605
|
+
}
|