@prisma-next/target-sqlite 0.5.0-dev.9 → 0.5.0

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 (134) hide show
  1. package/dist/codec-ids-CYwMu3-4.d.mts +13 -0
  2. package/dist/codec-ids-CYwMu3-4.d.mts.map +1 -0
  3. package/dist/codec-ids-CuUxYcd0.mjs +13 -0
  4. package/dist/codec-ids-CuUxYcd0.mjs.map +1 -0
  5. package/dist/codec-ids.d.mts +2 -0
  6. package/dist/codec-ids.mjs +2 -0
  7. package/dist/codec-types-DNauB5UT.d.mts +23 -0
  8. package/dist/codec-types-DNauB5UT.d.mts.map +1 -0
  9. package/dist/codec-types.d.mts +3 -0
  10. package/dist/codec-types.mjs +2 -0
  11. package/dist/codecs-BAlEiSeP.d.mts +126 -0
  12. package/dist/codecs-BAlEiSeP.d.mts.map +1 -0
  13. package/dist/codecs-DVnHtVWW.mjs +220 -0
  14. package/dist/codecs-DVnHtVWW.mjs.map +1 -0
  15. package/dist/codecs.d.mts +2 -0
  16. package/dist/codecs.mjs +13 -0
  17. package/dist/codecs.mjs.map +1 -0
  18. package/dist/control.d.mts +4 -3
  19. package/dist/control.d.mts.map +1 -1
  20. package/dist/control.mjs +428 -5
  21. package/dist/control.mjs.map +1 -1
  22. package/dist/default-normalizer-3Fccw7yw.mjs +69 -0
  23. package/dist/default-normalizer-3Fccw7yw.mjs.map +1 -0
  24. package/dist/default-normalizer.d.mts +7 -0
  25. package/dist/default-normalizer.d.mts.map +1 -0
  26. package/dist/default-normalizer.mjs +2 -0
  27. package/dist/descriptor-meta-CE2Kbn9b.mjs +17 -0
  28. package/dist/descriptor-meta-CE2Kbn9b.mjs.map +1 -0
  29. package/dist/migration.d.mts +85 -0
  30. package/dist/migration.d.mts.map +1 -0
  31. package/dist/migration.mjs +49 -0
  32. package/dist/migration.mjs.map +1 -0
  33. package/dist/native-type-normalizer-BlN5XfD-.mjs +14 -0
  34. package/dist/native-type-normalizer-BlN5XfD-.mjs.map +1 -0
  35. package/dist/native-type-normalizer.d.mts +11 -0
  36. package/dist/native-type-normalizer.d.mts.map +1 -0
  37. package/dist/native-type-normalizer.mjs +2 -0
  38. package/dist/op-factory-call-DRKKURAO.mjs +279 -0
  39. package/dist/op-factory-call-DRKKURAO.mjs.map +1 -0
  40. package/dist/op-factory-call.d.mts +151 -0
  41. package/dist/op-factory-call.d.mts.map +1 -0
  42. package/dist/op-factory-call.mjs +2 -0
  43. package/dist/pack.d.mts +35 -1
  44. package/dist/pack.d.mts.map +1 -1
  45. package/dist/pack.mjs +2 -3
  46. package/dist/planner-CdCU0v1B.mjs +525 -0
  47. package/dist/planner-CdCU0v1B.mjs.map +1 -0
  48. package/dist/planner-produced-sqlite-migration-CI9LdXPr.d.mts +29 -0
  49. package/dist/planner-produced-sqlite-migration-CI9LdXPr.d.mts.map +1 -0
  50. package/dist/planner-produced-sqlite-migration-C_TzWbT0.mjs +110 -0
  51. package/dist/planner-produced-sqlite-migration-C_TzWbT0.mjs.map +1 -0
  52. package/dist/planner-produced-sqlite-migration.d.mts +2 -0
  53. package/dist/planner-produced-sqlite-migration.mjs +2 -0
  54. package/dist/planner-target-details-Bm71XPKb.mjs +15 -0
  55. package/dist/planner-target-details-Bm71XPKb.mjs.map +1 -0
  56. package/dist/planner-target-details-vhvZDWK1.d.mts +12 -0
  57. package/dist/planner-target-details-vhvZDWK1.d.mts.map +1 -0
  58. package/dist/planner-target-details.d.mts +2 -0
  59. package/dist/planner-target-details.mjs +2 -0
  60. package/dist/planner.d.mts +59 -0
  61. package/dist/planner.d.mts.map +1 -0
  62. package/dist/planner.mjs +2 -0
  63. package/dist/render-ops-CSRDT4YL.mjs +8 -0
  64. package/dist/render-ops-CSRDT4YL.mjs.map +1 -0
  65. package/dist/render-ops.d.mts +10 -0
  66. package/dist/render-ops.d.mts.map +1 -0
  67. package/dist/render-ops.mjs +2 -0
  68. package/dist/runtime.d.mts.map +1 -1
  69. package/dist/runtime.mjs +4 -8
  70. package/dist/runtime.mjs.map +1 -1
  71. package/dist/shared-qLsgTOZs.d.mts +69 -0
  72. package/dist/shared-qLsgTOZs.d.mts.map +1 -0
  73. package/dist/sql-utils-DhevMgef.mjs +35 -0
  74. package/dist/sql-utils-DhevMgef.mjs.map +1 -0
  75. package/dist/sql-utils.d.mts +22 -0
  76. package/dist/sql-utils.d.mts.map +1 -0
  77. package/dist/sql-utils.mjs +2 -0
  78. package/dist/sqlite-migration-BBJktVVw.mjs +16 -0
  79. package/dist/sqlite-migration-BBJktVVw.mjs.map +1 -0
  80. package/dist/sqlite-migration-DAb2NEX6.d.mts +17 -0
  81. package/dist/sqlite-migration-DAb2NEX6.d.mts.map +1 -0
  82. package/dist/statement-builders-Dne-LkAV.mjs +158 -0
  83. package/dist/statement-builders-Dne-LkAV.mjs.map +1 -0
  84. package/dist/statement-builders.d.mts +68 -0
  85. package/dist/statement-builders.d.mts.map +1 -0
  86. package/dist/statement-builders.mjs +2 -0
  87. package/dist/tables-D84zfPZI.mjs +403 -0
  88. package/dist/tables-D84zfPZI.mjs.map +1 -0
  89. package/package.json +31 -9
  90. package/src/core/authoring.ts +9 -0
  91. package/src/core/codec-helpers.ts +11 -0
  92. package/src/core/codec-ids.ts +13 -0
  93. package/src/core/codecs.ts +337 -0
  94. package/src/core/control-target.ts +54 -11
  95. package/src/core/default-normalizer.ts +92 -0
  96. package/src/core/descriptor-meta.ts +5 -1
  97. package/src/core/migrations/issue-planner.ts +586 -0
  98. package/src/core/migrations/op-factory-call.ts +369 -0
  99. package/src/core/migrations/operations/columns.ts +62 -0
  100. package/src/core/migrations/operations/data-transform.ts +51 -0
  101. package/src/core/migrations/operations/indexes.ts +52 -0
  102. package/src/core/migrations/operations/raw.ts +12 -0
  103. package/src/core/migrations/operations/shared.ts +120 -0
  104. package/src/core/migrations/operations/tables.ts +388 -0
  105. package/src/core/migrations/planner-ddl-builders.ts +142 -0
  106. package/src/core/migrations/planner-produced-sqlite-migration.ts +70 -0
  107. package/src/core/migrations/planner-strategies.ts +231 -0
  108. package/src/core/migrations/planner-target-details.ts +33 -0
  109. package/src/core/migrations/planner.ts +183 -0
  110. package/src/core/migrations/render-ops.ts +15 -0
  111. package/src/core/migrations/render-typescript.ts +91 -0
  112. package/src/core/migrations/runner.ts +724 -0
  113. package/src/core/migrations/sqlite-migration.ts +13 -0
  114. package/src/core/migrations/statement-builders.ts +212 -0
  115. package/src/core/native-type-normalizer.ts +9 -0
  116. package/src/core/registry.ts +11 -0
  117. package/src/core/runtime-target.ts +1 -3
  118. package/src/core/sql-utils.ts +47 -0
  119. package/src/exports/codec-ids.ts +13 -0
  120. package/src/exports/codec-types.ts +43 -0
  121. package/src/exports/codecs.ts +20 -0
  122. package/src/exports/control.ts +1 -0
  123. package/src/exports/default-normalizer.ts +1 -0
  124. package/src/exports/migration.ts +24 -0
  125. package/src/exports/native-type-normalizer.ts +1 -0
  126. package/src/exports/op-factory-call.ts +12 -0
  127. package/src/exports/planner-produced-sqlite-migration.ts +4 -0
  128. package/src/exports/planner-target-details.ts +2 -0
  129. package/src/exports/planner.ts +2 -0
  130. package/src/exports/render-ops.ts +1 -0
  131. package/src/exports/sql-utils.ts +1 -0
  132. package/src/exports/statement-builders.ts +12 -0
  133. package/dist/descriptor-meta-DbuuziYA.mjs +0 -14
  134. package/dist/descriptor-meta-DbuuziYA.mjs.map +0 -1
@@ -0,0 +1,388 @@
1
+ import type { MigrationOperationClass } from '@prisma-next/family-sql/control';
2
+ import type { SchemaIssue } from '@prisma-next/framework-components/control';
3
+ import { stripOuterParens } from '../../default-normalizer';
4
+ import { escapeLiteral, quoteIdentifier } from '../../sql-utils';
5
+ import { buildCreateIndexSql } from '../planner-ddl-builders';
6
+ import { buildTargetDetails } from '../planner-target-details';
7
+ import {
8
+ type Op,
9
+ renderColumnDefinition,
10
+ renderForeignKeyClause,
11
+ type SqliteIndexSpec,
12
+ type SqliteTableSpec,
13
+ step,
14
+ } from './shared';
15
+
16
+ /**
17
+ * Renders the body of a `CREATE TABLE <name> ( … )` statement from a flat
18
+ * `SqliteTableSpec`. SQLite's `INTEGER PRIMARY KEY AUTOINCREMENT` form is
19
+ * inline on the column; the table-level PRIMARY KEY clause is emitted only
20
+ * when no column carries `inlineAutoincrementPrimaryKey`.
21
+ */
22
+ function renderCreateTableSql(tableName: string, spec: SqliteTableSpec): string {
23
+ const columnDefs = spec.columns.map(renderColumnDefinition);
24
+
25
+ const constraintDefs: string[] = [];
26
+ const hasInlinePk = spec.columns.some((c) => c.inlineAutoincrementPrimaryKey);
27
+ if (spec.primaryKey && !hasInlinePk) {
28
+ constraintDefs.push(`PRIMARY KEY (${spec.primaryKey.columns.map(quoteIdentifier).join(', ')})`);
29
+ }
30
+
31
+ for (const u of spec.uniques ?? []) {
32
+ const name = u.name ? `CONSTRAINT ${quoteIdentifier(u.name)} ` : '';
33
+ constraintDefs.push(`${name}UNIQUE (${u.columns.map(quoteIdentifier).join(', ')})`);
34
+ }
35
+
36
+ for (const fk of spec.foreignKeys ?? []) {
37
+ const clause = renderForeignKeyClause(fk);
38
+ if (clause) constraintDefs.push(clause);
39
+ }
40
+
41
+ const allDefs = [...columnDefs, ...constraintDefs];
42
+ return `CREATE TABLE ${quoteIdentifier(tableName)} (\n ${allDefs.join(',\n ')}\n)`;
43
+ }
44
+
45
+ export function createTable(tableName: string, spec: SqliteTableSpec): Op {
46
+ return {
47
+ id: `table.${tableName}`,
48
+ label: `Create table ${tableName}`,
49
+ summary: `Creates table ${tableName} with required columns`,
50
+ operationClass: 'additive',
51
+ target: { id: 'sqlite', details: buildTargetDetails('table', tableName) },
52
+ precheck: [
53
+ step(
54
+ `ensure table "${tableName}" does not exist`,
55
+ `SELECT COUNT(*) = 0 FROM sqlite_master WHERE type = 'table' AND name = '${escapeLiteral(tableName)}'`,
56
+ ),
57
+ ],
58
+ execute: [step(`create table "${tableName}"`, renderCreateTableSql(tableName, spec))],
59
+ postcheck: [
60
+ step(
61
+ `verify table "${tableName}" exists`,
62
+ `SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = '${escapeLiteral(tableName)}'`,
63
+ ),
64
+ ],
65
+ };
66
+ }
67
+
68
+ export function dropTable(tableName: string): Op {
69
+ return {
70
+ id: `dropTable.${tableName}`,
71
+ label: `Drop table ${tableName}`,
72
+ summary: `Drops table ${tableName} which is not in the contract`,
73
+ operationClass: 'destructive',
74
+ target: { id: 'sqlite', details: buildTargetDetails('table', tableName) },
75
+ precheck: [
76
+ step(
77
+ `ensure table "${tableName}" exists`,
78
+ `SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = '${escapeLiteral(tableName)}'`,
79
+ ),
80
+ ],
81
+ execute: [step(`drop table "${tableName}"`, `DROP TABLE ${quoteIdentifier(tableName)}`)],
82
+ postcheck: [
83
+ step(
84
+ `verify table "${tableName}" is gone`,
85
+ `SELECT COUNT(*) = 0 FROM sqlite_master WHERE type = 'table' AND name = '${escapeLiteral(tableName)}'`,
86
+ ),
87
+ ],
88
+ };
89
+ }
90
+
91
+ export interface RecreateTableArgs {
92
+ readonly tableName: string;
93
+ /** New (post-recreate) shape of the table. Same flat spec as `createTable`. */
94
+ readonly contractTable: SqliteTableSpec;
95
+ /**
96
+ * Names of columns that exist in the live (pre-recreate) schema. Used to
97
+ * compute the `INSERT INTO temp ... SELECT ... FROM old` column list — only
98
+ * shared columns are copied, so dropped columns are left behind and added
99
+ * columns come from defaults.
100
+ */
101
+ readonly schemaColumnNames: readonly string[];
102
+ /**
103
+ * Indexes (declared + FK-backing, deduped by column-set) to recreate after
104
+ * the table has been replaced. The planner pre-merges these.
105
+ */
106
+ readonly indexes: readonly SqliteIndexSpec[];
107
+ /** Human-readable summary of the change, built by the planner from issues. */
108
+ readonly summary: string;
109
+ /**
110
+ * Per-issue postcheck steps appended after the structural postchecks. The
111
+ * planner pre-builds these via `buildRecreatePostchecks` so the call IR
112
+ * carries flat, serializable data only — no `SchemaIssue` references.
113
+ */
114
+ readonly postchecks: readonly { readonly description: string; readonly sql: string }[];
115
+ readonly operationClass: MigrationOperationClass;
116
+ }
117
+
118
+ export function recreateTable(args: RecreateTableArgs): Op {
119
+ const {
120
+ tableName,
121
+ contractTable,
122
+ schemaColumnNames,
123
+ indexes,
124
+ summary,
125
+ postchecks,
126
+ operationClass,
127
+ } = args;
128
+ const tempName = `_prisma_new_${tableName}`;
129
+ const liveSet = new Set(schemaColumnNames);
130
+ const sharedColumns = contractTable.columns.filter((c) => liveSet.has(c.name)).map((c) => c.name);
131
+ const columnList = sharedColumns.map(quoteIdentifier).join(', ');
132
+
133
+ const indexStatements = indexes.map((idx) => ({
134
+ description: `recreate index "${idx.name}" on "${tableName}"`,
135
+ sql: buildCreateIndexSql(tableName, idx.name, idx.columns),
136
+ }));
137
+
138
+ // If the contract retains no columns from the live table, an `INSERT INTO
139
+ // tmp () SELECT FROM old` is invalid SQL — and would also be a no-op since
140
+ // there's nothing to copy. Skip the copy step in that case; the new
141
+ // (empty) table replaces the old one directly.
142
+ const copyStep =
143
+ sharedColumns.length > 0
144
+ ? [
145
+ step(
146
+ `copy data from "${tableName}" to "${tempName}"`,
147
+ `INSERT INTO ${quoteIdentifier(tempName)} (${columnList}) SELECT ${columnList} FROM ${quoteIdentifier(tableName)}`,
148
+ ),
149
+ ]
150
+ : [];
151
+
152
+ return {
153
+ id: `recreateTable.${tableName}`,
154
+ label: `Recreate table ${tableName}`,
155
+ summary,
156
+ operationClass,
157
+ target: { id: 'sqlite', details: buildTargetDetails('table', tableName) },
158
+ precheck: [
159
+ step(
160
+ `ensure table "${tableName}" exists`,
161
+ `SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = '${escapeLiteral(tableName)}'`,
162
+ ),
163
+ step(
164
+ `ensure temp table "${tempName}" does not exist`,
165
+ `SELECT COUNT(*) = 0 FROM sqlite_master WHERE type = 'table' AND name = '${escapeLiteral(tempName)}'`,
166
+ ),
167
+ ],
168
+ execute: [
169
+ step(
170
+ `create new table "${tempName}" with desired schema`,
171
+ renderCreateTableSql(tempName, contractTable),
172
+ ),
173
+ ...copyStep,
174
+ step(`drop old table "${tableName}"`, `DROP TABLE ${quoteIdentifier(tableName)}`),
175
+ step(
176
+ `rename "${tempName}" to "${tableName}"`,
177
+ `ALTER TABLE ${quoteIdentifier(tempName)} RENAME TO ${quoteIdentifier(tableName)}`,
178
+ ),
179
+ ...indexStatements,
180
+ ],
181
+ postcheck: [
182
+ step(
183
+ `verify table "${tableName}" exists`,
184
+ `SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = '${escapeLiteral(tableName)}'`,
185
+ ),
186
+ step(
187
+ `verify temp table "${tempName}" is gone`,
188
+ `SELECT COUNT(*) = 0 FROM sqlite_master WHERE type = 'table' AND name = '${escapeLiteral(tempName)}'`,
189
+ ),
190
+ ...postchecks,
191
+ ],
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Build a one-line summary of a recreate-table operation from the schema
197
+ * issues that triggered it. Lives next to `recreateTable` so the planner
198
+ * (which has the issues) can produce the same description the factory
199
+ * used to build inline. Keeping the formatting target-side keeps
200
+ * `RecreateTableCall` issue-free at the IR layer.
201
+ */
202
+ export function buildRecreateSummary(tableName: string, issues: readonly SchemaIssue[]): string {
203
+ const messages = issues.map((i) => i.message).join('; ');
204
+ return `Recreates table ${tableName} to apply schema changes: ${messages}`;
205
+ }
206
+
207
+ const COLUMN_LEVEL_ISSUE_KINDS = new Set<SchemaIssue['kind']>([
208
+ 'nullability_mismatch',
209
+ 'default_mismatch',
210
+ 'default_missing',
211
+ 'extra_default',
212
+ 'type_mismatch',
213
+ ]);
214
+
215
+ const PK_ISSUE_KINDS = new Set<SchemaIssue['kind']>(['primary_key_mismatch', 'extra_primary_key']);
216
+
217
+ const UNIQUE_ISSUE_KINDS = new Set<SchemaIssue['kind']>([
218
+ 'unique_constraint_mismatch',
219
+ 'extra_unique_constraint',
220
+ ]);
221
+
222
+ const FK_ISSUE_KINDS = new Set<SchemaIssue['kind']>(['foreign_key_mismatch', 'extra_foreign_key']);
223
+
224
+ /**
225
+ * Returns the columns the contract expects as the table's primary key. Picks
226
+ * up SQLite's inline `INTEGER PRIMARY KEY AUTOINCREMENT` form when no
227
+ * explicit `primaryKey` clause is set on the spec.
228
+ */
229
+ function expectedPrimaryKeyColumns(spec: SqliteTableSpec): readonly string[] {
230
+ if (spec.primaryKey) return spec.primaryKey.columns;
231
+ const inlinePk = spec.columns.find((c) => c.inlineAutoincrementPrimaryKey);
232
+ return inlinePk ? [inlinePk.name] : [];
233
+ }
234
+
235
+ function quoteSqlList(values: readonly string[]): string {
236
+ return values.map((v) => `'${escapeLiteral(v)}'`).join(', ');
237
+ }
238
+
239
+ /**
240
+ * Per-issue postchecks verifying the recreated table's shape against the
241
+ * contract spec. Column-level issues (`nullability_mismatch`,
242
+ * `default_mismatch`, …) emit one targeted check each; constraint-level
243
+ * issues (`primary_key_mismatch`, `unique_constraint_mismatch`,
244
+ * `foreign_key_mismatch`, plus their `extra_*` siblings) emit one
245
+ * `pragma_*`-driven check per declared constraint in the contract spec, so
246
+ * a recreated table with the right columns but the wrong PK / unique / FK
247
+ * shape fails the postcheck instead of passing silently. Exported so the
248
+ * planner can pre-build the list at construction time and
249
+ * `RecreateTableCall` doesn't have to carry `SchemaIssue` objects through
250
+ * to render time.
251
+ */
252
+ export function buildRecreatePostchecks(
253
+ tableName: string,
254
+ issues: readonly SchemaIssue[],
255
+ spec: SqliteTableSpec,
256
+ ): Array<{ description: string; sql: string }> {
257
+ const checks: Array<{ description: string; sql: string }> = [];
258
+ const t = escapeLiteral(tableName);
259
+ const byName = new Map(spec.columns.map((c) => [c.name, c]));
260
+
261
+ for (const issue of issues) {
262
+ if (issue.kind === 'enum_values_changed') continue;
263
+ if (!COLUMN_LEVEL_ISSUE_KINDS.has(issue.kind)) continue;
264
+ if (!issue.column) continue;
265
+ const c = escapeLiteral(issue.column);
266
+ if (issue.kind === 'nullability_mismatch') {
267
+ // `expected` carries the contract's nullable flag as a string. We only
268
+ // emit a postcheck when the value is recognized — anything else
269
+ // (case-folded, numeric coding, etc.) is left to the structural
270
+ // verifier so a typo here can't silently invert the meaning.
271
+ let wantNotNull: boolean | undefined;
272
+ if (issue.expected === 'false') wantNotNull = true;
273
+ else if (issue.expected === 'true') wantNotNull = false;
274
+ if (wantNotNull !== undefined) {
275
+ checks.push({
276
+ description: `verify "${issue.column}" nullability on "${tableName}"`,
277
+ sql: `SELECT COUNT(*) > 0 FROM pragma_table_info('${t}') WHERE name = '${c}' AND "notnull" = ${wantNotNull ? 1 : 0}`,
278
+ });
279
+ }
280
+ }
281
+ if (issue.kind === 'default_mismatch' || issue.kind === 'default_missing') {
282
+ const colSpec = byName.get(issue.column);
283
+ const expectedRaw = colSpec?.defaultSql.startsWith('DEFAULT ')
284
+ ? // SQLite's pragma_table_info.dflt_value strips outer parens for
285
+ // expression defaults (per the SQLite docs), so `(datetime('now'))`
286
+ // is stored as `datetime('now')`. Strip them here so the postcheck
287
+ // matches.
288
+ stripOuterParens(colSpec.defaultSql.slice('DEFAULT '.length))
289
+ : null;
290
+ if (expectedRaw) {
291
+ checks.push({
292
+ description: `verify "${issue.column}" default on "${tableName}"`,
293
+ sql: `SELECT COUNT(*) > 0 FROM pragma_table_info('${t}') WHERE name = '${c}' AND dflt_value = '${escapeLiteral(expectedRaw)}'`,
294
+ });
295
+ }
296
+ }
297
+ if (issue.kind === 'type_mismatch') {
298
+ const colSpec = byName.get(issue.column);
299
+ if (colSpec) {
300
+ checks.push({
301
+ description: `verify "${issue.column}" type on "${tableName}"`,
302
+ sql: `SELECT COUNT(*) > 0 FROM pragma_table_info('${t}') WHERE name = '${c}' AND LOWER(type) = '${escapeLiteral(colSpec.typeSql.toLowerCase())}'`,
303
+ });
304
+ }
305
+ }
306
+ if (issue.kind === 'extra_default') {
307
+ checks.push({
308
+ description: `verify "${issue.column}" has no default on "${tableName}"`,
309
+ sql: `SELECT COUNT(*) > 0 FROM pragma_table_info('${t}') WHERE name = '${c}' AND dflt_value IS NULL`,
310
+ });
311
+ }
312
+ }
313
+
314
+ // Constraint-level issues — emit one postcheck per declared constraint in
315
+ // the contract spec when *any* issue of that kind fires, since recreate
316
+ // rebuilds the entire table at once.
317
+ const hasPkIssue = issues.some((i) => PK_ISSUE_KINDS.has(i.kind));
318
+ const hasUniqueIssue = issues.some((i) => UNIQUE_ISSUE_KINDS.has(i.kind));
319
+ const hasFkIssue = issues.some((i) => FK_ISSUE_KINDS.has(i.kind));
320
+
321
+ if (hasPkIssue) {
322
+ const pkColumns = expectedPrimaryKeyColumns(spec);
323
+ // Verify pragma_table_info reports exactly these columns as PK members
324
+ // (count + named membership); zero columns expected ⇒ no PK at all.
325
+ const colCount = pkColumns.length;
326
+ if (colCount === 0) {
327
+ checks.push({
328
+ description: `verify "${tableName}" has no primary key`,
329
+ sql: `SELECT (SELECT COUNT(*) FROM pragma_table_info('${t}') WHERE pk > 0) = 0`,
330
+ });
331
+ } else {
332
+ checks.push({
333
+ description: `verify primary key on "${tableName}"`,
334
+ sql:
335
+ `SELECT (SELECT COUNT(*) FROM pragma_table_info('${t}') WHERE pk > 0) = ${colCount}` +
336
+ ` AND (SELECT COUNT(*) FROM pragma_table_info('${t}') WHERE pk > 0 AND name IN (${quoteSqlList(pkColumns)})) = ${colCount}`,
337
+ });
338
+ }
339
+ }
340
+
341
+ if (hasUniqueIssue) {
342
+ for (const u of spec.uniques ?? []) {
343
+ const colCount = u.columns.length;
344
+ const description = u.name
345
+ ? `verify unique constraint "${u.name}" on "${tableName}"`
346
+ : `verify unique constraint (${u.columns.join(', ')}) on "${tableName}"`;
347
+ // Match any unique index whose covered columns are exactly the expected
348
+ // set. Order is intentionally not checked — SQLite's unique-index
349
+ // identity is column-set, not column-sequence.
350
+ checks.push({
351
+ description,
352
+ sql:
353
+ `SELECT EXISTS (SELECT 1 FROM pragma_index_list('${t}') l` +
354
+ ` WHERE l."unique" = 1` +
355
+ ` AND (SELECT COUNT(*) FROM pragma_index_info(l.name)) = ${colCount}` +
356
+ ` AND (SELECT COUNT(*) FROM pragma_index_info(l.name) WHERE name IN (${quoteSqlList(u.columns)})) = ${colCount})`,
357
+ });
358
+ }
359
+ }
360
+
361
+ if (hasFkIssue) {
362
+ for (const fk of spec.foreignKeys ?? []) {
363
+ const refTable = escapeLiteral(fk.references.table);
364
+ const colCount = fk.columns.length;
365
+ // Build a `SUM(CASE WHEN ("from","to") IN ((…)) …)` so the check works
366
+ // for both single- and multi-column FKs without depending on FK row
367
+ // ordering inside `pragma_foreign_key_list`.
368
+ const tuples = fk.columns
369
+ .map((from, i) => {
370
+ const to = fk.references.columns[i] ?? from;
371
+ return `('${escapeLiteral(from)}', '${escapeLiteral(to)}')`;
372
+ })
373
+ .join(', ');
374
+ const description = `verify foreign key (${fk.columns.join(', ')}) → ${fk.references.table}(${fk.references.columns.join(', ')}) on "${tableName}"`;
375
+ checks.push({
376
+ description,
377
+ sql:
378
+ `SELECT EXISTS (SELECT 1 FROM pragma_foreign_key_list('${t}') f` +
379
+ ` WHERE f."table" = '${refTable}'` +
380
+ ' GROUP BY f.id' +
381
+ ` HAVING COUNT(*) = ${colCount}` +
382
+ ` AND SUM(CASE WHEN (f."from", f."to") IN (${tuples}) THEN 1 ELSE 0 END) = ${colCount})`,
383
+ });
384
+ }
385
+ }
386
+
387
+ return checks;
388
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Low-level DDL fragment builders for SQLite migrations.
3
+ *
4
+ * These helpers consume `StorageColumn` (the contract shape, possibly with
5
+ * `typeRef`) and produce string fragments. They are called once per column
6
+ * at the call-construction boundary in `issue-planner.ts` / strategies to
7
+ * build flat `SqliteColumnSpec`s; the operation factories themselves never
8
+ * see `StorageColumn` or `storageTypes`.
9
+ */
10
+
11
+ import type {
12
+ StorageColumn,
13
+ StorageTable,
14
+ StorageTypeInstance,
15
+ } from '@prisma-next/sql-contract/types';
16
+ import { escapeLiteral, quoteIdentifier } from '../sql-utils';
17
+
18
+ type SqliteColumnDefault = StorageColumn['default'];
19
+
20
+ const SAFE_NATIVE_TYPE_PATTERN = /^[a-zA-Z][a-zA-Z0-9_ ]*$/;
21
+
22
+ function assertSafeNativeType(nativeType: string): void {
23
+ if (!SAFE_NATIVE_TYPE_PATTERN.test(nativeType)) {
24
+ throw new Error(
25
+ `Unsafe native type name in contract: "${nativeType}". ` +
26
+ 'Native type names must match /^[a-zA-Z][a-zA-Z0-9_ ]*$/',
27
+ );
28
+ }
29
+ }
30
+
31
+ function assertSafeDefaultExpression(expression: string): void {
32
+ if (expression.includes(';') || /--|\/\*|\bSELECT\b/i.test(expression)) {
33
+ throw new Error(
34
+ `Unsafe default expression in contract: "${expression}". ` +
35
+ 'Default expressions must not contain semicolons, SQL comment tokens, or subqueries.',
36
+ );
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Renders the column's DDL type token (e.g. `"INTEGER"`, `"TEXT"`).
42
+ * Resolves `typeRef` against `storageTypes` and validates the resulting
43
+ * native type against a safe-identifier pattern.
44
+ */
45
+ export function buildColumnTypeSql(
46
+ column: StorageColumn,
47
+ storageTypes: Record<string, StorageTypeInstance> = {},
48
+ ): string {
49
+ const resolved = resolveColumnTypeMetadata(column, storageTypes);
50
+ assertSafeNativeType(resolved.nativeType);
51
+ return resolved.nativeType.toUpperCase();
52
+ }
53
+
54
+ /**
55
+ * Renders the column's `DEFAULT …` clause. Returns the empty string when
56
+ * there is no default, and also when the default is `autoincrement()` —
57
+ * SQLite encodes that as `INTEGER PRIMARY KEY AUTOINCREMENT` inline on the
58
+ * column definition, not as a separate DEFAULT.
59
+ */
60
+ export function buildColumnDefaultSql(columnDefault: SqliteColumnDefault | undefined): string {
61
+ if (!columnDefault) return '';
62
+
63
+ switch (columnDefault.kind) {
64
+ case 'literal':
65
+ return `DEFAULT ${renderDefaultLiteral(columnDefault.value)}`;
66
+ case 'function': {
67
+ if (columnDefault.expression === 'autoincrement()') return '';
68
+ if (columnDefault.expression === 'now()') return "DEFAULT (datetime('now'))";
69
+ assertSafeDefaultExpression(columnDefault.expression);
70
+ return `DEFAULT (${columnDefault.expression})`;
71
+ }
72
+ }
73
+ }
74
+
75
+ export function renderDefaultLiteral(value: unknown): string {
76
+ if (value instanceof Date) {
77
+ return `'${escapeLiteral(value.toISOString())}'`;
78
+ }
79
+ if (typeof value === 'string') {
80
+ return `'${escapeLiteral(value)}'`;
81
+ }
82
+ if (typeof value === 'number' || typeof value === 'bigint') {
83
+ return String(value);
84
+ }
85
+ if (typeof value === 'boolean') {
86
+ return value ? '1' : '0';
87
+ }
88
+ if (value === null) {
89
+ return 'NULL';
90
+ }
91
+ return `'${escapeLiteral(JSON.stringify(value))}'`;
92
+ }
93
+
94
+ export function buildCreateIndexSql(
95
+ tableName: string,
96
+ indexName: string,
97
+ columns: readonly string[],
98
+ unique = false,
99
+ ): string {
100
+ const uniqueKeyword = unique ? 'UNIQUE ' : '';
101
+ return `CREATE ${uniqueKeyword}INDEX ${quoteIdentifier(indexName)} ON ${quoteIdentifier(tableName)} (${columns.map(quoteIdentifier).join(', ')})`;
102
+ }
103
+
104
+ export function buildDropIndexSql(indexName: string): string {
105
+ return `DROP INDEX IF EXISTS ${quoteIdentifier(indexName)}`;
106
+ }
107
+
108
+ /**
109
+ * True when the column is rendered inline as `INTEGER PRIMARY KEY
110
+ * AUTOINCREMENT`. Requires the column's default to be `autoincrement()` and
111
+ * the column to be the sole member of the table's primary key — anything
112
+ * else falls back to a separate PRIMARY KEY constraint with a default
113
+ * AUTOINCREMENT semantics expressed elsewhere.
114
+ */
115
+ export function isInlineAutoincrementPrimaryKey(table: StorageTable, columnName: string): boolean {
116
+ if (table.primaryKey?.columns.length !== 1) return false;
117
+ if (table.primaryKey.columns[0] !== columnName) return false;
118
+ const column = table.columns[columnName];
119
+ return column?.default?.kind === 'function' && column.default.expression === 'autoincrement()';
120
+ }
121
+
122
+ type ResolvedColumnTypeMetadata = Pick<StorageColumn, 'nativeType' | 'codecId' | 'typeParams'>;
123
+
124
+ function resolveColumnTypeMetadata(
125
+ column: StorageColumn,
126
+ storageTypes: Record<string, StorageTypeInstance>,
127
+ ): ResolvedColumnTypeMetadata {
128
+ if (!column.typeRef) {
129
+ return column;
130
+ }
131
+ const referencedType = storageTypes[column.typeRef];
132
+ if (!referencedType) {
133
+ throw new Error(
134
+ `Storage type "${column.typeRef}" referenced by column is not defined in storage.types.`,
135
+ );
136
+ }
137
+ return {
138
+ codecId: referencedType.codecId,
139
+ nativeType: referencedType.nativeType,
140
+ typeParams: referencedType.typeParams,
141
+ };
142
+ }
@@ -0,0 +1,70 @@
1
+ import type { SqlMigrationPlanOperation } from '@prisma-next/family-sql/control';
2
+ import type {
3
+ MigrationPlanWithAuthoringSurface,
4
+ OpFactoryCall,
5
+ } from '@prisma-next/framework-components/control';
6
+ import type { MigrationMeta } from '@prisma-next/migration-tools/migration';
7
+ import { ifDefined } from '@prisma-next/utils/defined';
8
+ import type { SqlitePlanTargetDetails } from './planner-target-details';
9
+ import { renderOps } from './render-ops';
10
+ import { renderCallsToTypeScript } from './render-typescript';
11
+ import { SqliteMigration } from './sqlite-migration';
12
+
13
+ type Op = SqlMigrationPlanOperation<SqlitePlanTargetDetails>;
14
+
15
+ export interface SqliteMigrationDestinationInfo {
16
+ readonly storageHash: string;
17
+ readonly profileHash?: string;
18
+ }
19
+
20
+ export class TypeScriptRenderableSqliteMigration
21
+ extends SqliteMigration
22
+ implements MigrationPlanWithAuthoringSurface
23
+ {
24
+ readonly #calls: readonly OpFactoryCall[];
25
+ readonly #meta: MigrationMeta;
26
+ readonly #destination: SqliteMigrationDestinationInfo;
27
+ readonly #spaceId: string;
28
+
29
+ constructor(
30
+ calls: readonly OpFactoryCall[],
31
+ meta: MigrationMeta,
32
+ spaceId: string,
33
+ destination?: SqliteMigrationDestinationInfo,
34
+ ) {
35
+ super();
36
+ this.#calls = calls;
37
+ this.#meta = meta;
38
+ this.#spaceId = spaceId;
39
+ this.#destination = destination ?? { storageHash: meta.to };
40
+ }
41
+
42
+ override get operations(): readonly Op[] {
43
+ return renderOps(this.#calls);
44
+ }
45
+
46
+ override describe(): MigrationMeta {
47
+ return this.#meta;
48
+ }
49
+
50
+ override get destination(): SqliteMigrationDestinationInfo {
51
+ return this.#destination;
52
+ }
53
+
54
+ /**
55
+ * Contract space this planner-produced plan applies to. Threaded
56
+ * from {@link SqlMigrationPlannerPlanOptions.spaceId} so the runner
57
+ * keys the marker row by the right space when executing the plan.
58
+ */
59
+ get spaceId(): string {
60
+ return this.#spaceId;
61
+ }
62
+
63
+ renderTypeScript(): string {
64
+ return renderCallsToTypeScript(this.#calls, {
65
+ from: this.#meta.from,
66
+ to: this.#meta.to,
67
+ ...ifDefined('labels', this.#meta.labels),
68
+ });
69
+ }
70
+ }