@zmdb/migrations 1.0.0-beta.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/LICENSE +674 -0
- package/README.md +36 -0
- package/dist/declarations/emit.d.ts +23 -0
- package/dist/declarations/emit.d.ts.map +1 -0
- package/dist/declarations/emit.js +581 -0
- package/dist/declarations/emit.js.map +1 -0
- package/dist/declarations/index.d.ts +3 -0
- package/dist/declarations/index.d.ts.map +1 -0
- package/dist/declarations/index.js +3 -0
- package/dist/declarations/index.js.map +1 -0
- package/dist/declarations/tagged-property.d.ts +33 -0
- package/dist/declarations/tagged-property.d.ts.map +1 -0
- package/dist/declarations/tagged-property.js +148 -0
- package/dist/declarations/tagged-property.js.map +1 -0
- package/dist/embedded.d.ts +25 -0
- package/dist/embedded.d.ts.map +1 -0
- package/dist/embedded.js +133 -0
- package/dist/embedded.js.map +1 -0
- package/dist/file-io.d.ts +15 -0
- package/dist/file-io.d.ts.map +1 -0
- package/dist/file-io.js +92 -0
- package/dist/file-io.js.map +1 -0
- package/dist/files.d.ts +11 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +10 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +350 -0
- package/dist/index.js.map +1 -0
- package/dist/introspect/common.d.ts +26 -0
- package/dist/introspect/common.d.ts.map +1 -0
- package/dist/introspect/common.js +155 -0
- package/dist/introspect/common.js.map +1 -0
- package/dist/introspect/drift.d.ts +45 -0
- package/dist/introspect/drift.d.ts.map +1 -0
- package/dist/introspect/drift.js +84 -0
- package/dist/introspect/drift.js.map +1 -0
- package/dist/introspect/index.d.ts +8 -0
- package/dist/introspect/index.d.ts.map +1 -0
- package/dist/introspect/index.js +10 -0
- package/dist/introspect/index.js.map +1 -0
- package/dist/operations/check.d.ts +16 -0
- package/dist/operations/check.d.ts.map +1 -0
- package/dist/operations/check.js +236 -0
- package/dist/operations/check.js.map +1 -0
- package/dist/operations/embed.d.ts +23 -0
- package/dist/operations/embed.d.ts.map +1 -0
- package/dist/operations/embed.js +52 -0
- package/dist/operations/embed.js.map +1 -0
- package/dist/operations/export.d.ts +9 -0
- package/dist/operations/export.d.ts.map +1 -0
- package/dist/operations/export.js +15 -0
- package/dist/operations/export.js.map +1 -0
- package/dist/operations/generate.d.ts +17 -0
- package/dist/operations/generate.d.ts.map +1 -0
- package/dist/operations/generate.js +115 -0
- package/dist/operations/generate.js.map +1 -0
- package/dist/operations/migrate.d.ts +23 -0
- package/dist/operations/migrate.d.ts.map +1 -0
- package/dist/operations/migrate.js +67 -0
- package/dist/operations/migrate.js.map +1 -0
- package/dist/operations/pull.d.ts +40 -0
- package/dist/operations/pull.d.ts.map +1 -0
- package/dist/operations/pull.js +100 -0
- package/dist/operations/pull.js.map +1 -0
- package/dist/operations/push.d.ts +20 -0
- package/dist/operations/push.d.ts.map +1 -0
- package/dist/operations/push.js +82 -0
- package/dist/operations/push.js.map +1 -0
- package/dist/operations/upgrade.d.ts +10 -0
- package/dist/operations/upgrade.d.ts.map +1 -0
- package/dist/operations/upgrade.js +38 -0
- package/dist/operations/upgrade.js.map +1 -0
- package/dist/project.d.ts +46 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +42 -0
- package/dist/project.js.map +1 -0
- package/dist/runner.d.ts +59 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +163 -0
- package/dist/runner.js.map +1 -0
- package/dist/testing/official-dialects.fixture.d.ts +16 -0
- package/dist/testing/official-dialects.fixture.d.ts.map +1 -0
- package/dist/testing/official-dialects.fixture.js +21 -0
- package/dist/testing/official-dialects.fixture.js.map +1 -0
- package/dist/testing.d.ts +8 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +27 -0
- package/dist/testing.js.map +1 -0
- package/package.json +81 -0
- package/src/declarations/emit.ts +705 -0
- package/src/declarations/index.ts +13 -0
- package/src/declarations/tagged-property.ts +163 -0
- package/src/embedded.ts +192 -0
- package/src/file-io.ts +114 -0
- package/src/files.ts +34 -0
- package/src/index.ts +502 -0
- package/src/introspect/__fixtures__/mysql-8.4.11.json +371 -0
- package/src/introspect/common.ts +203 -0
- package/src/introspect/drift.ts +157 -0
- package/src/introspect/index.ts +33 -0
- package/src/operations/check.ts +309 -0
- package/src/operations/embed.ts +87 -0
- package/src/operations/export.ts +21 -0
- package/src/operations/generate.ts +144 -0
- package/src/operations/migrate.ts +110 -0
- package/src/operations/pull.ts +160 -0
- package/src/operations/push.ts +98 -0
- package/src/operations/upgrade.ts +56 -0
- package/src/project.ts +93 -0
- package/src/runner.ts +260 -0
- package/src/testing/official-dialects.fixture.ts +24 -0
- package/src/testing.ts +32 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// Migrations (#41–#44): snapshot a set of schemas, diff two snapshots into
|
|
2
|
+
// change ops, and emit up/down DDL per dialect. Deterministic throughout —
|
|
3
|
+
// tables and columns are sorted by name so a snapshot is byte-stable.
|
|
4
|
+
import {
|
|
5
|
+
UnsupportedFeatureError,
|
|
6
|
+
dialectName,
|
|
7
|
+
type ChangeOp,
|
|
8
|
+
type ColumnSnapshot,
|
|
9
|
+
type ExtensionSnapshot,
|
|
10
|
+
type ExtensionType,
|
|
11
|
+
type ForeignKeySnapshot,
|
|
12
|
+
type MigrationDialect,
|
|
13
|
+
type ReferentialAction,
|
|
14
|
+
type SchemaSnapshot,
|
|
15
|
+
type SchemaObjectOperation,
|
|
16
|
+
type SqlDialect,
|
|
17
|
+
type TableOptions,
|
|
18
|
+
type TableSnapshot,
|
|
19
|
+
} from '@zmdb/sql';
|
|
20
|
+
import { ddlType, type DdlSqlType } from '@zmdb/sql/schema-objects';
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
type ChangeOp,
|
|
24
|
+
type ColumnSnapshot,
|
|
25
|
+
type ExtensionSnapshot,
|
|
26
|
+
type ExtensionType,
|
|
27
|
+
type ForeignKeySnapshot,
|
|
28
|
+
type ReferentialAction,
|
|
29
|
+
type SchemaSnapshot,
|
|
30
|
+
type TableOptions,
|
|
31
|
+
type TableSnapshot,
|
|
32
|
+
} from '@zmdb/sql';
|
|
33
|
+
export { type MigrationDialect, type SchemaObjectOperation } from '@zmdb/sql';
|
|
34
|
+
export { ddlType };
|
|
35
|
+
export type { DdlSqlType };
|
|
36
|
+
export { down, downTo, driverMigrationConnection, ensureVersionTable, rollbackTo, status, up } from './runner.js';
|
|
37
|
+
export type {
|
|
38
|
+
AppliedMigration,
|
|
39
|
+
Migration,
|
|
40
|
+
MigrationConnection,
|
|
41
|
+
MigrationDriver,
|
|
42
|
+
MigrationRunOptions,
|
|
43
|
+
MigrationStatus,
|
|
44
|
+
MigrationTableOptions,
|
|
45
|
+
} from './runner.js';
|
|
46
|
+
export type { MigrationProject } from './project.js';
|
|
47
|
+
|
|
48
|
+
function migrationsOf(target: SqlDialect | MigrationDialect): MigrationDialect {
|
|
49
|
+
return 'migrations' in target ? target.migrations : target;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function emitSchemaObject(
|
|
53
|
+
operation: SchemaObjectOperation,
|
|
54
|
+
target: SqlDialect | MigrationDialect,
|
|
55
|
+
): readonly string[] {
|
|
56
|
+
return migrationsOf(target).emitSchemaObject(operation);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The slice of a schema a snapshot reads.
|
|
61
|
+
*
|
|
62
|
+
* Declared structurally so callers can supply the required snapshot data.
|
|
63
|
+
* `CoreSchema` satisfies this shape. Migrations also depends on
|
|
64
|
+
* `@zmdb/schema/naming` for canonical names.
|
|
65
|
+
*/
|
|
66
|
+
export interface SnapshotableSchema {
|
|
67
|
+
readonly table: string;
|
|
68
|
+
readonly primaryKey: readonly string[];
|
|
69
|
+
readonly columns: Readonly<
|
|
70
|
+
Record<
|
|
71
|
+
string,
|
|
72
|
+
{
|
|
73
|
+
readonly type: string | ExtensionType;
|
|
74
|
+
readonly flags: {
|
|
75
|
+
readonly nullable: boolean;
|
|
76
|
+
readonly primaryKey?: boolean | undefined;
|
|
77
|
+
readonly length?: number | undefined;
|
|
78
|
+
readonly unique?: boolean | undefined;
|
|
79
|
+
};
|
|
80
|
+
readonly references?: { readonly target: string };
|
|
81
|
+
}
|
|
82
|
+
>
|
|
83
|
+
>;
|
|
84
|
+
readonly ir?: {
|
|
85
|
+
readonly table: string;
|
|
86
|
+
readonly physicalTable?: string;
|
|
87
|
+
readonly columns: readonly {
|
|
88
|
+
readonly name: string;
|
|
89
|
+
readonly physicalName?: string;
|
|
90
|
+
readonly references?: string;
|
|
91
|
+
readonly onDelete?: ReferentialAction;
|
|
92
|
+
readonly onUpdate?: ReferentialAction;
|
|
93
|
+
}[];
|
|
94
|
+
readonly foreignKeys?: readonly {
|
|
95
|
+
readonly columns: readonly string[];
|
|
96
|
+
readonly targetTable: string;
|
|
97
|
+
readonly targetColumns: readonly string[];
|
|
98
|
+
}[];
|
|
99
|
+
readonly tableOptions?: TableOptions;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function generatedForeignKeyName(table: string, columns: readonly string[]): string {
|
|
104
|
+
const name = `${table}_${columns.join('_')}_fkey`;
|
|
105
|
+
if (name.length > 63) {
|
|
106
|
+
throw new TypeError(
|
|
107
|
+
`generated foreign key name "${name}" is ${name.length} characters long; PostgreSQL's limit is 63, ` +
|
|
108
|
+
'so give the table or columns shorter names',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return name;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function referencedTarget(target: string): { readonly table: string; readonly column: string } {
|
|
115
|
+
const separator = target.indexOf('.');
|
|
116
|
+
if (separator <= 0 || separator !== target.lastIndexOf('.') || separator === target.length - 1) {
|
|
117
|
+
throw new TypeError(`foreign key target "${target}" must be written as "table.column"`);
|
|
118
|
+
}
|
|
119
|
+
return { table: target.slice(0, separator), column: target.slice(separator + 1) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function physicalColumn(schema: SnapshotableSchema, declared: string): string {
|
|
123
|
+
const column = schema.ir?.columns.find(candidate => candidate.name === declared);
|
|
124
|
+
return column?.physicalName ?? declared;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function physicalTarget(
|
|
128
|
+
target: string,
|
|
129
|
+
schemasByDeclaredTable: ReadonlyMap<string, SnapshotableSchema>,
|
|
130
|
+
): { readonly table: string; readonly column: string } {
|
|
131
|
+
const declared = referencedTarget(target);
|
|
132
|
+
const schema = schemasByDeclaredTable.get(declared.table);
|
|
133
|
+
return schema === undefined
|
|
134
|
+
? declared
|
|
135
|
+
: {
|
|
136
|
+
table: schema.table,
|
|
137
|
+
column: physicalColumn(schema, declared.column),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function physicalTableOptions(schema: SnapshotableSchema): TableOptions | undefined {
|
|
142
|
+
const options = schema.ir?.tableOptions;
|
|
143
|
+
if (options === undefined) return undefined;
|
|
144
|
+
return {
|
|
145
|
+
...(options.shardKey === undefined
|
|
146
|
+
? {}
|
|
147
|
+
: { shardKey: options.shardKey.map(column => physicalColumn(schema, column)) }),
|
|
148
|
+
...(options.sortKey === undefined
|
|
149
|
+
? {}
|
|
150
|
+
: { sortKey: options.sortKey.map(column => physicalColumn(schema, column)) }),
|
|
151
|
+
...(options.rowstore === undefined ? {} : { rowstore: options.rowstore }),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function snapshot(schemas: readonly SnapshotableSchema[]): SchemaSnapshot {
|
|
156
|
+
const schemasByDeclaredTable = new Map(schemas.map(schema => [schema.ir?.table ?? schema.table, schema] as const));
|
|
157
|
+
const extensions = new Map<string, ExtensionSnapshot>();
|
|
158
|
+
const tables: TableSnapshot[] = schemas
|
|
159
|
+
.map(schema => {
|
|
160
|
+
const irColumns = new Map(
|
|
161
|
+
schema.ir?.columns.flatMap(column => [
|
|
162
|
+
[column.name, column] as const,
|
|
163
|
+
[column.physicalName ?? column.name, column] as const,
|
|
164
|
+
]) ?? [],
|
|
165
|
+
);
|
|
166
|
+
const columns: ColumnSnapshot[] = Object.entries(schema.columns)
|
|
167
|
+
.map(([name, meta]) => {
|
|
168
|
+
if (typeof meta.type !== 'string') {
|
|
169
|
+
extensions.set(meta.type.extension, { name: meta.type.extension });
|
|
170
|
+
}
|
|
171
|
+
const ir = irColumns.get(name);
|
|
172
|
+
return {
|
|
173
|
+
name: ir?.physicalName ?? name,
|
|
174
|
+
type: meta.type,
|
|
175
|
+
nullable: meta.flags.nullable,
|
|
176
|
+
primaryKey: meta.flags.primaryKey === true,
|
|
177
|
+
// Written only when there is one, so columns that are not `varchar` do not
|
|
178
|
+
// acquire a meaningless field in the version-1 snapshot.
|
|
179
|
+
...(meta.flags.length === undefined ? {} : { length: meta.flags.length }),
|
|
180
|
+
...(meta.flags.unique === true ? { unique: true } : {}),
|
|
181
|
+
};
|
|
182
|
+
})
|
|
183
|
+
.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
184
|
+
const foreignKeys: ForeignKeySnapshot[] = [];
|
|
185
|
+
for (const [name, meta] of Object.entries(schema.columns)) {
|
|
186
|
+
if (meta.references === undefined) continue;
|
|
187
|
+
const target = physicalTarget(meta.references.target, schemasByDeclaredTable);
|
|
188
|
+
const ir = irColumns.get(name);
|
|
189
|
+
const localName = ir?.physicalName ?? name;
|
|
190
|
+
foreignKeys.push({
|
|
191
|
+
name: generatedForeignKeyName(schema.table, [localName]),
|
|
192
|
+
columns: [localName],
|
|
193
|
+
targetTable: target.table,
|
|
194
|
+
targetColumns: [target.column],
|
|
195
|
+
onDelete: ir?.onDelete ?? 'no action',
|
|
196
|
+
onUpdate: ir?.onUpdate ?? 'no action',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
for (const foreignKey of schema.ir?.foreignKeys ?? []) {
|
|
200
|
+
const localColumns = foreignKey.columns.map(column => physicalColumn(schema, column));
|
|
201
|
+
const targetSchema = schemasByDeclaredTable.get(foreignKey.targetTable);
|
|
202
|
+
foreignKeys.push({
|
|
203
|
+
name: generatedForeignKeyName(schema.table, localColumns),
|
|
204
|
+
columns: localColumns,
|
|
205
|
+
targetTable: targetSchema?.table ?? foreignKey.targetTable,
|
|
206
|
+
targetColumns:
|
|
207
|
+
targetSchema === undefined
|
|
208
|
+
? foreignKey.targetColumns
|
|
209
|
+
: foreignKey.targetColumns.map(column => physicalColumn(targetSchema, column)),
|
|
210
|
+
onDelete: 'no action',
|
|
211
|
+
onUpdate: 'no action',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const tableOptions = physicalTableOptions(schema);
|
|
215
|
+
return {
|
|
216
|
+
name: schema.table,
|
|
217
|
+
columns,
|
|
218
|
+
primaryKey: schema.primaryKey.map(column => physicalColumn(schema, column)),
|
|
219
|
+
foreignKeys: foreignKeys.toSorted((a, b) => a.name.localeCompare(b.name)),
|
|
220
|
+
...(tableOptions === undefined ? {} : { tableOptions }),
|
|
221
|
+
};
|
|
222
|
+
})
|
|
223
|
+
.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
version: 1,
|
|
227
|
+
tables,
|
|
228
|
+
extensions: [...extensions.values()].toSorted((a, b) => a.name.localeCompare(b.name)),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function sameType(previous: string | ExtensionType, next: string | ExtensionType): boolean {
|
|
233
|
+
if (typeof previous === 'string' || typeof next === 'string') return previous === next;
|
|
234
|
+
if (previous.extension !== next.extension || previous.name !== next.name) return false;
|
|
235
|
+
const previousArgs = previous.args ?? [];
|
|
236
|
+
const nextArgs = next.args ?? [];
|
|
237
|
+
return previousArgs.length === nextArgs.length && previousArgs.every((value, index) => value === nextArgs[index]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export const CHANGE_PHASES = [
|
|
241
|
+
['create_extension'],
|
|
242
|
+
['drop_foreign_key'],
|
|
243
|
+
['drop_table'],
|
|
244
|
+
['create_table', 'add_column'],
|
|
245
|
+
['alter_column_type', 'alter_primary_key'],
|
|
246
|
+
['drop_column'],
|
|
247
|
+
['add_foreign_key'],
|
|
248
|
+
] as const satisfies readonly (readonly ChangeOp['kind'][])[];
|
|
249
|
+
|
|
250
|
+
const CHANGE_PHASE = new Map<ChangeOp['kind'], number>(
|
|
251
|
+
CHANGE_PHASES.flatMap((kinds, phase) => kinds.map(kind => [kind, phase] as const)),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
function orderChanges(ops: readonly ChangeOp[]): readonly ChangeOp[] {
|
|
255
|
+
const phaseOf = (kind: ChangeOp['kind']): number => {
|
|
256
|
+
const phase = CHANGE_PHASE.get(kind);
|
|
257
|
+
if (phase === undefined) throw new Error(`change kind "${kind}" has no migration phase`);
|
|
258
|
+
return phase;
|
|
259
|
+
};
|
|
260
|
+
return ops.toSorted((left, right) => phaseOf(left.kind) - phaseOf(right.kind));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function sameSequence(previous: readonly string[], next: readonly string[]): boolean {
|
|
264
|
+
return previous.length === next.length && previous.every((value, index) => value === next[index]);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function sameTableOptions(previous: TableOptions | undefined, next: TableOptions | undefined): boolean {
|
|
268
|
+
if (previous?.rowstore !== next?.rowstore) return false;
|
|
269
|
+
return (
|
|
270
|
+
sameSequence(previous?.shardKey ?? [], next?.shardKey ?? []) &&
|
|
271
|
+
sameSequence(previous?.sortKey ?? [], next?.sortKey ?? [])
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function foreignKeysOf(table: TableSnapshot): readonly ForeignKeySnapshot[] {
|
|
276
|
+
// Stored snapshots written before referential actions landed have no field.
|
|
277
|
+
// Reading them as [] lets the first post-upgrade diff add the declared constraints
|
|
278
|
+
// instead of failing while loading the migration history.
|
|
279
|
+
return table.foreignKeys ?? [];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function foreignKeyIdentity(foreignKey: ForeignKeySnapshot): string {
|
|
283
|
+
return JSON.stringify([foreignKey.columns, foreignKey.targetTable, foreignKey.targetColumns]);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function sameForeignKeyActions(previous: ForeignKeySnapshot, next: ForeignKeySnapshot): boolean {
|
|
287
|
+
return previous.onDelete === next.onDelete && previous.onUpdate === next.onUpdate;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function diffForeignKeys(
|
|
291
|
+
table: string,
|
|
292
|
+
previous: readonly ForeignKeySnapshot[],
|
|
293
|
+
next: readonly ForeignKeySnapshot[],
|
|
294
|
+
ops: ChangeOp[],
|
|
295
|
+
): void {
|
|
296
|
+
const unmatchedPrevious = new Map(previous.map(foreignKey => [foreignKeyIdentity(foreignKey), foreignKey]));
|
|
297
|
+
|
|
298
|
+
for (const foreignKey of next) {
|
|
299
|
+
const identity = foreignKeyIdentity(foreignKey);
|
|
300
|
+
const before = unmatchedPrevious.get(identity);
|
|
301
|
+
if (before === undefined) {
|
|
302
|
+
ops.push({ kind: 'add_foreign_key', table, fk: foreignKey });
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
unmatchedPrevious.delete(identity);
|
|
306
|
+
if (sameForeignKeyActions(before, foreignKey)) continue;
|
|
307
|
+
ops.push({ kind: 'drop_foreign_key', table, name: before.name });
|
|
308
|
+
ops.push({ kind: 'add_foreign_key', table, fk: foreignKey });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
for (const foreignKey of unmatchedPrevious.values()) {
|
|
312
|
+
ops.push({ kind: 'drop_foreign_key', table, name: foreignKey.name });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function createdTablesInOrder(
|
|
317
|
+
previous: ReadonlyMap<string, TableSnapshot>,
|
|
318
|
+
next: readonly TableSnapshot[],
|
|
319
|
+
dialect: SqlDialect | undefined,
|
|
320
|
+
): readonly TableSnapshot[] {
|
|
321
|
+
const created = new Map(next.filter(table => !previous.has(table.name)).map(table => [table.name, table]));
|
|
322
|
+
const dependencies = new Map<string, Set<string>>();
|
|
323
|
+
const dependants = new Map<string, Set<string>>();
|
|
324
|
+
|
|
325
|
+
for (const table of created.values()) {
|
|
326
|
+
const targets = new Set(
|
|
327
|
+
foreignKeysOf(table)
|
|
328
|
+
.map(foreignKey => foreignKey.targetTable)
|
|
329
|
+
.filter(target => target !== table.name && created.has(target)),
|
|
330
|
+
);
|
|
331
|
+
dependencies.set(table.name, targets);
|
|
332
|
+
for (const target of targets) {
|
|
333
|
+
const children = dependants.get(target) ?? new Set<string>();
|
|
334
|
+
children.add(table.name);
|
|
335
|
+
dependants.set(target, children);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const ready = [...created.keys()]
|
|
340
|
+
.filter(name => dependencies.get(name)?.size === 0)
|
|
341
|
+
.toSorted((left, right) => left.localeCompare(right));
|
|
342
|
+
const ordered: TableSnapshot[] = [];
|
|
343
|
+
while (ready.length > 0) {
|
|
344
|
+
const name = ready.shift();
|
|
345
|
+
if (name === undefined) break;
|
|
346
|
+
const table = created.get(name);
|
|
347
|
+
if (table !== undefined) ordered.push(table);
|
|
348
|
+
for (const child of dependants.get(name) ?? []) {
|
|
349
|
+
const remaining = dependencies.get(child);
|
|
350
|
+
remaining?.delete(name);
|
|
351
|
+
if (remaining?.size === 0) {
|
|
352
|
+
ready.push(child);
|
|
353
|
+
ready.sort((left, right) => left.localeCompare(right));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (ordered.length === created.size) return ordered;
|
|
359
|
+
const orderedNames = new Set(ordered.map(table => table.name));
|
|
360
|
+
const cyclic = [...created.keys()]
|
|
361
|
+
.filter(name => !orderedNames.has(name))
|
|
362
|
+
.toSorted((left, right) => left.localeCompare(right));
|
|
363
|
+
if (dialect?.migrations.foreignKeyMode === 'inline') {
|
|
364
|
+
const databaseName = dialectName(dialect);
|
|
365
|
+
throw new UnsupportedFeatureError(
|
|
366
|
+
`creating mutually-referencing tables ${cyclic.map(tableName => `"${tableName}"`).join(', ')}`,
|
|
367
|
+
databaseName,
|
|
368
|
+
`${databaseName} cannot create the mutually-referencing tables ${cyclic
|
|
369
|
+
.map(tableName => `"${tableName}"`)
|
|
370
|
+
.join(' and ')}; ` +
|
|
371
|
+
'their foreign keys must be inline, so neither table can be created before the other — ' +
|
|
372
|
+
'use a hand-written table rebuild or remove the cycle',
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return [...ordered, ...cyclic.map(name => created.get(name)).filter(table => table !== undefined)];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export interface DiffOptions {
|
|
379
|
+
readonly dialect?: SqlDialect | undefined;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export interface MigrationPlan {
|
|
383
|
+
readonly operations: readonly ChangeOp[];
|
|
384
|
+
readonly up: readonly string[];
|
|
385
|
+
readonly down: readonly string[];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export interface MigrationPlanDialect {
|
|
389
|
+
readonly dialect: SqlDialect;
|
|
390
|
+
emitUp(operation: ChangeOp): string;
|
|
391
|
+
emitDown(operation: ChangeOp): string;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function diff(prev: SchemaSnapshot, next: SchemaSnapshot, options: DiffOptions = {}): readonly ChangeOp[] {
|
|
395
|
+
const selectedDialect = options.dialect;
|
|
396
|
+
const ops: ChangeOp[] = [];
|
|
397
|
+
const prevTables = new Map(prev.tables.map(t => [t.name, t]));
|
|
398
|
+
const nextTables = new Map(next.tables.map(t => [t.name, t]));
|
|
399
|
+
const prevExtensions = new Set(prev.extensions.map(extension => extension.name));
|
|
400
|
+
|
|
401
|
+
for (const extension of next.extensions) {
|
|
402
|
+
if (!prevExtensions.has(extension.name)) {
|
|
403
|
+
ops.push({
|
|
404
|
+
kind: 'create_extension',
|
|
405
|
+
name: extension.name,
|
|
406
|
+
...(extension.schema === undefined ? {} : { schema: extension.schema }),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Dropped tables.
|
|
412
|
+
for (const t of prev.tables) {
|
|
413
|
+
if (!nextTables.has(t.name)) ops.push({ kind: 'drop_table', table: t.name });
|
|
414
|
+
}
|
|
415
|
+
// Column-level and foreign-key diffs on tables that already exist.
|
|
416
|
+
for (const t of next.tables) {
|
|
417
|
+
const before = prevTables.get(t.name);
|
|
418
|
+
if (!before) continue;
|
|
419
|
+
if (!sameTableOptions(before.tableOptions, t.tableOptions)) {
|
|
420
|
+
const target = selectedDialect === undefined ? 'the selected migration dialect' : dialectName(selectedDialect);
|
|
421
|
+
throw new UnsupportedFeatureError(
|
|
422
|
+
'table options change',
|
|
423
|
+
target,
|
|
424
|
+
`${target} cannot alter the shard key, sort key, or rowstore setting of "${t.name}"; ` +
|
|
425
|
+
'create a replacement table and copy the data',
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
const beforeCols = new Map(before.columns.map(c => [c.name, c]));
|
|
429
|
+
const afterCols = new Map(t.columns.map(c => [c.name, c]));
|
|
430
|
+
for (const c of before.columns) {
|
|
431
|
+
if (!afterCols.has(c.name)) ops.push({ kind: 'drop_column', table: t.name, column: c.name });
|
|
432
|
+
}
|
|
433
|
+
for (const c of t.columns) {
|
|
434
|
+
const bc = beforeCols.get(c.name);
|
|
435
|
+
if (!bc) {
|
|
436
|
+
ops.push({ kind: 'add_column', table: t.name, column: c });
|
|
437
|
+
} else if (!sameType(bc.type, c.type)) {
|
|
438
|
+
ops.push({
|
|
439
|
+
kind: 'alter_column_type',
|
|
440
|
+
table: t.name,
|
|
441
|
+
column: c.name,
|
|
442
|
+
from: bc.type,
|
|
443
|
+
to: c.type,
|
|
444
|
+
fromNullable: bc.nullable,
|
|
445
|
+
toNullable: c.nullable,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (!sameSequence(before.primaryKey, t.primaryKey)) {
|
|
450
|
+
ops.push({ kind: 'alter_primary_key', table: t.name, from: before.primaryKey, to: t.primaryKey });
|
|
451
|
+
}
|
|
452
|
+
diffForeignKeys(t.name, foreignKeysOf(before), foreignKeysOf(t), ops);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Created tables are target-before-child for inline constraints. Deferred
|
|
456
|
+
// strategies get the same deterministic order before their ALTER statements.
|
|
457
|
+
for (const table of createdTablesInOrder(prevTables, next.tables, selectedDialect)) {
|
|
458
|
+
const foreignKeys = foreignKeysOf(table);
|
|
459
|
+
ops.push({
|
|
460
|
+
kind: 'create_table',
|
|
461
|
+
table: table.name,
|
|
462
|
+
columns: table.columns,
|
|
463
|
+
primaryKey: table.primaryKey,
|
|
464
|
+
foreignKeys,
|
|
465
|
+
...(table.tableOptions === undefined ? {} : { tableOptions: table.tableOptions }),
|
|
466
|
+
});
|
|
467
|
+
if (selectedDialect?.migrations.foreignKeyMode !== 'inline') {
|
|
468
|
+
for (const foreignKey of foreignKeys) {
|
|
469
|
+
ops.push({ kind: 'add_foreign_key', table: table.name, fk: foreignKey });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const operations = orderChanges(ops);
|
|
474
|
+
selectedDialect?.migrations.validatePlan({
|
|
475
|
+
before: prev,
|
|
476
|
+
after: next,
|
|
477
|
+
operations,
|
|
478
|
+
});
|
|
479
|
+
return operations;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function planMigration(
|
|
483
|
+
previous: SchemaSnapshot,
|
|
484
|
+
next: SchemaSnapshot,
|
|
485
|
+
database: MigrationPlanDialect,
|
|
486
|
+
): MigrationPlan {
|
|
487
|
+
const operations = diff(previous, next, { dialect: database.dialect });
|
|
488
|
+
return {
|
|
489
|
+
operations,
|
|
490
|
+
up: operations.map(operation => database.emitUp(operation)),
|
|
491
|
+
down: operations.toReversed().map(operation => database.emitDown(operation)),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Database packages own every DDL spelling; this package only delegates the planned operation.
|
|
496
|
+
export function emitUp(operation: ChangeOp, target: SqlDialect | MigrationDialect): string {
|
|
497
|
+
return migrationsOf(target).emitUp(operation);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function emitDown(operation: ChangeOp, target: SqlDialect | MigrationDialect): string {
|
|
501
|
+
return migrationsOf(target).emitDown(operation);
|
|
502
|
+
}
|