@prisma-next/target-sqlite 0.14.0-dev.6 → 0.14.0-dev.60

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 (72) hide show
  1. package/dist/contract-free.mjs +1 -1
  2. package/dist/control.d.mts +3 -4
  3. package/dist/control.d.mts.map +1 -1
  4. package/dist/control.mjs +16 -27
  5. package/dist/control.mjs.map +1 -1
  6. package/dist/{descriptor-meta-DxmEeTJ-.mjs → descriptor-meta-D1fap-5t.mjs} +4 -11
  7. package/dist/descriptor-meta-D1fap-5t.mjs.map +1 -0
  8. package/dist/descriptor-meta-runtime-BkXK3OjD.mjs +12 -0
  9. package/dist/descriptor-meta-runtime-BkXK3OjD.mjs.map +1 -0
  10. package/dist/migration.d.mts +2 -2
  11. package/dist/migration.mjs +2 -2
  12. package/dist/native-type-normalizer.mjs +13 -1
  13. package/dist/native-type-normalizer.mjs.map +1 -0
  14. package/dist/{op-factory-call-DmdfD1yd.mjs → op-factory-call-Do03QgD9.mjs} +134 -117
  15. package/dist/op-factory-call-Do03QgD9.mjs.map +1 -0
  16. package/dist/op-factory-call.d.mts +2 -2
  17. package/dist/op-factory-call.d.mts.map +1 -1
  18. package/dist/op-factory-call.mjs +1 -1
  19. package/dist/pack.mjs +1 -1
  20. package/dist/planner-DGhzXuGh.mjs +848 -0
  21. package/dist/planner-DGhzXuGh.mjs.map +1 -0
  22. package/dist/{planner-produced-sqlite-migration-0xPEm3R1.mjs → planner-produced-sqlite-migration-BDzhFAoW.mjs} +46 -17
  23. package/dist/planner-produced-sqlite-migration-BDzhFAoW.mjs.map +1 -0
  24. package/dist/{planner-produced-sqlite-migration-CpgsY-M9.d.mts → planner-produced-sqlite-migration-o1qUzwLk.d.mts} +3 -3
  25. package/dist/{planner-produced-sqlite-migration-CpgsY-M9.d.mts.map → planner-produced-sqlite-migration-o1qUzwLk.d.mts.map} +1 -1
  26. package/dist/planner-produced-sqlite-migration.d.mts +1 -1
  27. package/dist/planner-produced-sqlite-migration.mjs +1 -1
  28. package/dist/planner.d.mts +47 -11
  29. package/dist/planner.d.mts.map +1 -1
  30. package/dist/planner.mjs +1 -1
  31. package/dist/runtime.d.mts +6 -4
  32. package/dist/runtime.d.mts.map +1 -1
  33. package/dist/runtime.mjs +3 -3
  34. package/dist/{shared-Dhc8mLK1.d.mts → shared-C6sM-wel.d.mts} +2 -2
  35. package/dist/{shared-Dhc8mLK1.d.mts.map → shared-C6sM-wel.d.mts.map} +1 -1
  36. package/dist/{sqlite-contract-serializer--iaDgC8e.mjs → sqlite-contract-view-BEwi0mUC.mjs} +34 -9
  37. package/dist/sqlite-contract-view-BEwi0mUC.mjs.map +1 -0
  38. package/dist/sqlite-contract-view-J32K82Gi.d.mts +29 -0
  39. package/dist/sqlite-contract-view-J32K82Gi.d.mts.map +1 -0
  40. package/dist/sqlite-migration-BNiSiP0z.mjs +123 -0
  41. package/dist/sqlite-migration-BNiSiP0z.mjs.map +1 -0
  42. package/dist/{sqlite-migration-DVfhQwN_.d.mts → sqlite-migration-BYbzskpE.d.mts} +35 -6
  43. package/dist/sqlite-migration-BYbzskpE.d.mts.map +1 -0
  44. package/package.json +18 -18
  45. package/src/core/control-target.ts +13 -18
  46. package/src/core/errors.ts +14 -12
  47. package/src/core/migrations/column-ddl-rendering.ts +177 -0
  48. package/src/core/migrations/diff-database-schema.ts +230 -0
  49. package/src/core/migrations/issue-planner.ts +361 -488
  50. package/src/core/migrations/op-factory-call.ts +5 -0
  51. package/src/core/migrations/operations/tables.ts +107 -68
  52. package/src/core/migrations/planner-strategies.ts +118 -137
  53. package/src/core/migrations/planner.ts +117 -30
  54. package/src/core/migrations/render-typescript.ts +42 -15
  55. package/src/core/migrations/runner.ts +4 -10
  56. package/src/core/migrations/sqlite-migration.ts +69 -31
  57. package/src/core/sqlite-contract-serializer.ts +29 -14
  58. package/src/core/sqlite-contract-view.ts +41 -0
  59. package/src/core/sqlite-schema-verifier.ts +6 -3
  60. package/src/core/sqlite-unbound-database.ts +8 -8
  61. package/src/exports/runtime.ts +1 -0
  62. package/dist/descriptor-meta-DxmEeTJ-.mjs.map +0 -1
  63. package/dist/native-type-normalizer-CiSyVmMP.mjs +0 -14
  64. package/dist/native-type-normalizer-CiSyVmMP.mjs.map +0 -1
  65. package/dist/op-factory-call-DmdfD1yd.mjs.map +0 -1
  66. package/dist/planner-Ciq8p_dL.mjs +0 -622
  67. package/dist/planner-Ciq8p_dL.mjs.map +0 -1
  68. package/dist/planner-produced-sqlite-migration-0xPEm3R1.mjs.map +0 -1
  69. package/dist/sqlite-contract-serializer--iaDgC8e.mjs.map +0 -1
  70. package/dist/sqlite-migration-A0rwqPOG.mjs +0 -92
  71. package/dist/sqlite-migration-A0rwqPOG.mjs.map +0 -1
  72. package/dist/sqlite-migration-DVfhQwN_.d.mts.map +0 -1
@@ -96,6 +96,11 @@ function renderDdlConstraintAsTsCall(constraint: DdlTableConstraint): string {
96
96
  const nameOpt = constraint.name ? `, { name: ${jsonToTsSource(constraint.name)} }` : '';
97
97
  return `unique(${jsonToTsSource(constraint.columns)}${nameOpt})`;
98
98
  }
99
+ case 'check-expression':
100
+ throw new Error(
101
+ `SQLite does not support expression CHECK constraints (constraint "${constraint.name}"). ` +
102
+ 'Scalar-array columns and their element-non-null checks are Postgres-only.',
103
+ );
99
104
  }
100
105
  }
101
106
 
@@ -1,6 +1,8 @@
1
1
  import type { MigrationOperationClass } from '@prisma-next/family-sql/control';
2
2
  import type { ExecuteRequestLowerer } from '@prisma-next/family-sql/control-adapter';
3
- import type { SchemaIssue } from '@prisma-next/framework-components/control';
3
+ import type { SchemaDiffIssue } from '@prisma-next/framework-components/control';
4
+ import { RelationalSchemaNodeKind, type SqlColumnIR } from '@prisma-next/sql-schema-ir/types';
5
+ import { blindCast } from '@prisma-next/utils/casts';
4
6
  import { tableExistsAst } from '../../../contract-free/checks';
5
7
  import { stripOuterParens } from '../../default-normalizer';
6
8
  import { escapeLiteral, quoteIdentifier } from '../../sql-utils';
@@ -109,7 +111,7 @@ export interface RecreateTableArgs {
109
111
  /**
110
112
  * Per-issue postcheck steps appended after the structural postchecks. The
111
113
  * planner pre-builds these via `buildRecreatePostchecks` so the call IR
112
- * carries flat, serializable data only — no `SchemaIssue` references.
114
+ * carries flat, serializable data only — no `SchemaDiffIssue` references.
113
115
  */
114
116
  readonly postchecks: readonly { readonly description: string; readonly sql: string }[];
115
117
  readonly operationClass: MigrationOperationClass;
@@ -195,33 +197,37 @@ export async function recreateTable(
195
197
  }
196
198
 
197
199
  /**
198
- * Build a one-line summary of a recreate-table operation from the schema
200
+ * Build a one-line summary of a recreate-table operation from the schema-diff
199
201
  * issues that triggered it. Lives next to `recreateTable` so the planner
200
202
  * (which has the issues) can produce the same description the factory
201
203
  * used to build inline. Keeping the formatting target-side keeps
202
- * `RecreateTableCall` issue-free at the IR layer.
204
+ * `RecreateTableCall` issue-free at the IR layer. `SchemaDiffIssue` carries
205
+ * no rendered message, so this renders one from each issue's path.
203
206
  */
204
- export function buildRecreateSummary(tableName: string, issues: readonly SchemaIssue[]): string {
205
- const messages = issues.map((i) => i.message).join('; ');
207
+ export function buildRecreateSummary(
208
+ tableName: string,
209
+ issues: readonly SchemaDiffIssue[],
210
+ ): string {
211
+ const messages = issues.map((i) => i.path.join('/')).join('; ');
206
212
  return `Recreates table ${tableName} to apply schema changes: ${messages}`;
207
213
  }
208
214
 
209
- const COLUMN_LEVEL_ISSUE_KINDS = new Set<SchemaIssue['kind']>([
210
- 'nullability_mismatch',
211
- 'default_mismatch',
212
- 'default_missing',
213
- 'extra_default',
214
- 'type_mismatch',
215
- ]);
216
-
217
- const PK_ISSUE_KINDS = new Set<SchemaIssue['kind']>(['primary_key_mismatch', 'extra_primary_key']);
218
-
219
- const UNIQUE_ISSUE_KINDS = new Set<SchemaIssue['kind']>([
220
- 'unique_constraint_mismatch',
221
- 'extra_unique_constraint',
222
- ]);
215
+ function nodeKindOf(issue: SchemaDiffIssue): string | undefined {
216
+ const node = issue.expected ?? issue.actual;
217
+ if (node === undefined) return undefined;
218
+ return blindCast<{ readonly nodeKind: string }, 'every diff-tree node declares nodeKind'>(node)
219
+ .nodeKind;
220
+ }
223
221
 
224
- const FK_ISSUE_KINDS = new Set<SchemaIssue['kind']>(['foreign_key_mismatch', 'extra_foreign_key']);
222
+ /** Mirrors `SqlColumnIR.isEqualTo`'s type comparison, isolated so a `not-equal` column issue's postcheck can target type drift and nullability drift independently. */
223
+ function columnTypeChanged(expected: SqlColumnIR, actual: SqlColumnIR): boolean {
224
+ if (expected.resolvedNativeType !== undefined && actual.resolvedNativeType !== undefined) {
225
+ return expected.resolvedNativeType !== actual.resolvedNativeType;
226
+ }
227
+ return (
228
+ expected.nativeType !== actual.nativeType || Boolean(expected.many) !== Boolean(actual.many)
229
+ );
230
+ }
225
231
 
226
232
  /**
227
233
  * Returns the columns the contract expects as the table's primary key. Picks
@@ -238,50 +244,97 @@ function quoteSqlList(values: readonly string[]): string {
238
244
  return values.map((v) => `'${escapeLiteral(v)}'`).join(', ');
239
245
  }
240
246
 
247
+ function columnNameFromNode(issue: SchemaDiffIssue): string | undefined {
248
+ const node = issue.expected ?? issue.actual;
249
+ if (node === undefined) return undefined;
250
+ return blindCast<
251
+ { readonly name: string },
252
+ 'a column or column-default issue node carries name (default nodes read the owning column name off the diff path instead)'
253
+ >(node).name;
254
+ }
255
+
256
+ /**
257
+ * A column-default issue's own node has no back-reference to its owning
258
+ * column — it's a transient child built by `SqlColumnIR.children()`. The
259
+ * column's id (`column:<name>`) is always the diff path's second-to-last
260
+ * segment for a default issue (`[..., tableId, columnId, 'default']`), so
261
+ * the name is recovered from the path rather than the node.
262
+ */
263
+ function columnNameFromDefaultIssuePath(issue: SchemaDiffIssue): string | undefined {
264
+ const columnId = issue.path[issue.path.length - 2];
265
+ if (columnId === undefined) return undefined;
266
+ const prefix = 'column:';
267
+ return columnId.startsWith(prefix) ? columnId.slice(prefix.length) : columnId;
268
+ }
269
+
241
270
  /**
242
271
  * Per-issue postchecks verifying the recreated table's shape against the
243
- * contract spec. Column-level issues (`nullability_mismatch`,
244
- * `default_mismatch`, ) emit one targeted check each; constraint-level
245
- * issues (`primary_key_mismatch`, `unique_constraint_mismatch`,
246
- * `foreign_key_mismatch`, plus their `extra_*` siblings) emit one
247
- * `pragma_*`-driven check per declared constraint in the contract spec, so
248
- * a recreated table with the right columns but the wrong PK / unique / FK
249
- * shape fails the postcheck instead of passing silently. Exported so the
250
- * planner can pre-build the list at construction time and
251
- * `RecreateTableCall` doesn't have to carry `SchemaIssue` objects through
272
+ * expected spec. Column-level issues (`sql-column` `not-equal`,
273
+ * `sql-column-default` any reason) emit one targeted check each;
274
+ * constraint-level issues (`sql-primary-key`, `sql-unique`, `sql-foreign-key`,
275
+ * any reason) emit one `pragma_*`-driven check per declared constraint in the
276
+ * expected spec, so a recreated table with the right columns but the wrong
277
+ * PK / unique / FK shape fails the postcheck instead of passing silently.
278
+ * Exported so the planner can pre-build the list at construction time and
279
+ * `RecreateTableCall` doesn't have to carry `SchemaDiffIssue` objects through
252
280
  * to render time.
253
281
  */
254
282
  export function buildRecreatePostchecks(
255
283
  tableName: string,
256
- issues: readonly SchemaIssue[],
284
+ issues: readonly SchemaDiffIssue[],
257
285
  spec: SqliteTableSpec,
258
286
  ): Array<{ description: string; sql: string }> {
259
287
  const checks: Array<{ description: string; sql: string }> = [];
260
288
  const t = escapeLiteral(tableName);
261
289
  const byName = new Map(spec.columns.map((c) => [c.name, c]));
262
290
 
291
+ let hasPkIssue = false;
292
+ let hasUniqueIssue = false;
293
+ let hasFkIssue = false;
294
+
263
295
  for (const issue of issues) {
264
- if (issue.kind === 'enum_values_changed') continue;
265
- if (!COLUMN_LEVEL_ISSUE_KINDS.has(issue.kind)) continue;
266
- if (!issue.column) continue;
267
- const c = escapeLiteral(issue.column);
268
- if (issue.kind === 'nullability_mismatch') {
269
- // `expected` carries the contract's nullable flag as a string. We only
270
- // emit a postcheck when the value is recognized — anything else
271
- // (case-folded, numeric coding, etc.) is left to the structural
272
- // verifier so a typo here can't silently invert the meaning.
273
- let wantNotNull: boolean | undefined;
274
- if (issue.expected === 'false') wantNotNull = true;
275
- else if (issue.expected === 'true') wantNotNull = false;
276
- if (wantNotNull !== undefined) {
296
+ const nodeKind = nodeKindOf(issue);
297
+ if (nodeKind === RelationalSchemaNodeKind.column && issue.reason === 'not-equal') {
298
+ const columnName = columnNameFromNode(issue);
299
+ if (columnName === undefined) continue;
300
+ const c = escapeLiteral(columnName);
301
+ const expected = blindCast<SqlColumnIR, 'a not-equal column issue carries the expected node'>(
302
+ issue.expected,
303
+ );
304
+ const actual = blindCast<SqlColumnIR, 'a not-equal column issue carries the actual node'>(
305
+ issue.actual,
306
+ );
307
+ if (expected.nullable !== actual.nullable) {
277
308
  checks.push({
278
- description: `verify "${issue.column}" nullability on "${tableName}"`,
279
- sql: `SELECT COUNT(*) > 0 FROM pragma_table_info('${t}') WHERE name = '${c}' AND "notnull" = ${wantNotNull ? 1 : 0}`,
309
+ description: `verify "${columnName}" nullability on "${tableName}"`,
310
+ sql: `SELECT COUNT(*) > 0 FROM pragma_table_info('${t}') WHERE name = '${c}' AND "notnull" = ${expected.nullable ? 0 : 1}`,
280
311
  });
281
312
  }
313
+ if (columnTypeChanged(expected, actual)) {
314
+ const colSpec = byName.get(columnName);
315
+ if (colSpec) {
316
+ checks.push({
317
+ description: `verify "${columnName}" type on "${tableName}"`,
318
+ sql: `SELECT COUNT(*) > 0 FROM pragma_table_info('${t}') WHERE name = '${c}' AND LOWER(type) = '${escapeLiteral(colSpec.typeSql.toLowerCase())}'`,
319
+ });
320
+ }
321
+ }
322
+ continue;
282
323
  }
283
- if (issue.kind === 'default_mismatch' || issue.kind === 'default_missing') {
284
- const colSpec = byName.get(issue.column);
324
+ if (nodeKind === RelationalSchemaNodeKind.columnDefault) {
325
+ const columnName = columnNameFromDefaultIssuePath(issue);
326
+ if (columnName === undefined) continue;
327
+ const c = escapeLiteral(columnName);
328
+ if (issue.reason === 'not-expected') {
329
+ checks.push({
330
+ description: `verify "${columnName}" has no default on "${tableName}"`,
331
+ sql: `SELECT COUNT(*) > 0 FROM pragma_table_info('${t}') WHERE name = '${c}' AND dflt_value IS NULL`,
332
+ });
333
+ continue;
334
+ }
335
+ // not-found (missing) or not-equal (drift) — both want the expected
336
+ // default SQL present on the live column.
337
+ const colSpec = byName.get(columnName);
285
338
  const expectedRaw = colSpec?.defaultSql.startsWith('DEFAULT ')
286
339
  ? // SQLite's pragma_table_info.dflt_value strips outer parens for
287
340
  // expression defaults (per the SQLite docs), so `(datetime('now'))`
@@ -291,34 +344,20 @@ export function buildRecreatePostchecks(
291
344
  : null;
292
345
  if (expectedRaw) {
293
346
  checks.push({
294
- description: `verify "${issue.column}" default on "${tableName}"`,
347
+ description: `verify "${columnName}" default on "${tableName}"`,
295
348
  sql: `SELECT COUNT(*) > 0 FROM pragma_table_info('${t}') WHERE name = '${c}' AND dflt_value = '${escapeLiteral(expectedRaw)}'`,
296
349
  });
297
350
  }
351
+ continue;
298
352
  }
299
- if (issue.kind === 'type_mismatch') {
300
- const colSpec = byName.get(issue.column);
301
- if (colSpec) {
302
- checks.push({
303
- description: `verify "${issue.column}" type on "${tableName}"`,
304
- sql: `SELECT COUNT(*) > 0 FROM pragma_table_info('${t}') WHERE name = '${c}' AND LOWER(type) = '${escapeLiteral(colSpec.typeSql.toLowerCase())}'`,
305
- });
306
- }
307
- }
308
- if (issue.kind === 'extra_default') {
309
- checks.push({
310
- description: `verify "${issue.column}" has no default on "${tableName}"`,
311
- sql: `SELECT COUNT(*) > 0 FROM pragma_table_info('${t}') WHERE name = '${c}' AND dflt_value IS NULL`,
312
- });
313
- }
353
+ if (nodeKind === RelationalSchemaNodeKind.primaryKey) hasPkIssue = true;
354
+ if (nodeKind === RelationalSchemaNodeKind.unique) hasUniqueIssue = true;
355
+ if (nodeKind === RelationalSchemaNodeKind.foreignKey) hasFkIssue = true;
314
356
  }
315
357
 
316
358
  // Constraint-level issues — emit one postcheck per declared constraint in
317
- // the contract spec when *any* issue of that kind fires, since recreate
359
+ // the expected spec when *any* issue of that kind fires, since recreate
318
360
  // rebuilds the entire table at once.
319
- const hasPkIssue = issues.some((i) => PK_ISSUE_KINDS.has(i.kind));
320
- const hasUniqueIssue = issues.some((i) => UNIQUE_ISSUE_KINDS.has(i.kind));
321
- const hasFkIssue = issues.some((i) => FK_ISSUE_KINDS.has(i.kind));
322
361
 
323
362
  if (hasPkIssue) {
324
363
  const pkColumns = expectedPrimaryKeyColumns(spec);
@@ -1,88 +1,52 @@
1
1
  /**
2
2
  * SQLite migration strategies.
3
3
  *
4
- * Each strategy examines the issue list, consumes issues it handles, and
5
- * returns the `SqliteOpFactoryCall[]` to address them. The issue planner
6
- * runs each strategy in order and routes whatever's left through
7
- * `mapIssueToCall`.
4
+ * Each strategy examines the node-typed issue list, consumes issues it
5
+ * handles, and returns the `SqliteOpFactoryCall[]` to address them. The issue
6
+ * planner runs each strategy in order and routes whatever's left through
7
+ * `mapNodeIssueToCall`.
8
8
  *
9
9
  * SQLite has no enums, no data-safe backfill, and no component-declared
10
10
  * database dependencies. The only recipe that needs strategy-level
11
- * multi-issue consumption is `recreateTable` (added in a later phase), which
12
- * absorbs type/nullability/default/constraint mismatches for a given table
13
- * into a single recreate operation.
11
+ * multi-issue consumption is `recreateTable`, which absorbs
12
+ * type/nullability/default/constraint mismatches for a given table into a
13
+ * single recreate operation.
14
14
  */
15
15
 
16
- import type { Contract } from '@prisma-next/contract/types';
17
16
  import type {
18
- CodecControlHooks,
19
17
  MigrationOperationClass,
20
18
  MigrationOperationPolicy,
21
19
  } from '@prisma-next/family-sql/control';
22
20
  import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
23
- import type { SchemaIssue } from '@prisma-next/framework-components/control';
24
- import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
25
- import type {
26
- SqlStorage,
27
- StorageTable,
28
- StorageTypeInstance,
29
- } from '@prisma-next/sql-contract/types';
21
+ import type { SchemaDiffIssue } from '@prisma-next/framework-components/control';
30
22
  import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming';
31
- import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
32
- import { toTableSpec } from './issue-planner';
23
+ import {
24
+ RelationalSchemaNodeKind,
25
+ type SqlColumnIR,
26
+ type SqlSchemaIR,
27
+ } from '@prisma-next/sql-schema-ir/types';
28
+ import { blindCast } from '@prisma-next/utils/casts';
29
+ import { columnTypeChanged, tableSpecFromNode } from './issue-planner';
33
30
  import { DataTransformCall, RecreateTableCall, type SqliteOpFactoryCall } from './op-factory-call';
34
31
  import type { SqliteIndexSpec } from './operations/shared';
35
32
  import { buildRecreatePostchecks, buildRecreateSummary } from './operations/tables';
36
33
 
37
34
  export interface StrategyContext {
38
- readonly toContract: Contract<SqlStorage>;
39
- readonly fromContract: Contract<SqlStorage> | null;
40
- readonly codecHooks: ReadonlyMap<string, CodecControlHooks>;
41
- readonly storageTypes: Readonly<Record<string, StorageTypeInstance>>;
42
- readonly schema: SqlSchemaIR;
35
+ /** The desired ("end") tree — resolved leaf values, incl. `codecRef`. */
36
+ readonly expected: SqlSchemaIR;
37
+ /** The live ("start") tree. */
38
+ readonly actual: SqlSchemaIR;
43
39
  readonly policy: MigrationOperationPolicy;
44
40
  readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
45
41
  }
46
42
 
47
- /**
48
- * Look up a storage table by its explicit namespace coordinate. Returns
49
- * `undefined` when the namespace has no table by that name (or no such
50
- * namespace exists). Callers that get `undefined` MUST treat it as an
51
- * explicit conflict rather than silently falling back to a name-only
52
- * walk across namespaces — the SQLite target currently has a single
53
- * namespace, but this helper enforces the explicit-coordinate
54
- * discipline so a future multi-namespace SQLite shape inherits the
55
- * conflict-on-stale-coordinate behaviour the Postgres planner already
56
- * has.
57
- */
58
- export function tableAt(
59
- storage: SqlStorage,
60
- namespaceId: string,
61
- tableName: string,
62
- ): StorageTable | undefined {
63
- const ns = storage.namespaces[namespaceId];
64
- return ns !== undefined ? ns.entries.table?.[tableName] : undefined;
65
- }
66
-
67
- /**
68
- * Default namespace coordinate for an issue that does not carry one
69
- * explicitly. Hand-crafted unit-test issues fall back to `__unbound__`,
70
- * the only namespace any single-namespace contract carries —
71
- * verifier-emitted issues for legacy single-namespace contracts already
72
- * stamp this id explicitly. Typed structurally so issue variants
73
- * without a `namespaceId` slot flow through to the same fallback.
74
- */
75
- export function resolveNamespaceIdForIssue(issue: { readonly namespaceId?: string }): string {
76
- return issue.namespaceId ?? UNBOUND_NAMESPACE_ID;
77
- }
78
-
79
43
  export type CallMigrationStrategy = (
80
- issues: readonly SchemaIssue[],
44
+ issues: readonly SchemaDiffIssue[],
81
45
  context: StrategyContext,
82
46
  ) =>
83
47
  | {
84
48
  kind: 'match';
85
- issues: readonly SchemaIssue[];
49
+ issues: readonly SchemaDiffIssue[];
86
50
  calls: readonly SqliteOpFactoryCall[];
87
51
  recipe?: boolean;
88
52
  }
@@ -92,60 +56,77 @@ export type CallMigrationStrategy = (
92
56
  // Recreate-table strategy
93
57
  // ============================================================================
94
58
 
95
- const WIDENING_ISSUE_KINDS = new Set<SchemaIssue['kind']>(['default_mismatch', 'default_missing']);
96
-
97
- const DESTRUCTIVE_ISSUE_KINDS = new Set<SchemaIssue['kind']>([
98
- 'extra_default',
99
- 'type_mismatch',
100
- 'primary_key_mismatch',
101
- 'foreign_key_mismatch',
102
- 'unique_constraint_mismatch',
103
- 'extra_foreign_key',
104
- 'extra_unique_constraint',
105
- 'extra_primary_key',
106
- ]);
107
-
108
- function classifyIssue(issue: SchemaIssue): 'widening' | 'destructive' | null {
109
- if (issue.kind === 'enum_values_changed') return null;
110
- if (!issue.table) return null;
111
- if (issue.kind === 'nullability_mismatch') {
112
- // Relaxing (NOT NULL → nullable) is widening; tightening is destructive.
113
- return issue.expected === 'true' ? 'widening' : 'destructive';
59
+ /**
60
+ * Classifies a node issue into the operation class a recreate absorbing it
61
+ * would need, or `null` when the strategy doesn't handle this node/reason at
62
+ * all (table/column not-found-or-not-expected, and index issues — those are
63
+ * standalone ops, never folded into a recreate).
64
+ *
65
+ * Column drift is a single `not-equal` issue now (type AND nullability
66
+ * compared together by `SqlColumnIR.isEqualTo`), so this reads both fields
67
+ * off the node pair directly rather than trusting a separate issue kind per
68
+ * attribute: a type change is always destructive; a pure nullability change
69
+ * is destructive when tightening (NOT NULL required) and widening when
70
+ * relaxing.
71
+ */
72
+ function classifyNodeIssue(issue: SchemaDiffIssue): 'widening' | 'destructive' | null {
73
+ const node = issue.expected ?? issue.actual;
74
+ if (node === undefined) return null;
75
+ const nodeKind = blindCast<
76
+ { readonly nodeKind: string },
77
+ 'every diff-tree node declares nodeKind'
78
+ >(node).nodeKind;
79
+ switch (nodeKind) {
80
+ case RelationalSchemaNodeKind.column: {
81
+ if (issue.reason !== 'not-equal') return null;
82
+ const expected = blindCast<SqlColumnIR, 'a not-equal column issue carries the expected node'>(
83
+ issue.expected,
84
+ );
85
+ const actual = blindCast<SqlColumnIR, 'a not-equal column issue carries the actual node'>(
86
+ issue.actual,
87
+ );
88
+ if (columnTypeChanged(expected, actual)) return 'destructive';
89
+ // Type is unchanged, so `not-equal` here means only nullability
90
+ // differs: relaxing (NOT NULL → nullable) is safe; tightening is not.
91
+ return expected.nullable ? 'widening' : 'destructive';
92
+ }
93
+ case RelationalSchemaNodeKind.columnDefault:
94
+ return issue.reason === 'not-expected' ? 'destructive' : 'widening';
95
+ case RelationalSchemaNodeKind.primaryKey:
96
+ case RelationalSchemaNodeKind.foreignKey:
97
+ case RelationalSchemaNodeKind.unique:
98
+ return 'destructive';
99
+ default:
100
+ return null;
114
101
  }
115
- if (WIDENING_ISSUE_KINDS.has(issue.kind)) return 'widening';
116
- if (DESTRUCTIVE_ISSUE_KINDS.has(issue.kind)) return 'destructive';
117
- return null;
118
102
  }
119
103
 
120
104
  /**
121
105
  * Groups recreate-eligible issues by table, decides per-table operation class
122
106
  * (destructive wins over widening), and emits one `RecreateTableCall` per
123
107
  * table. Returns unchanged-or-smaller issue list — issues the strategy
124
- * consumed are removed so `mapIssueToCall` doesn't double-handle them.
108
+ * consumed are removed so `mapNodeIssueToCall` doesn't double-handle them.
109
+ *
110
+ * The full desired/live table shapes come from `ctx.expected`/`ctx.actual`
111
+ * directly (keyed by table name — SQLite is a flat, single-namespace target)
112
+ * rather than from any individual issue, since a single drifted attribute's
113
+ * issue only carries that attribute's own node, never the whole table.
125
114
  */
126
115
  export const recreateTableStrategy: CallMigrationStrategy = (issues, ctx) => {
127
- const byTable = new Map<
128
- string,
129
- { issues: SchemaIssue[]; hasDestructive: boolean; namespaceId: string }
130
- >();
131
- const consumed = new Set<SchemaIssue>();
116
+ const byTable = new Map<string, { issues: SchemaDiffIssue[]; hasDestructive: boolean }>();
117
+ const consumed = new Set<SchemaDiffIssue>();
132
118
 
133
119
  for (const issue of issues) {
134
- const cls = classifyIssue(issue);
120
+ const cls = classifyNodeIssue(issue);
135
121
  if (!cls) continue;
136
- if (issue.kind === 'enum_values_changed') continue;
137
- if (!issue.table) continue;
138
- const table = issue.table;
139
- const entry = byTable.get(table);
122
+ const tableName = issue.path[1];
123
+ if (tableName === undefined) continue;
124
+ const entry = byTable.get(tableName);
140
125
  if (entry) {
141
126
  entry.issues.push(issue);
142
127
  if (cls === 'destructive') entry.hasDestructive = true;
143
128
  } else {
144
- byTable.set(table, {
145
- issues: [issue],
146
- hasDestructive: cls === 'destructive',
147
- namespaceId: resolveNamespaceIdForIssue(issue),
148
- });
129
+ byTable.set(tableName, { issues: [issue], hasDestructive: cls === 'destructive' });
149
130
  }
150
131
  consumed.add(issue);
151
132
  }
@@ -154,44 +135,29 @@ export const recreateTableStrategy: CallMigrationStrategy = (issues, ctx) => {
154
135
 
155
136
  const calls: SqliteOpFactoryCall[] = [];
156
137
  for (const [tableName, entry] of byTable) {
157
- const contractTable = tableAt(ctx.toContract.storage, entry.namespaceId, tableName);
158
- const schemaTable = ctx.schema.tables[tableName];
159
- if (!contractTable || !schemaTable) continue;
138
+ const expectedTable = ctx.expected.tables[tableName];
139
+ const actualTable = ctx.actual.tables[tableName];
140
+ if (!expectedTable || !actualTable) continue;
160
141
  const operationClass: MigrationOperationClass = entry.hasDestructive
161
142
  ? 'destructive'
162
143
  : 'widening';
163
144
 
164
- // Flatten the contract table to a self-contained spec — the Call holds
165
- // pre-rendered SQL fragments only, no `StorageColumn` or `storageTypes`.
166
- const tableSpec = toTableSpec(contractTable, ctx.storageTypes);
145
+ // Flatten the expected table node to a self-contained spec — the Call
146
+ // holds pre-rendered SQL fragments only, no schema-IR node.
147
+ const tableSpec = tableSpecFromNode(expectedTable);
167
148
 
168
- const seenIndexColumnKeys = new Set<string>();
169
- const indexes: SqliteIndexSpec[] = [];
170
- for (const idx of contractTable.indexes) {
171
- const key = idx.columns.join(',');
172
- if (seenIndexColumnKeys.has(key)) continue;
173
- seenIndexColumnKeys.add(key);
174
- indexes.push({
175
- name: idx.name ?? defaultIndexName(tableName, idx.columns),
176
- columns: idx.columns,
177
- });
178
- }
179
- for (const fk of contractTable.foreignKeys) {
180
- if (fk.index === false) continue;
181
- const key = fk.source.columns.join(',');
182
- if (seenIndexColumnKeys.has(key)) continue;
183
- seenIndexColumnKeys.add(key);
184
- indexes.push({
185
- name: defaultIndexName(tableName, fk.source.columns),
186
- columns: fk.source.columns,
187
- });
188
- }
149
+ // Indexes (declared + FK-backing) are already merged and deduped by
150
+ // column-set at derivation (`contractToSchemaIR`'s `convertTable`).
151
+ const indexes: SqliteIndexSpec[] = expectedTable.indexes.map((idx) => ({
152
+ name: idx.name ?? defaultIndexName(tableName, idx.columns),
153
+ columns: idx.columns,
154
+ }));
189
155
 
190
156
  calls.push(
191
157
  new RecreateTableCall({
192
158
  tableName,
193
159
  contractTable: tableSpec,
194
- schemaColumnNames: Object.keys(schemaTable.columns),
160
+ schemaColumnNames: Object.keys(actualTable.columns),
195
161
  indexes,
196
162
  summary: buildRecreateSummary(tableName, entry.issues),
197
163
  postchecks: buildRecreatePostchecks(tableName, entry.issues, tableSpec),
@@ -213,7 +179,7 @@ export const recreateTableStrategy: CallMigrationStrategy = (issues, ctx) => {
213
179
  // ============================================================================
214
180
 
215
181
  /**
216
- * When the policy allows `'data'` and the contract tightens one or more
182
+ * When the policy allows `'data'` and the expected tree tightens one or more
217
183
  * columns from nullable to NOT NULL, emit a `DataTransformCall` stub per
218
184
  * tightened column. The user fills the backfill `UPDATE` in the rendered
219
185
  * `migration.ts` before the subsequent `RecreateTableCall` copies data into
@@ -239,23 +205,38 @@ export const nullabilityTighteningBackfillStrategy: CallMigrationStrategy = (iss
239
205
 
240
206
  const calls: SqliteOpFactoryCall[] = [];
241
207
  for (const issue of issues) {
242
- if (issue.kind !== 'nullability_mismatch') continue;
243
- if (!issue.table || !issue.column) continue;
244
- // Tightening only: `expected === 'true'` means the contract wants the
245
- // column nullable (relaxing from NOT NULL → nullable), which is safe and
246
- // needs no backfill.
247
- if (issue.expected === 'true') continue;
208
+ if (
209
+ issue.reason !== 'not-equal' ||
210
+ issue.expected === undefined ||
211
+ issue.actual === undefined
212
+ ) {
213
+ continue;
214
+ }
215
+ const expected = blindCast<
216
+ { readonly nodeKind: string },
217
+ 'every diff-tree node declares nodeKind'
218
+ >(issue.expected).nodeKind;
219
+ if (expected !== RelationalSchemaNodeKind.column) continue;
248
220
 
249
- const namespaceId = resolveNamespaceIdForIssue(issue);
250
- const column = tableAt(ctx.toContract.storage, namespaceId, issue.table)?.columns[issue.column];
251
- if (!column || column.nullable === true) continue;
221
+ const expectedColumn = blindCast<
222
+ SqlColumnIR,
223
+ 'a not-equal column issue carries the expected node'
224
+ >(issue.expected);
225
+ const actualColumn = blindCast<SqlColumnIR, 'a not-equal column issue carries the actual node'>(
226
+ issue.actual,
227
+ );
228
+ if (expectedColumn.nullable === actualColumn.nullable) continue; // not a nullability change
229
+ if (expectedColumn.nullable) continue; // relaxing — no backfill needed
230
+
231
+ const tableName = issue.path[1];
232
+ if (tableName === undefined) continue;
252
233
 
253
234
  calls.push(
254
235
  new DataTransformCall(
255
- `data_migration.backfill-${issue.table}-${issue.column}`,
256
- `Backfill NULLs in "${issue.table}"."${issue.column}" before NOT NULL tightening`,
257
- issue.table,
258
- issue.column,
236
+ `data_migration.backfill-${tableName}-${expectedColumn.name}`,
237
+ `Backfill NULLs in "${tableName}"."${expectedColumn.name}" before NOT NULL tightening`,
238
+ tableName,
239
+ expectedColumn.name,
259
240
  ),
260
241
  );
261
242
  }