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