@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.
Files changed (32) hide show
  1. package/dist/control.mjs +6 -18
  2. package/dist/control.mjs.map +1 -1
  3. package/dist/migration.mjs +2 -2
  4. package/dist/native-type-normalizer.mjs +13 -1
  5. package/dist/native-type-normalizer.mjs.map +1 -0
  6. package/dist/{op-factory-call-CMBTpb4M.mjs → op-factory-call-D_eYsHTp.mjs} +82 -65
  7. package/dist/op-factory-call-D_eYsHTp.mjs.map +1 -0
  8. package/dist/op-factory-call.mjs +1 -1
  9. package/dist/planner-DhvFrgPT.mjs +854 -0
  10. package/dist/planner-DhvFrgPT.mjs.map +1 -0
  11. package/dist/{planner-produced-sqlite-migration-BSjMKJhI.mjs → planner-produced-sqlite-migration-DyRmcteq.mjs} +2 -2
  12. package/dist/{planner-produced-sqlite-migration-BSjMKJhI.mjs.map → planner-produced-sqlite-migration-DyRmcteq.mjs.map} +1 -1
  13. package/dist/planner-produced-sqlite-migration.mjs +1 -1
  14. package/dist/planner.d.mts +46 -10
  15. package/dist/planner.d.mts.map +1 -1
  16. package/dist/planner.mjs +1 -1
  17. package/dist/{sqlite-migration-BdT_3SlM.mjs → sqlite-migration-D93wCdSD.mjs} +2 -2
  18. package/dist/{sqlite-migration-BdT_3SlM.mjs.map → sqlite-migration-D93wCdSD.mjs.map} +1 -1
  19. package/package.json +18 -18
  20. package/src/core/control-target.ts +9 -22
  21. package/src/core/migrations/column-ddl-rendering.ts +177 -0
  22. package/src/core/migrations/diff-database-schema.ts +197 -31
  23. package/src/core/migrations/issue-planner.ts +359 -488
  24. package/src/core/migrations/operations/tables.ts +107 -67
  25. package/src/core/migrations/planner-strategies.ts +118 -137
  26. package/src/core/migrations/planner.ts +112 -37
  27. package/src/core/sqlite-schema-verifier.ts +6 -3
  28. package/dist/native-type-normalizer-CiSyVmMP.mjs +0 -14
  29. package/dist/native-type-normalizer-CiSyVmMP.mjs.map +0 -1
  30. package/dist/op-factory-call-CMBTpb4M.mjs.map +0 -1
  31. package/dist/planner-EoQcJGzv.mjs +0 -676
  32. 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 `verifySqlSchema`) and emits migration IR
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 `mapIssueToCall` for the default case.
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 { SchemaIssue } from '@prisma-next/framework-components/control';
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 type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
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
- // Issue kind ordering (dependency order)
67
+ // Node-keyed issue ordering (dependency order)
75
68
  // ============================================================================
76
69
 
77
- const ISSUE_KIND_ORDER: Record<string, number> = {
78
- // Drops (reconciliation clear the way for creates)
79
- extra_foreign_key: 10,
80
- extra_unique_constraint: 11,
81
- extra_primary_key: 12,
82
- extra_index: 13,
83
- extra_default: 14,
84
- extra_column: 15,
85
- extra_table: 16,
86
-
87
- // Tables before columns
88
- missing_table: 20,
89
-
90
- // Columns before constraints
91
- missing_column: 30,
92
-
93
- // Reconciliation alters (on existing objects)
94
- type_mismatch: 40,
95
- nullability_mismatch: 41,
96
- default_missing: 42,
97
- default_mismatch: 43,
98
-
99
- // Constraints after columns exist
100
- primary_key_mismatch: 50,
101
- unique_constraint_mismatch: 51,
102
- index_mismatch: 52,
103
- foreign_key_mismatch: 60,
104
- };
105
-
106
- function issueOrder(issue: SchemaIssue): number {
107
- return ISSUE_KIND_ORDER[issue.kind] ?? 99;
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
- function issueKey(issue: SchemaIssue): string {
111
- const table = 'table' in issue && typeof issue.table === 'string' ? issue.table : '';
112
- const column = 'column' in issue && typeof issue.column === 'string' ? issue.column : '';
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
- // Conflict helpers
112
+ // Subtree coalescing — the planner's responsibility per the differ's contract
122
113
  // ============================================================================
123
114
 
124
- function issueConflict(
125
- kind: SqlPlannerConflict['kind'],
126
- summary: string,
127
- location?: SqlPlannerConflict['location'],
128
- ): SqlPlannerConflict {
129
- return {
130
- kind,
131
- summary,
132
- why: 'Use `migration new` to author a custom migration for this change.',
133
- ...(location ? { location } : {}),
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 conflictKindForIssue(issue: SchemaIssue): SqlPlannerConflict['kind'] {
138
- switch (issue.kind) {
139
- case 'type_mismatch':
140
- return 'typeMismatch';
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
- function issueLocation(issue: SchemaIssue): SqlPlannerConflictLocation | undefined {
158
- if (issue.kind === 'enum_values_changed') return undefined;
159
- const location: {
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 conflictForDisallowedCall(
171
- call: SqliteOpFactoryCall,
172
- allowed: readonly string[],
173
- ): SqlPlannerConflict {
174
- const summary = `Operation "${call.label}" requires class "${call.operationClass}", but policy allows only: ${allowed.join(', ')}`;
175
- const location = locationForCall(call);
176
- return {
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
- function conflictKindForCall(call: SqliteOpFactoryCall): SqlPlannerConflict['kind'] {
185
- switch (call.factoryName) {
186
- case 'createIndex':
187
- case 'dropIndex':
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
- * Flattens a `StorageTable` into a `SqliteTableSpec` ready for
242
- * `CreateTableCall` / `RecreateTableCall`. Sole-column AUTOINCREMENT
243
- * primary keys are detected here and marked on the column spec so the
244
- * renderer emits `INTEGER PRIMARY KEY AUTOINCREMENT` inline.
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 toTableSpec(
247
- table: StorageTable,
248
- storageTypes: Readonly<Record<string, StorageTypeInstance>>,
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.source.columns,
259
- references: { table: fk.target.tableName, columns: fk.target.columns },
260
- constraint: fk.constraint !== false,
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
- // StorageTable / StorageColumn → DdlColumn[] + DdlTableConstraint[] (for CreateTableCall)
202
+ // Conflict helpers
275
203
  // ============================================================================
276
204
 
277
- function sqliteDefaultToDdlColumnDefault(
278
- columnDefault: StorageColumn['default'],
279
- ): DdlColumn['default'] {
280
- if (!columnDefault) return undefined;
281
- switch (columnDefault.kind) {
282
- case 'literal':
283
- return new LiteralColumnDefault(columnDefault.value);
284
- case 'function':
285
- // `autoincrement()` is not a DEFAULT clause SQLite encodes it as
286
- // `INTEGER PRIMARY KEY AUTOINCREMENT` inline on the column. Skip it
287
- // here; the renderer also has a defensive guard for the same case.
288
- if (columnDefault.expression === 'autoincrement()') return undefined;
289
- return new FunctionColumnDefault(columnDefault.expression);
290
- default: {
291
- const exhaustive: never = columnDefault;
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
- * Converts a `StorageTable` to the `DdlColumn[]` + `DdlTableConstraint[]`
301
- * pair used by `CreateTableCall`. This is the structured form consumed by
302
- * the DDL lowering path; `toTableSpec` / `toColumnSpec` remain in use for
303
- * `RecreateTableCall` and `AddColumnCall` (Phase 2).
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
- export function tableToDdlParts(
306
- table: StorageTable,
307
- storageTypes: Record<string, StorageTypeInstance>,
308
- ): { columns: DdlColumn[]; constraints: DdlTableConstraint[] } {
309
- const columns: DdlColumn[] = Object.entries(table.columns).map(([name, column]) => {
310
- const inlineAutoincrement = isInlineAutoincrementPrimaryKey(table, name);
311
- const typeSql = buildColumnTypeSql(
312
- column,
313
- blindCast<
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
- for (const u of table.uniques) {
368
- constraints.push(
369
- new UniqueConstraint({
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
- for (const fk of table.foreignKeys) {
377
- if (fk.constraint === false) continue;
378
- constraints.push(
379
- new ForeignKeyConstraint({
380
- columns: fk.source.columns,
381
- refTable: fk.target.tableName,
382
- refColumns: fk.target.columns,
383
- ...ifDefined('name', fk.name),
384
- ...ifDefined('onDelete', fk.onDelete),
385
- ...ifDefined('onUpdate', fk.onUpdate),
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 planner
269
+ // Issue → Call mapping (per-issue default path)
395
270
  // ============================================================================
396
271
 
397
- export interface IssuePlannerOptions {
398
- readonly issues: readonly SchemaIssue[];
399
- readonly toContract: Contract<SqlStorage>;
400
- readonly fromContract: Contract<SqlStorage> | null;
401
- readonly codecHooks: ReadonlyMap<string, CodecControlHooks>;
402
- readonly storageTypes: Readonly<Record<string, StorageTypeInstance>>;
403
- readonly schema?: SqlSchemaIR;
404
- readonly policy?: MigrationOperationPolicy;
405
- readonly frameworkComponents?: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
406
- readonly strategies?: readonly CallMigrationStrategy[];
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
- export interface IssuePlannerValue {
410
- readonly calls: readonly SqliteOpFactoryCall[];
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
- const DEFAULT_POLICY: MigrationOperationPolicy = {
414
- allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'],
415
- };
416
-
417
- function emptySchemaIR(): SqlSchemaIR {
418
- return { tables: {} };
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
- // Issue → Call mapping (per-issue default path)
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
- switch (issue.kind) {
430
- case 'missing_table': {
431
- if (!issue.table) {
432
- return notOk(
433
- issueConflict('unsupportedOperation', 'Missing table issue has no table name'),
434
- );
435
- }
436
- const namespaceId = resolveNamespaceIdForIssue(issue);
437
- const contractTable = tableAt(ctx.toContract.storage, namespaceId, issue.table);
438
- if (!contractTable) {
439
- return notOk(
440
- issueConflict(
441
- 'unsupportedOperation',
442
- `Table "${issue.table}" in namespace "${namespaceId}" reported missing but not found in destination contract`,
443
- ),
444
- );
445
- }
446
- const { columns: ddlColumns, constraints: ddlConstraints } = tableToDdlParts(
447
- contractTable,
448
- ctx.storageTypes,
449
- );
450
- const calls: SqliteOpFactoryCall[] = [
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
- 'Received enum_values_changed against a SQLite schema (sql.enums: false) — verifier bug',
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
- toContract: options.toContract,
643
- fromContract: options.fromContract,
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 = issueOrder(a) - issueOrder(b);
505
+ const kindDelta = nodeIssueOrder(a) - nodeIssueOrder(b);
671
506
  if (kindDelta !== 0) return kindDelta;
672
- const keyA = issueKey(a);
673
- const keyB = issueKey(b);
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 = mapIssueToCall(issue, context);
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
+ }