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