@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
|
@@ -1,44 +1,46 @@
|
|
|
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
|
|
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';
|
|
24
|
+
import { issueOutcome, orderIssuesByDependencies } from '@prisma-next/framework-components/control';
|
|
35
25
|
import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming';
|
|
36
|
-
import
|
|
26
|
+
import {
|
|
27
|
+
RelationalSchemaNodeKind,
|
|
28
|
+
type SqlColumnIR,
|
|
29
|
+
type SqlIndexIR,
|
|
30
|
+
SqlSchemaIR,
|
|
31
|
+
type SqlSchemaIRNode,
|
|
32
|
+
type SqlTableIR,
|
|
33
|
+
} from '@prisma-next/sql-schema-ir/types';
|
|
37
34
|
import { blindCast } from '@prisma-next/utils/casts';
|
|
38
|
-
import { ifDefined } from '@prisma-next/utils/defined';
|
|
39
35
|
import type { Result } from '@prisma-next/utils/result';
|
|
40
36
|
import { notOk, ok } from '@prisma-next/utils/result';
|
|
41
37
|
import { CONTROL_TABLE_NAMES } from '../control-tables';
|
|
38
|
+
import {
|
|
39
|
+
columnSpecFromNode,
|
|
40
|
+
ddlColumnFromNode,
|
|
41
|
+
isInlineAutoincrementPrimaryKeyNode,
|
|
42
|
+
tableConstraintsFromNode,
|
|
43
|
+
} from './column-ddl-rendering';
|
|
42
44
|
import {
|
|
43
45
|
AddColumnCall,
|
|
44
46
|
CreateIndexCall,
|
|
@@ -54,210 +56,93 @@ import type {
|
|
|
54
56
|
SqliteTableSpec,
|
|
55
57
|
SqliteUniqueSpec,
|
|
56
58
|
} from './operations/shared';
|
|
57
|
-
import {
|
|
58
|
-
buildColumnDefaultSql,
|
|
59
|
-
buildColumnTypeSql,
|
|
60
|
-
isInlineAutoincrementPrimaryKey,
|
|
61
|
-
resolveColumnTypeMetadata,
|
|
62
|
-
} from './planner-ddl-builders';
|
|
63
59
|
import {
|
|
64
60
|
type CallMigrationStrategy,
|
|
65
|
-
resolveNamespaceIdForIssue,
|
|
66
61
|
type StrategyContext,
|
|
67
62
|
sqlitePlannerStrategies,
|
|
68
|
-
tableAt,
|
|
69
63
|
} from './planner-strategies';
|
|
70
64
|
|
|
71
65
|
export type { CallMigrationStrategy, StrategyContext };
|
|
72
66
|
|
|
73
67
|
// ============================================================================
|
|
74
|
-
//
|
|
68
|
+
// Subtree coalescing — the planner's responsibility per the differ's contract
|
|
75
69
|
// ============================================================================
|
|
76
70
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
// Constraints after columns exist
|
|
100
|
-
primary_key_mismatch: 50,
|
|
101
|
-
unique_constraint_mismatch: 51,
|
|
102
|
-
index_mismatch: 52,
|
|
103
|
-
foreign_key_mismatch: 60,
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
function issueOrder(issue: SchemaIssue): number {
|
|
107
|
-
return ISSUE_KIND_ORDER[issue.kind] ?? 99;
|
|
71
|
+
/**
|
|
72
|
+
* The generic differ is total: a missing/extra table (or column) emits an
|
|
73
|
+
* issue for itself AND for every node in its subtree (columns, defaults,
|
|
74
|
+
* constraints, indexes). `CreateTableCall`/`DropTableCall` and
|
|
75
|
+
* `AddColumnCall`/`DropColumnCall` already account for the whole subtree
|
|
76
|
+
* (reading it directly off the table/column node), so the nested issues are
|
|
77
|
+
* redundant — coalescing them is "the planner's responsibility" the differ's
|
|
78
|
+
* own contract assigns (`schema-diff.ts`). Drops any issue whose path is a
|
|
79
|
+
* strict descendant of a `not-found`/`not-expected` issue's path.
|
|
80
|
+
*/
|
|
81
|
+
export function coalesceSubtreeIssues(
|
|
82
|
+
issues: readonly SchemaDiffIssue[],
|
|
83
|
+
): readonly SchemaDiffIssue[] {
|
|
84
|
+
const collapsingPaths = issues
|
|
85
|
+
.filter(
|
|
86
|
+
(issue) => issueOutcome(issue) === 'not-found' || issueOutcome(issue) === 'not-expected',
|
|
87
|
+
)
|
|
88
|
+
.map((issue) => issue.path);
|
|
89
|
+
if (collapsingPaths.length === 0) return issues;
|
|
90
|
+
return issues.filter(
|
|
91
|
+
(issue) => !collapsingPaths.some((ancestor) => isStrictDescendantPath(issue.path, ancestor)),
|
|
92
|
+
);
|
|
108
93
|
}
|
|
109
94
|
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
: '';
|
|
117
|
-
return `${table}\u0000${column}\u0000${name}`;
|
|
95
|
+
function isStrictDescendantPath(path: readonly string[], ancestor: readonly string[]): boolean {
|
|
96
|
+
if (path.length <= ancestor.length) return false;
|
|
97
|
+
for (let i = 0; i < ancestor.length; i += 1) {
|
|
98
|
+
if (path[i] !== ancestor[i]) return false;
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
118
101
|
}
|
|
119
102
|
|
|
120
103
|
// ============================================================================
|
|
121
|
-
//
|
|
104
|
+
// Node helpers
|
|
122
105
|
// ============================================================================
|
|
123
106
|
|
|
124
|
-
function
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
summary,
|
|
132
|
-
why: 'Use `migration new` to author a custom migration for this change.',
|
|
133
|
-
...(location ? { location } : {}),
|
|
134
|
-
};
|
|
107
|
+
export function issueNode(issue: SchemaDiffIssue): SqlSchemaIRNode | undefined {
|
|
108
|
+
const node = issue.expected ?? issue.actual;
|
|
109
|
+
if (node === undefined) return undefined;
|
|
110
|
+
return blindCast<
|
|
111
|
+
SqlSchemaIRNode,
|
|
112
|
+
'every node in a SQL schema diff tree is a SqlSchemaIRNode; nodeKind is its required discriminant'
|
|
113
|
+
>(node);
|
|
135
114
|
}
|
|
136
115
|
|
|
137
|
-
|
|
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';
|
|
116
|
+
/** Whether the expected/actual native type (resolved, or raw+many fallback) differs — mirrors `SqlColumnIR.isEqualTo`'s type comparison. */
|
|
117
|
+
export function columnTypeChanged(expected: SqlColumnIR, actual: SqlColumnIR): boolean {
|
|
118
|
+
if (expected.resolvedNativeType !== undefined && actual.resolvedNativeType !== undefined) {
|
|
119
|
+
return expected.resolvedNativeType !== actual.resolvedNativeType;
|
|
154
120
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
function issueLocation(issue: SchemaIssue): SqlPlannerConflictLocation | undefined {
|
|
158
|
-
if (issue.kind === 'enum_values_changed') return undefined;
|
|
159
|
-
const location: {
|
|
160
|
-
table?: string;
|
|
161
|
-
column?: string;
|
|
162
|
-
constraint?: string;
|
|
163
|
-
} = {};
|
|
164
|
-
if (issue.table) location.table = issue.table;
|
|
165
|
-
if (issue.column) location.column = issue.column;
|
|
166
|
-
if (issue.indexOrConstraint) location.constraint = issue.indexOrConstraint;
|
|
167
|
-
return Object.keys(location).length > 0 ? (location as SqlPlannerConflictLocation) : undefined;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function conflictForDisallowedCall(
|
|
171
|
-
call: SqliteOpFactoryCall,
|
|
172
|
-
allowed: readonly string[],
|
|
173
|
-
): SqlPlannerConflict {
|
|
174
|
-
const summary = `Operation "${call.label}" requires class "${call.operationClass}", but policy allows only: ${allowed.join(', ')}`;
|
|
175
|
-
const location = locationForCall(call);
|
|
176
|
-
return {
|
|
177
|
-
kind: conflictKindForCall(call),
|
|
178
|
-
summary,
|
|
179
|
-
why: 'Use `migration new` to author a custom migration for this change.',
|
|
180
|
-
...(location ? { location } : {}),
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function conflictKindForCall(call: SqliteOpFactoryCall): SqlPlannerConflict['kind'] {
|
|
185
|
-
switch (call.factoryName) {
|
|
186
|
-
case 'createIndex':
|
|
187
|
-
case 'dropIndex':
|
|
188
|
-
return 'indexIncompatible';
|
|
189
|
-
default:
|
|
190
|
-
return 'missingButNonAdditive';
|
|
191
|
-
}
|
|
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),
|
|
121
|
+
return (
|
|
122
|
+
expected.nativeType !== actual.nativeType || Boolean(expected.many) !== Boolean(actual.many)
|
|
229
123
|
);
|
|
230
|
-
const defaultSql = buildColumnDefaultSql(column.default);
|
|
231
|
-
return {
|
|
232
|
-
name,
|
|
233
|
-
typeSql,
|
|
234
|
-
defaultSql,
|
|
235
|
-
nullable: column.nullable,
|
|
236
|
-
...(inlineAutoincrementPrimaryKey ? { inlineAutoincrementPrimaryKey: true } : {}),
|
|
237
|
-
};
|
|
238
124
|
}
|
|
239
125
|
|
|
240
126
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
127
|
+
* Builds the flat `SqliteTableSpec` `RecreateTableCall` needs from the
|
|
128
|
+
* expected table node — the node-sourced equivalent of the retired
|
|
129
|
+
* `toTableSpec` (which read a raw contract `StorageTable`). Every column's
|
|
130
|
+
* spec is resolved from its `codecRef` via `columnSpecFromNode`.
|
|
245
131
|
*/
|
|
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)),
|
|
132
|
+
export function tableSpecFromNode(table: SqlTableIR): SqliteTableSpec {
|
|
133
|
+
const columns: SqliteColumnSpec[] = Object.values(table.columns).map((c) =>
|
|
134
|
+
columnSpecFromNode(c, isInlineAutoincrementPrimaryKeyNode(table, c)),
|
|
252
135
|
);
|
|
253
136
|
const uniques: SqliteUniqueSpec[] = table.uniques.map((u) => ({
|
|
254
137
|
columns: u.columns,
|
|
255
138
|
...(u.name !== undefined ? { name: u.name } : {}),
|
|
256
139
|
}));
|
|
140
|
+
// Every FK node on the expected tree is constraint-bearing by construction
|
|
141
|
+
// (a persisted `foreignKeys[]` entry is a constraint; a non-constraint FK
|
|
142
|
+
// contributes only a backing index, never an FK node).
|
|
257
143
|
const foreignKeys: SqliteForeignKeySpec[] = table.foreignKeys.map((fk) => ({
|
|
258
|
-
columns: fk.
|
|
259
|
-
references: { table: fk.
|
|
260
|
-
constraint: fk.constraint !== false,
|
|
144
|
+
columns: fk.columns,
|
|
145
|
+
references: { table: fk.referencedTable, columns: fk.referencedColumns },
|
|
261
146
|
...(fk.name !== undefined ? { name: fk.name } : {}),
|
|
262
147
|
...(fk.onDelete !== undefined ? { onDelete: fk.onDelete } : {}),
|
|
263
148
|
...(fk.onUpdate !== undefined ? { onUpdate: fk.onUpdate } : {}),
|
|
@@ -271,324 +156,220 @@ export function toTableSpec(
|
|
|
271
156
|
}
|
|
272
157
|
|
|
273
158
|
// ============================================================================
|
|
274
|
-
//
|
|
159
|
+
// Conflict helpers
|
|
275
160
|
// ============================================================================
|
|
276
161
|
|
|
277
|
-
function
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
162
|
+
function issueConflict(
|
|
163
|
+
kind: SqlPlannerConflict['kind'],
|
|
164
|
+
summary: string,
|
|
165
|
+
location?: SqlPlannerConflict['location'],
|
|
166
|
+
): SqlPlannerConflict {
|
|
167
|
+
return {
|
|
168
|
+
kind,
|
|
169
|
+
summary,
|
|
170
|
+
why: 'Use `migration new` to author a custom migration for this change.',
|
|
171
|
+
...(location ? { location } : {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function issueLocation(tableName: string, columnName?: string): SqlPlannerConflictLocation {
|
|
176
|
+
return columnName !== undefined
|
|
177
|
+
? { entityKind: 'table', entityName: tableName, column: columnName }
|
|
178
|
+
: { entityKind: 'table', entityName: tableName };
|
|
297
179
|
}
|
|
298
180
|
|
|
299
181
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
* `
|
|
182
|
+
* Conflict kind for a node kind that `recreateTableStrategy` absorbs for
|
|
183
|
+
* every reachable production issue. Reaching `mapNodeIssueToCall` for one of
|
|
184
|
+
* these means the recreate strategy didn't run — mirrors the legacy
|
|
185
|
+
* `conflictKindForIssue` per-kind categorization.
|
|
304
186
|
*/
|
|
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 }));
|
|
187
|
+
function absorbedConflictKind(nodeKind: string): SqlPlannerConflict['kind'] {
|
|
188
|
+
switch (nodeKind) {
|
|
189
|
+
case RelationalSchemaNodeKind.primaryKey:
|
|
190
|
+
case RelationalSchemaNodeKind.unique:
|
|
191
|
+
return 'indexIncompatible';
|
|
192
|
+
case RelationalSchemaNodeKind.foreignKey:
|
|
193
|
+
return 'foreignKeyConflict';
|
|
194
|
+
default:
|
|
195
|
+
return 'missingButNonAdditive';
|
|
365
196
|
}
|
|
197
|
+
}
|
|
366
198
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
columns: u.columns,
|
|
371
|
-
...(u.name !== undefined ? { name: u.name } : {}),
|
|
372
|
-
}),
|
|
373
|
-
);
|
|
374
|
-
}
|
|
199
|
+
// ============================================================================
|
|
200
|
+
// StorageTable / StorageColumn → flat SqliteTableSpec / DdlColumn helpers
|
|
201
|
+
// ============================================================================
|
|
375
202
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
)
|
|
203
|
+
/**
|
|
204
|
+
* Builds the `CreateTableCall` + per-index `CreateIndexCall`s for a
|
|
205
|
+
* newly-expected table. Reads only the table node's own children — indexes
|
|
206
|
+
* (declared + FK-backing, deduped) are already merged and ordered at
|
|
207
|
+
* derivation (`contractToSchemaIR`'s `convertTable`).
|
|
208
|
+
*/
|
|
209
|
+
function buildCreateTableCalls(table: SqlTableIR): SqliteOpFactoryCall[] {
|
|
210
|
+
const columns = Object.values(table.columns).map((c) =>
|
|
211
|
+
ddlColumnFromNode(c, isInlineAutoincrementPrimaryKeyNode(table, c)),
|
|
212
|
+
);
|
|
213
|
+
const hasInlinePk = Object.values(table.columns).some((c) =>
|
|
214
|
+
isInlineAutoincrementPrimaryKeyNode(table, c),
|
|
215
|
+
);
|
|
216
|
+
const constraints = tableConstraintsFromNode(table, hasInlinePk);
|
|
217
|
+
const calls: SqliteOpFactoryCall[] = [
|
|
218
|
+
new CreateTableCall(table.name, columns, constraints.length > 0 ? constraints : undefined),
|
|
219
|
+
];
|
|
220
|
+
for (const index of table.indexes) {
|
|
221
|
+
const indexName = index.name ?? defaultIndexName(table.name, index.columns);
|
|
222
|
+
calls.push(new CreateIndexCall(table.name, indexName, index.columns));
|
|
388
223
|
}
|
|
389
|
-
|
|
390
|
-
return { columns, constraints };
|
|
224
|
+
return calls;
|
|
391
225
|
}
|
|
392
226
|
|
|
393
227
|
// ============================================================================
|
|
394
|
-
// Issue
|
|
228
|
+
// Issue → Call mapping (per-issue default path)
|
|
395
229
|
// ============================================================================
|
|
396
230
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
231
|
+
function mapTableIssue(
|
|
232
|
+
issue: SchemaDiffIssue,
|
|
233
|
+
): Result<readonly SqliteOpFactoryCall[], SqlPlannerConflict> {
|
|
234
|
+
if (issueOutcome(issue) === 'not-found') {
|
|
235
|
+
const table = blindCast<
|
|
236
|
+
SqlTableIR,
|
|
237
|
+
'a not-found table issue always carries the expected table node'
|
|
238
|
+
>(issue.expected);
|
|
239
|
+
return ok(buildCreateTableCalls(table));
|
|
240
|
+
}
|
|
241
|
+
if (issueOutcome(issue) === 'not-expected') {
|
|
242
|
+
const table = blindCast<
|
|
243
|
+
SqlTableIR,
|
|
244
|
+
'a not-expected table issue always carries the actual table node'
|
|
245
|
+
>(issue.actual);
|
|
246
|
+
// Runner-owned control tables must never be dropped.
|
|
247
|
+
if (CONTROL_TABLE_NAMES.has(table.name)) return ok([]);
|
|
248
|
+
return ok([new DropTableCall(table.name)]);
|
|
249
|
+
}
|
|
250
|
+
// Unreachable: SqlTableIR.isEqualTo is identity, so a paired table can
|
|
251
|
+
// never mismatch — kept for exhaustiveness against a future node change.
|
|
252
|
+
return notOk(
|
|
253
|
+
issueConflict('unsupportedOperation', `Unexpected table drift: ${issue.path.join('/')}`),
|
|
254
|
+
);
|
|
407
255
|
}
|
|
408
256
|
|
|
409
|
-
|
|
410
|
-
|
|
257
|
+
function mapColumnIssue(
|
|
258
|
+
issue: SchemaDiffIssue,
|
|
259
|
+
ctx: StrategyContext,
|
|
260
|
+
): Result<readonly SqliteOpFactoryCall[], SqlPlannerConflict> {
|
|
261
|
+
const tableName = issue.path[1];
|
|
262
|
+
if (tableName === undefined) {
|
|
263
|
+
return notOk(
|
|
264
|
+
issueConflict(
|
|
265
|
+
'unsupportedOperation',
|
|
266
|
+
`Column issue has no table in its path: ${issue.path.join('/')}`,
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (issueOutcome(issue) === 'not-found') {
|
|
271
|
+
const column = blindCast<
|
|
272
|
+
SqlColumnIR,
|
|
273
|
+
'a not-found column issue always carries the expected column node'
|
|
274
|
+
>(issue.expected);
|
|
275
|
+
// A sole-autoincrement-PK column is always part of the table's own
|
|
276
|
+
// CREATE (never a bare ADD COLUMN — PK changes go through
|
|
277
|
+
// `recreateTableStrategy`), but the check is cheap and keeps this path
|
|
278
|
+
// honest against the table node rather than assuming it.
|
|
279
|
+
const table = ctx.expected.tables[tableName];
|
|
280
|
+
const inline = table !== undefined && isInlineAutoincrementPrimaryKeyNode(table, column);
|
|
281
|
+
return ok([new AddColumnCall(tableName, columnSpecFromNode(column, inline))]);
|
|
282
|
+
}
|
|
283
|
+
if (issueOutcome(issue) === 'not-expected') {
|
|
284
|
+
const column = blindCast<
|
|
285
|
+
SqlColumnIR,
|
|
286
|
+
'a not-expected column issue always carries the actual column node'
|
|
287
|
+
>(issue.actual);
|
|
288
|
+
return ok([new DropColumnCall(tableName, column.name)]);
|
|
289
|
+
}
|
|
290
|
+
// not-equal: absorbed by recreateTableStrategy for every reachable
|
|
291
|
+
// production issue (SQLite can't ALTER a column type/nullability in
|
|
292
|
+
// place). Reaching here means the strategy didn't run — conflict.
|
|
293
|
+
const expected = blindCast<
|
|
294
|
+
SqlColumnIR,
|
|
295
|
+
'a not-equal column issue always carries the expected column node'
|
|
296
|
+
>(issue.expected);
|
|
297
|
+
const actual = blindCast<
|
|
298
|
+
SqlColumnIR,
|
|
299
|
+
'a not-equal column issue always carries the actual column node'
|
|
300
|
+
>(issue.actual);
|
|
301
|
+
const kind = columnTypeChanged(expected, actual) ? 'typeMismatch' : 'nullabilityConflict';
|
|
302
|
+
return notOk(issueConflict(kind, issue.path.join('/'), issueLocation(tableName, expected.name)));
|
|
411
303
|
}
|
|
412
304
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
305
|
+
function mapIndexIssue(
|
|
306
|
+
issue: SchemaDiffIssue,
|
|
307
|
+
): Result<readonly SqliteOpFactoryCall[], SqlPlannerConflict> {
|
|
308
|
+
const tableName = issue.path[1];
|
|
309
|
+
if (tableName === undefined) {
|
|
310
|
+
return notOk(
|
|
311
|
+
issueConflict(
|
|
312
|
+
'unsupportedOperation',
|
|
313
|
+
`Index issue has no table in its path: ${issue.path.join('/')}`,
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (issueOutcome(issue) === 'not-found') {
|
|
318
|
+
const index = blindCast<
|
|
319
|
+
SqlIndexIR,
|
|
320
|
+
'a not-found index issue always carries the expected index node'
|
|
321
|
+
>(issue.expected);
|
|
322
|
+
const indexName = index.name ?? defaultIndexName(tableName, index.columns);
|
|
323
|
+
return ok([new CreateIndexCall(tableName, indexName, index.columns)]);
|
|
324
|
+
}
|
|
325
|
+
if (issueOutcome(issue) === 'not-expected') {
|
|
326
|
+
const index = blindCast<
|
|
327
|
+
SqlIndexIR,
|
|
328
|
+
'a not-expected index issue always carries the actual index node'
|
|
329
|
+
>(issue.actual);
|
|
330
|
+
const indexName = index.name ?? defaultIndexName(tableName, index.columns);
|
|
331
|
+
return ok([new DropIndexCall(tableName, indexName)]);
|
|
332
|
+
}
|
|
333
|
+
// not-equal: index type/options/uniqueness drift. SQLite can't ALTER an
|
|
334
|
+
// index in place and the legacy planner never absorbed this into a
|
|
335
|
+
// recreate either — surfaces as a conflict, matching `index_mismatch`.
|
|
336
|
+
return notOk(issueConflict('indexIncompatible', issue.path.join('/'), issueLocation(tableName)));
|
|
419
337
|
}
|
|
420
338
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
// ============================================================================
|
|
424
|
-
|
|
425
|
-
function mapIssueToCall(
|
|
426
|
-
issue: SchemaIssue,
|
|
339
|
+
export function mapNodeIssueToCall(
|
|
340
|
+
issue: SchemaDiffIssue,
|
|
427
341
|
ctx: StrategyContext,
|
|
428
342
|
): 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':
|
|
343
|
+
const node = issueNode(issue);
|
|
344
|
+
if (node === undefined) {
|
|
345
|
+
return notOk(
|
|
346
|
+
issueConflict(
|
|
347
|
+
'unsupportedOperation',
|
|
348
|
+
`Issue carries neither an expected nor an actual node: ${issue.path.join('/')}`,
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
switch (node.nodeKind) {
|
|
353
|
+
case RelationalSchemaNodeKind.table:
|
|
354
|
+
return mapTableIssue(issue);
|
|
355
|
+
case RelationalSchemaNodeKind.column:
|
|
356
|
+
return mapColumnIssue(issue, ctx);
|
|
357
|
+
case RelationalSchemaNodeKind.index:
|
|
358
|
+
return mapIndexIssue(issue);
|
|
359
|
+
case RelationalSchemaNodeKind.columnDefault:
|
|
360
|
+
case RelationalSchemaNodeKind.primaryKey:
|
|
361
|
+
case RelationalSchemaNodeKind.foreignKey:
|
|
362
|
+
case RelationalSchemaNodeKind.unique:
|
|
363
|
+
return notOk(issueConflict(absorbedConflictKind(node.nodeKind), issue.path.join('/')));
|
|
364
|
+
case RelationalSchemaNodeKind.check:
|
|
562
365
|
return notOk(
|
|
563
366
|
issueConflict(
|
|
564
367
|
'unsupportedOperation',
|
|
565
|
-
|
|
368
|
+
`SQLite does not support CHECK constraint DDL: ${issue.path.join('/')}`,
|
|
566
369
|
),
|
|
567
370
|
);
|
|
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
371
|
default:
|
|
586
|
-
return notOk(
|
|
587
|
-
issueConflict(
|
|
588
|
-
'unsupportedOperation',
|
|
589
|
-
`Unhandled issue kind: ${(issue as SchemaIssue).kind}`,
|
|
590
|
-
),
|
|
591
|
-
);
|
|
372
|
+
return notOk(issueConflict('unsupportedOperation', `Unhandled node kind: ${node.nodeKind}`));
|
|
592
373
|
}
|
|
593
374
|
}
|
|
594
375
|
|
|
@@ -630,20 +411,35 @@ function classifyCall(call: SqliteOpFactoryCall): CallCategory | null {
|
|
|
630
411
|
// Top-level planIssues
|
|
631
412
|
// ============================================================================
|
|
632
413
|
|
|
414
|
+
export interface IssuePlannerOptions {
|
|
415
|
+
readonly issues: readonly SchemaDiffIssue[];
|
|
416
|
+
/** The desired ("end") tree — resolved leaf values, incl. `codecRef`. */
|
|
417
|
+
readonly expected?: SqlSchemaIR;
|
|
418
|
+
/** The live ("start") tree. */
|
|
419
|
+
readonly actual?: SqlSchemaIR;
|
|
420
|
+
readonly policy?: MigrationOperationPolicy;
|
|
421
|
+
readonly frameworkComponents?: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
|
|
422
|
+
readonly strategies?: readonly CallMigrationStrategy[];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export interface IssuePlannerValue {
|
|
426
|
+
readonly calls: readonly SqliteOpFactoryCall[];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const DEFAULT_POLICY: MigrationOperationPolicy = {
|
|
430
|
+
allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'],
|
|
431
|
+
};
|
|
432
|
+
|
|
633
433
|
export function planIssues(
|
|
634
434
|
options: IssuePlannerOptions,
|
|
635
435
|
): Result<IssuePlannerValue, readonly SqlPlannerConflict[]> {
|
|
636
436
|
const policyProvided = options.policy !== undefined;
|
|
637
437
|
const policy = options.policy ?? DEFAULT_POLICY;
|
|
638
|
-
const schema = options.schema ?? emptySchemaIR();
|
|
639
438
|
const frameworkComponents = options.frameworkComponents ?? [];
|
|
640
439
|
|
|
641
440
|
const context: StrategyContext = {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
codecHooks: options.codecHooks,
|
|
645
|
-
storageTypes: options.storageTypes,
|
|
646
|
-
schema,
|
|
441
|
+
expected: options.expected ?? emptySchemaIR(),
|
|
442
|
+
actual: options.actual ?? emptySchemaIR(),
|
|
647
443
|
policy,
|
|
648
444
|
frameworkComponents,
|
|
649
445
|
};
|
|
@@ -666,19 +462,18 @@ export function planIssues(
|
|
|
666
462
|
}
|
|
667
463
|
}
|
|
668
464
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
});
|
|
465
|
+
// Dependency-graph order: a topological sort over the issues' `dependsOn`
|
|
466
|
+
// cross-links and containment edges (edge direction from the ordering law's
|
|
467
|
+
// presence rule), path-tiebroken for determinism. `classifyCall` bucketing
|
|
468
|
+
// downstream fixes the coarse create/recreate/drop sequence; this settles
|
|
469
|
+
// the order within each bucket.
|
|
470
|
+
const sorted = orderIssuesByDependencies(remaining);
|
|
676
471
|
|
|
677
472
|
const defaultCalls: SqliteOpFactoryCall[] = [];
|
|
678
473
|
const conflicts: SqlPlannerConflict[] = [];
|
|
679
474
|
|
|
680
475
|
for (const issue of sorted) {
|
|
681
|
-
const result =
|
|
476
|
+
const result = mapNodeIssueToCall(issue, context);
|
|
682
477
|
if (result.ok) {
|
|
683
478
|
defaultCalls.push(...result.value);
|
|
684
479
|
} else {
|
|
@@ -732,3 +527,43 @@ export function planIssues(
|
|
|
732
527
|
|
|
733
528
|
return ok({ calls });
|
|
734
529
|
}
|
|
530
|
+
|
|
531
|
+
function emptySchemaIR(): SqlSchemaIR {
|
|
532
|
+
return new SqlSchemaIR({ tables: {} });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function conflictForDisallowedCall(
|
|
536
|
+
call: SqliteOpFactoryCall,
|
|
537
|
+
allowed: readonly string[],
|
|
538
|
+
): SqlPlannerConflict {
|
|
539
|
+
const summary = `Operation "${call.label}" requires class "${call.operationClass}", but policy allows only: ${allowed.join(', ')}`;
|
|
540
|
+
const location = locationForCall(call);
|
|
541
|
+
return {
|
|
542
|
+
kind: conflictKindForCall(call),
|
|
543
|
+
summary,
|
|
544
|
+
why: 'Use `migration new` to author a custom migration for this change.',
|
|
545
|
+
...(location ? { location } : {}),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function conflictKindForCall(call: SqliteOpFactoryCall): SqlPlannerConflict['kind'] {
|
|
550
|
+
switch (call.factoryName) {
|
|
551
|
+
case 'createIndex':
|
|
552
|
+
case 'dropIndex':
|
|
553
|
+
return 'indexIncompatible';
|
|
554
|
+
default:
|
|
555
|
+
return 'missingButNonAdditive';
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function locationForCall(call: SqliteOpFactoryCall): SqlPlannerConflictLocation | undefined {
|
|
560
|
+
const location: { entityKind?: string; entityName?: string; column?: string; index?: string } =
|
|
561
|
+
{};
|
|
562
|
+
if ('tableName' in call) {
|
|
563
|
+
location.entityKind = 'table';
|
|
564
|
+
location.entityName = call.tableName;
|
|
565
|
+
}
|
|
566
|
+
if ('columnName' in call) location.column = call.columnName;
|
|
567
|
+
if ('indexName' in call) location.index = call.indexName;
|
|
568
|
+
return Object.keys(location).length > 0 ? (location as SqlPlannerConflictLocation) : undefined;
|
|
569
|
+
}
|