@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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChangeOp,
|
|
3
|
+
type ColumnSnapshot,
|
|
4
|
+
type SchemaSnapshot,
|
|
5
|
+
type SqlDialect,
|
|
6
|
+
type TableSnapshot,
|
|
7
|
+
} from '@zmdb/sql';
|
|
8
|
+
|
|
9
|
+
import { diff } from '../index.js';
|
|
10
|
+
import { tableSelected, type CatalogForeignKeySnapshot, type CatalogIndexSnapshot } from './common.js';
|
|
11
|
+
|
|
12
|
+
export interface DriftOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Table-name globs to omit. When absent, the migration ledger is omitted.
|
|
15
|
+
* Supplying the list replaces that default, matching introspection selection.
|
|
16
|
+
*/
|
|
17
|
+
readonly exclude?: readonly string[];
|
|
18
|
+
/** The selected database product whose migration rules compare the snapshots. */
|
|
19
|
+
readonly dialect: SqlDialect;
|
|
20
|
+
/**
|
|
21
|
+
* Database-owned catalog normalization. MySQL-family introspectors set this
|
|
22
|
+
* because their servers may create an undeclared btree index for a foreign key.
|
|
23
|
+
*/
|
|
24
|
+
readonly omitForeignKeySupportIndexes?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DriftReport {
|
|
28
|
+
/** Operations that transform the declared snapshot into the live database shape. */
|
|
29
|
+
readonly onlyInDatabase: readonly ChangeOp[];
|
|
30
|
+
/** Operations that transform the live database snapshot into the declared shape. */
|
|
31
|
+
readonly onlyInDeclarations: readonly ChangeOp[];
|
|
32
|
+
readonly clean: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type DriftColumnSnapshot = ColumnSnapshot & {
|
|
36
|
+
readonly catalogType?: string;
|
|
37
|
+
readonly default?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
interface DriftTableSnapshot extends Omit<TableSnapshot, 'columns' | 'foreignKeys'> {
|
|
41
|
+
readonly columns: readonly DriftColumnSnapshot[];
|
|
42
|
+
readonly foreignKeys?: readonly CatalogForeignKeySnapshot[];
|
|
43
|
+
readonly indexes?: readonly CatalogIndexSnapshot[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface DriftComparableSnapshot extends Omit<SchemaSnapshot, 'tables'> {
|
|
47
|
+
readonly tables: readonly DriftTableSnapshot[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface NormalizedDriftTableSnapshot extends Omit<TableSnapshot, 'foreignKeys'> {
|
|
51
|
+
readonly foreignKeys: readonly CatalogForeignKeySnapshot[];
|
|
52
|
+
readonly indexes?: readonly CatalogIndexSnapshot[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface NormalizedDriftSnapshot extends Omit<SchemaSnapshot, 'tables'> {
|
|
56
|
+
readonly tables: readonly NormalizedDriftTableSnapshot[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type SnapshotRole = 'live' | 'declared';
|
|
60
|
+
|
|
61
|
+
function normalizeColumn(column: DriftColumnSnapshot): ColumnSnapshot {
|
|
62
|
+
return {
|
|
63
|
+
name: column.name,
|
|
64
|
+
type: column.type,
|
|
65
|
+
nullable: column.nullable,
|
|
66
|
+
primaryKey: column.primaryKey,
|
|
67
|
+
...(column.length === undefined ? {} : { length: column.length }),
|
|
68
|
+
...(column.unique === undefined ? {} : { unique: column.unique }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sameColumns(index: CatalogIndexSnapshot, foreignKey: CatalogForeignKeySnapshot): boolean {
|
|
73
|
+
if (index.columns.length !== foreignKey.columns.length) return false;
|
|
74
|
+
return index.columns.every((column, position) => {
|
|
75
|
+
const expected = foreignKey.columns[position];
|
|
76
|
+
return typeof column === 'string' && column === expected;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isMySqlForeignKeySupportingIndex(
|
|
81
|
+
index: CatalogIndexSnapshot,
|
|
82
|
+
foreignKeys: readonly CatalogForeignKeySnapshot[],
|
|
83
|
+
): boolean {
|
|
84
|
+
if (index.unique || index.where !== undefined) return false;
|
|
85
|
+
if (index.method !== undefined && index.method.toLowerCase() !== 'btree') return false;
|
|
86
|
+
|
|
87
|
+
return foreignKeys.some(
|
|
88
|
+
foreignKey =>
|
|
89
|
+
(index.name === foreignKey.name || index.name === `${foreignKey.name}_idx`) && sameColumns(index, foreignKey),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeIndexes(
|
|
94
|
+
table: DriftTableSnapshot,
|
|
95
|
+
role: SnapshotRole,
|
|
96
|
+
omitForeignKeySupportIndexes: boolean,
|
|
97
|
+
): readonly CatalogIndexSnapshot[] | undefined {
|
|
98
|
+
if (table.indexes === undefined) return undefined;
|
|
99
|
+
if (role !== 'live' || !omitForeignKeySupportIndexes) return table.indexes;
|
|
100
|
+
const foreignKeys = table.foreignKeys ?? [];
|
|
101
|
+
return table.indexes.filter(index => !isMySqlForeignKeySupportingIndex(index, foreignKeys));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Internal normalization boundary used before both calls to the migration diff.
|
|
106
|
+
*
|
|
107
|
+
* Exported from the introspection subpath as the vendor-neutral normalization
|
|
108
|
+
* seam used by independently shipped database introspectors.
|
|
109
|
+
*/
|
|
110
|
+
export function normalizeDriftSnapshot(
|
|
111
|
+
snapshot: SchemaSnapshot,
|
|
112
|
+
role: SnapshotRole,
|
|
113
|
+
options: Pick<DriftOptions, 'exclude' | 'omitForeignKeySupportIndexes'> = {},
|
|
114
|
+
): NormalizedDriftSnapshot {
|
|
115
|
+
const comparable: DriftComparableSnapshot = snapshot;
|
|
116
|
+
const selection = options.exclude === undefined ? {} : { exclude: options.exclude };
|
|
117
|
+
const tables = comparable.tables
|
|
118
|
+
.filter(table => tableSelected(table.name, selection))
|
|
119
|
+
.map(table => {
|
|
120
|
+
const indexes = normalizeIndexes(table, role, options.omitForeignKeySupportIndexes ?? false);
|
|
121
|
+
const normalized: NormalizedDriftTableSnapshot = {
|
|
122
|
+
name: table.name,
|
|
123
|
+
// Catalog spellings and default expressions are evidence, not drift:
|
|
124
|
+
// aliases have already collapsed into `type`, and servers rewrite defaults.
|
|
125
|
+
columns: table.columns.map(normalizeColumn),
|
|
126
|
+
primaryKey: table.primaryKey,
|
|
127
|
+
foreignKeys: table.foreignKeys ?? [],
|
|
128
|
+
...(table.tableOptions === undefined ? {} : { tableOptions: table.tableOptions }),
|
|
129
|
+
...(indexes === undefined ? {} : { indexes }),
|
|
130
|
+
};
|
|
131
|
+
return normalized;
|
|
132
|
+
})
|
|
133
|
+
.toSorted((left, right) => left.name.localeCompare(right.name));
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
version: 1,
|
|
137
|
+
tables,
|
|
138
|
+
extensions: snapshot.extensions.toSorted((left, right) => left.name.localeCompare(right.name)),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compare an introspected snapshot and declared snapshot through the migration
|
|
144
|
+
* comparator in both directions.
|
|
145
|
+
*/
|
|
146
|
+
export function detectDrift(live: SchemaSnapshot, declared: SchemaSnapshot, options: DriftOptions): DriftReport {
|
|
147
|
+
const normalizedLive = normalizeDriftSnapshot(live, 'live', options);
|
|
148
|
+
const normalizedDeclared = normalizeDriftSnapshot(declared, 'declared', options);
|
|
149
|
+
const onlyInDatabase = diff(normalizedDeclared, normalizedLive, { dialect: options.dialect });
|
|
150
|
+
const onlyInDeclarations = diff(normalizedLive, normalizedDeclared, { dialect: options.dialect });
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
onlyInDatabase,
|
|
154
|
+
onlyInDeclarations,
|
|
155
|
+
clean: onlyInDatabase.length === 0 && onlyInDeclarations.length === 0,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Introspector, type SqlDialect } from '@zmdb/sql';
|
|
2
|
+
|
|
3
|
+
export { type IntrospectionDriver, type Introspector, type IntrospectOptions } from '@zmdb/sql';
|
|
4
|
+
export { detectDrift, type DriftOptions, type DriftReport } from './drift.js';
|
|
5
|
+
export {
|
|
6
|
+
action,
|
|
7
|
+
CatalogRowError,
|
|
8
|
+
deterministicForeignKeyName,
|
|
9
|
+
flagField,
|
|
10
|
+
integerField,
|
|
11
|
+
nullableIntegerField,
|
|
12
|
+
nullableTextField,
|
|
13
|
+
query,
|
|
14
|
+
sortByName,
|
|
15
|
+
sortWarnings,
|
|
16
|
+
splitSqlList,
|
|
17
|
+
tableSelected,
|
|
18
|
+
textField,
|
|
19
|
+
type CatalogColumnSnapshot,
|
|
20
|
+
type CatalogForeignKeySnapshot,
|
|
21
|
+
type CatalogIndexColumn,
|
|
22
|
+
type CatalogIndexSnapshot,
|
|
23
|
+
type CatalogSchemaSnapshot,
|
|
24
|
+
type CatalogTableSnapshot,
|
|
25
|
+
type CatalogWarning,
|
|
26
|
+
type ReferentialAction,
|
|
27
|
+
} from './common.js';
|
|
28
|
+
export { normalizeDriftSnapshot } from './drift.js';
|
|
29
|
+
|
|
30
|
+
/** Resolve the catalog reader carried by an explicitly selected database package. */
|
|
31
|
+
export function createIntrospector<Name extends string>(dialect: SqlDialect<Name>): Introspector<Name> {
|
|
32
|
+
return dialect.introspector;
|
|
33
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { readMigrations } from '../file-io.js';
|
|
5
|
+
import {
|
|
6
|
+
diff,
|
|
7
|
+
snapshot,
|
|
8
|
+
type ColumnSnapshot,
|
|
9
|
+
type ExtensionSnapshot,
|
|
10
|
+
type ExtensionType,
|
|
11
|
+
type ForeignKeySnapshot,
|
|
12
|
+
type ReferentialAction,
|
|
13
|
+
type SchemaSnapshot,
|
|
14
|
+
type TableSnapshot,
|
|
15
|
+
} from '../index.js';
|
|
16
|
+
import { detectDrift } from '../introspect/index.js';
|
|
17
|
+
import { migrationTarget, requiredIntrospector, type MigrationProject } from '../project.js';
|
|
18
|
+
import { EMBEDDED_WITH_DOWN_MARKER, embeddedOutputPath, renderEmbeddedModule } from './embed.js';
|
|
19
|
+
|
|
20
|
+
const EMPTY_SNAPSHOT: SchemaSnapshot = { version: 1, tables: [], extensions: [] };
|
|
21
|
+
|
|
22
|
+
export type CheckFindingKind =
|
|
23
|
+
| 'uncommitted-schema'
|
|
24
|
+
| 'duplicate-version'
|
|
25
|
+
| 'snapshot-version'
|
|
26
|
+
| 'missing-down'
|
|
27
|
+
| 'stale-embedded'
|
|
28
|
+
| 'drift';
|
|
29
|
+
|
|
30
|
+
export interface CheckFinding {
|
|
31
|
+
readonly kind: CheckFindingKind;
|
|
32
|
+
readonly message: string;
|
|
33
|
+
readonly subject: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CheckResult {
|
|
37
|
+
readonly findings: readonly CheckFinding[];
|
|
38
|
+
readonly skipped: readonly { readonly kind: 'drift'; readonly reason: string }[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function checkProject(project: MigrationProject): Promise<CheckResult> {
|
|
42
|
+
const findings: CheckFinding[] = [];
|
|
43
|
+
const skipped: { readonly kind: 'drift'; readonly reason: string }[] = [];
|
|
44
|
+
const snapshotPath = join(project.outDir, 'snapshot.json');
|
|
45
|
+
const stored = await storedSnapshot(snapshotPath);
|
|
46
|
+
const declared = snapshot(project.schemas);
|
|
47
|
+
|
|
48
|
+
if ('newerVersion' in stored) {
|
|
49
|
+
findings.push({
|
|
50
|
+
kind: 'snapshot-version',
|
|
51
|
+
message: `snapshot version ${String(stored.newerVersion)} is newer than this build's version 1`,
|
|
52
|
+
subject: snapshotPath,
|
|
53
|
+
});
|
|
54
|
+
} else if (diff(stored.snapshot, declared, { dialect: migrationTarget(project) }).length > 0) {
|
|
55
|
+
findings.push({
|
|
56
|
+
kind: 'uncommitted-schema',
|
|
57
|
+
message: 'declarations differ from the stored snapshot; run zmdb generate',
|
|
58
|
+
subject: snapshotPath,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const migrations = await readMigrations(project.outDir);
|
|
63
|
+
const byVersion = new Map<number, string[]>();
|
|
64
|
+
for (const migration of migrations) {
|
|
65
|
+
const paths = byVersion.get(migration.version) ?? [];
|
|
66
|
+
paths.push(migration.path);
|
|
67
|
+
byVersion.set(migration.version, paths);
|
|
68
|
+
if (!migration.hasDown) {
|
|
69
|
+
findings.push({
|
|
70
|
+
kind: 'missing-down',
|
|
71
|
+
message: `migration ${String(migration.version)} ${migration.name} has no -- zmdb:down section`,
|
|
72
|
+
subject: migration.path,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const [version, paths] of byVersion) {
|
|
77
|
+
if (paths.length < 2) continue;
|
|
78
|
+
findings.push({
|
|
79
|
+
kind: 'duplicate-version',
|
|
80
|
+
message: `migration version ${String(version)} appears in ${String(paths.length)} files`,
|
|
81
|
+
subject: paths.join(', '),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (project.dialect.migrations.embedded) {
|
|
86
|
+
const embeddedPath = embeddedOutputPath(project);
|
|
87
|
+
const embedded = await optionalFile(embeddedPath);
|
|
88
|
+
if (embedded !== undefined) {
|
|
89
|
+
const withDown = embedded.startsWith(
|
|
90
|
+
`// Generated by \`zmdb embed\`. Do not edit.\n${EMBEDDED_WITH_DOWN_MARKER}\n`,
|
|
91
|
+
);
|
|
92
|
+
const expected = await renderEmbeddedModule(project, withDown ? { withDown: true } : {});
|
|
93
|
+
if (embedded !== expected.source) {
|
|
94
|
+
findings.push({
|
|
95
|
+
kind: 'stale-embedded',
|
|
96
|
+
message: 'embedded migration module differs from the configured migration files; run zmdb embed',
|
|
97
|
+
subject: embeddedPath,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (project.driver === undefined) {
|
|
104
|
+
skipped.push({ kind: 'drift', reason: `config ${project.configPath} has no driver` });
|
|
105
|
+
} else if (!('newerVersion' in stored)) {
|
|
106
|
+
const introspector = requiredIntrospector(project);
|
|
107
|
+
const live = await introspector.snapshot(project.driver, project.introspect);
|
|
108
|
+
const report = detectDrift(
|
|
109
|
+
introspector.normalizeForDrift(live, 'live'),
|
|
110
|
+
introspector.normalizeForDrift(stored.snapshot, 'declared'),
|
|
111
|
+
{ dialect: project.dialect },
|
|
112
|
+
);
|
|
113
|
+
if (!report.clean) {
|
|
114
|
+
findings.push({
|
|
115
|
+
kind: 'drift',
|
|
116
|
+
message:
|
|
117
|
+
`live database differs from the stored snapshot: ` +
|
|
118
|
+
`${String(report.onlyInDatabase.length)} database-only and ` +
|
|
119
|
+
`${String(report.onlyInDeclarations.length)} declaration-only operations`,
|
|
120
|
+
subject: project.configPath,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { findings, skipped };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function optionalFile(path: string): Promise<string | undefined> {
|
|
129
|
+
try {
|
|
130
|
+
return await readFile(path, 'utf8');
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (errorCode(error) === 'ENOENT') return undefined;
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function storedSnapshot(
|
|
138
|
+
path: string,
|
|
139
|
+
): Promise<{ readonly snapshot: SchemaSnapshot } | { readonly newerVersion: number }> {
|
|
140
|
+
let source: string;
|
|
141
|
+
try {
|
|
142
|
+
source = await readFile(path, 'utf8');
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (errorCode(error) === 'ENOENT') return { snapshot: EMPTY_SNAPSHOT };
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
const parsed: unknown = JSON.parse(source);
|
|
148
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
149
|
+
throw new TypeError(`stored snapshot ${path} must be a JSON object`);
|
|
150
|
+
}
|
|
151
|
+
const version: unknown = Reflect.get(parsed, 'version');
|
|
152
|
+
if (typeof version !== 'number' || !Number.isSafeInteger(version)) {
|
|
153
|
+
throw new TypeError(`stored snapshot ${path} has no safe integer version`);
|
|
154
|
+
}
|
|
155
|
+
if (version > 1) return { newerVersion: version };
|
|
156
|
+
if (version !== 1) throw new TypeError(`stored snapshot ${path} uses unknown version ${String(version)}`);
|
|
157
|
+
return { snapshot: snapshotV1(parsed, path) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function snapshotV1(value: object, path: string): SchemaSnapshot {
|
|
161
|
+
const stored = record(value, `stored snapshot ${path}`);
|
|
162
|
+
const tableValues = array(stored.tables, `stored snapshot ${path} tables`);
|
|
163
|
+
const extensionValues = array(stored.extensions, `stored snapshot ${path} extensions`);
|
|
164
|
+
return {
|
|
165
|
+
version: 1,
|
|
166
|
+
tables: tableValues.map((table, index) => tableSnapshot(table, path, index)),
|
|
167
|
+
extensions: extensionValues.map((extension, index) => extensionSnapshot(extension, path, index)),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function tableSnapshot(value: unknown, path: string, index: number): TableSnapshot {
|
|
172
|
+
const table = record(value, `stored snapshot ${path} table ${String(index)}`);
|
|
173
|
+
const name = text(table.name, `stored snapshot ${path} table ${String(index)} name`);
|
|
174
|
+
const columns = array(table.columns, `stored snapshot ${path} table ${name} columns`).map((column, columnIndex) =>
|
|
175
|
+
columnSnapshot(column, path, name, columnIndex),
|
|
176
|
+
);
|
|
177
|
+
const primaryKey =
|
|
178
|
+
table.primaryKey === undefined
|
|
179
|
+
? columns.filter(column => column.primaryKey).map(column => column.name)
|
|
180
|
+
: stringArray(table.primaryKey, `stored snapshot ${path} table ${name} primaryKey`);
|
|
181
|
+
const foreignKeys =
|
|
182
|
+
table.foreignKeys === undefined
|
|
183
|
+
? []
|
|
184
|
+
: array(table.foreignKeys, `stored snapshot ${path} table ${name} foreignKeys`).map((foreignKey, keyIndex) =>
|
|
185
|
+
foreignKeySnapshot(foreignKey, path, name, keyIndex),
|
|
186
|
+
);
|
|
187
|
+
return { name, columns, primaryKey, foreignKeys };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function columnSnapshot(value: unknown, path: string, table: string, index: number): ColumnSnapshot {
|
|
191
|
+
const column = record(value, `stored snapshot ${path} table ${table} column ${String(index)}`);
|
|
192
|
+
const length = column.length;
|
|
193
|
+
if (length !== undefined && (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 1)) {
|
|
194
|
+
throw new TypeError(`stored snapshot ${path} table ${table} column ${String(index)} length must be positive`);
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
name: text(column.name, `stored snapshot ${path} table ${table} column ${String(index)} name`),
|
|
198
|
+
type: columnType(column.type, path, table, index),
|
|
199
|
+
nullable: flag(column.nullable, `stored snapshot ${path} table ${table} column ${String(index)} nullable`),
|
|
200
|
+
primaryKey: flag(column.primaryKey, `stored snapshot ${path} table ${table} column ${String(index)} primaryKey`),
|
|
201
|
+
...(length === undefined ? {} : { length }),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function columnType(value: unknown, path: string, table: string, index: number): string | ExtensionType {
|
|
206
|
+
if (typeof value === 'string') return value;
|
|
207
|
+
const type = record(value, `stored snapshot ${path} table ${table} column ${String(index)} type`);
|
|
208
|
+
const args =
|
|
209
|
+
type.args === undefined
|
|
210
|
+
? undefined
|
|
211
|
+
: array(type.args, `stored snapshot ${path} table ${table} column ${String(index)} type args`).map(
|
|
212
|
+
(argument, argumentIndex) => {
|
|
213
|
+
if (typeof argument === 'string' || typeof argument === 'number') return argument;
|
|
214
|
+
throw new TypeError(
|
|
215
|
+
`stored snapshot ${path} table ${table} column ${String(index)} type arg ${String(argumentIndex)} ` +
|
|
216
|
+
'must be a string or number',
|
|
217
|
+
);
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
return {
|
|
221
|
+
extension: text(type.extension, `stored snapshot ${path} table ${table} column ${String(index)} type extension`),
|
|
222
|
+
name: text(type.name, `stored snapshot ${path} table ${table} column ${String(index)} type name`),
|
|
223
|
+
...(args === undefined ? {} : { args }),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function foreignKeySnapshot(value: unknown, path: string, table: string, index: number): ForeignKeySnapshot {
|
|
228
|
+
const foreignKey = record(value, `stored snapshot ${path} table ${table} foreign key ${String(index)}`);
|
|
229
|
+
return {
|
|
230
|
+
name: text(foreignKey.name, `stored snapshot ${path} table ${table} foreign key ${String(index)} name`),
|
|
231
|
+
columns: stringArray(
|
|
232
|
+
foreignKey.columns,
|
|
233
|
+
`stored snapshot ${path} table ${table} foreign key ${String(index)} columns`,
|
|
234
|
+
),
|
|
235
|
+
targetTable: text(
|
|
236
|
+
foreignKey.targetTable,
|
|
237
|
+
`stored snapshot ${path} table ${table} foreign key ${String(index)} targetTable`,
|
|
238
|
+
),
|
|
239
|
+
targetColumns: stringArray(
|
|
240
|
+
foreignKey.targetColumns,
|
|
241
|
+
`stored snapshot ${path} table ${table} foreign key ${String(index)} targetColumns`,
|
|
242
|
+
),
|
|
243
|
+
onDelete: referentialAction(
|
|
244
|
+
foreignKey.onDelete,
|
|
245
|
+
`stored snapshot ${path} table ${table} foreign key ${String(index)} onDelete`,
|
|
246
|
+
),
|
|
247
|
+
onUpdate: referentialAction(
|
|
248
|
+
foreignKey.onUpdate,
|
|
249
|
+
`stored snapshot ${path} table ${table} foreign key ${String(index)} onUpdate`,
|
|
250
|
+
),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function extensionSnapshot(value: unknown, path: string, index: number): ExtensionSnapshot {
|
|
255
|
+
const extension = record(value, `stored snapshot ${path} extension ${String(index)}`);
|
|
256
|
+
const schema = extension.schema;
|
|
257
|
+
if (schema !== undefined && typeof schema !== 'string') {
|
|
258
|
+
throw new TypeError(`stored snapshot ${path} extension ${String(index)} schema must be a string`);
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
name: text(extension.name, `stored snapshot ${path} extension ${String(index)} name`),
|
|
262
|
+
...(schema === undefined ? {} : { schema }),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function referentialAction(value: unknown, label: string): ReferentialAction {
|
|
267
|
+
if (
|
|
268
|
+
value === 'cascade' ||
|
|
269
|
+
value === 'restrict' ||
|
|
270
|
+
value === 'set null' ||
|
|
271
|
+
value === 'set default' ||
|
|
272
|
+
value === 'no action'
|
|
273
|
+
) {
|
|
274
|
+
return value;
|
|
275
|
+
}
|
|
276
|
+
throw new TypeError(`${label} must be a referential action`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function record(value: unknown, label: string): Readonly<Record<string, unknown>> {
|
|
280
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
281
|
+
throw new TypeError(`${label} must be an object`);
|
|
282
|
+
}
|
|
283
|
+
return Object.fromEntries(Object.entries(value));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function array(value: unknown, label: string): readonly unknown[] {
|
|
287
|
+
if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`);
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function stringArray(value: unknown, label: string): readonly string[] {
|
|
292
|
+
return array(value, label).map((item, index) => text(item, `${label} item ${String(index)}`));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function text(value: unknown, label: string): string {
|
|
296
|
+
if (typeof value !== 'string') throw new TypeError(`${label} must be a string`);
|
|
297
|
+
return value;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function flag(value: unknown, label: string): boolean {
|
|
301
|
+
if (typeof value !== 'boolean') throw new TypeError(`${label} must be a boolean`);
|
|
302
|
+
return value;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function errorCode(error: unknown): string | undefined {
|
|
306
|
+
if (typeof error !== 'object' || error === null) return undefined;
|
|
307
|
+
const code = Reflect.get(error, 'code');
|
|
308
|
+
return typeof code === 'string' ? code : undefined;
|
|
309
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { dirname, join, resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { dialectName } from '@zmdb/sql';
|
|
4
|
+
|
|
5
|
+
import { readMigrations, writeTextAtomically } from '../file-io.js';
|
|
6
|
+
import { MigrationProjectError, requiredSourceFormatter, type MigrationProject } from '../project.js';
|
|
7
|
+
|
|
8
|
+
export const EMBEDDED_WITH_DOWN_MARKER = '// Includes down sections for development tooling.';
|
|
9
|
+
|
|
10
|
+
export interface EmbedOptions {
|
|
11
|
+
readonly out?: string;
|
|
12
|
+
readonly withDown?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface EmbeddedModuleResult {
|
|
16
|
+
readonly file: string;
|
|
17
|
+
readonly source: string;
|
|
18
|
+
readonly migrations: readonly {
|
|
19
|
+
readonly version: number;
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly checksum: string;
|
|
22
|
+
}[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EmbedResult {
|
|
26
|
+
readonly file: string;
|
|
27
|
+
readonly migrations: EmbeddedModuleResult['migrations'];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function embeddedOutputPath(project: MigrationProject, requested?: string): string {
|
|
31
|
+
return requested === undefined
|
|
32
|
+
? join(project.outDir, 'embedded.ts')
|
|
33
|
+
: resolve(dirname(project.configPath), requested);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function renderEmbeddedModule(
|
|
37
|
+
project: MigrationProject,
|
|
38
|
+
options: EmbedOptions = {},
|
|
39
|
+
): Promise<EmbeddedModuleResult> {
|
|
40
|
+
const file = embeddedOutputPath(project, options.out);
|
|
41
|
+
const migrations = await Promise.all(
|
|
42
|
+
(await readMigrations(project.outDir)).map(async migration => ({
|
|
43
|
+
version: migration.version,
|
|
44
|
+
name: migration.name,
|
|
45
|
+
up: migration.up,
|
|
46
|
+
...(options.withDown === true ? { down: migration.down } : {}),
|
|
47
|
+
checksum: await checksum(migration.up),
|
|
48
|
+
})),
|
|
49
|
+
);
|
|
50
|
+
const arrayType =
|
|
51
|
+
options.withDown === true
|
|
52
|
+
? 'readonly (EmbeddedMigration & { readonly down: string })[]'
|
|
53
|
+
: 'readonly EmbeddedMigration[]';
|
|
54
|
+
const marker = options.withDown === true ? `${EMBEDDED_WITH_DOWN_MARKER}\n` : '';
|
|
55
|
+
const raw =
|
|
56
|
+
'// Generated by `zmdb embed`. Do not edit.\n' +
|
|
57
|
+
marker +
|
|
58
|
+
"import type { EmbeddedMigration } from '@zmdb/migrations/embedded';\n\n" +
|
|
59
|
+
`export const migrations: ${arrayType} = ${JSON.stringify(migrations, null, 2)} as const;\n`;
|
|
60
|
+
return {
|
|
61
|
+
file,
|
|
62
|
+
source: await requiredSourceFormatter(project)(file, raw),
|
|
63
|
+
migrations: migrations.map(migration => ({
|
|
64
|
+
version: migration.version,
|
|
65
|
+
name: migration.name,
|
|
66
|
+
checksum: migration.checksum,
|
|
67
|
+
})),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function embedMigrations(project: MigrationProject, options: EmbedOptions = {}): Promise<EmbedResult> {
|
|
72
|
+
if (!project.dialect.migrations.embedded) {
|
|
73
|
+
throw new MigrationProjectError(
|
|
74
|
+
`embedded migrations are not supported by ${dialectName(project.dialect)} in ${project.configPath}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const rendered = await renderEmbeddedModule(project, options);
|
|
78
|
+
await writeTextAtomically(rendered.file, rendered.source);
|
|
79
|
+
return { file: rendered.file, migrations: rendered.migrations };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function checksum(source: string): Promise<string> {
|
|
83
|
+
const bytes = new TextEncoder().encode(source);
|
|
84
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes));
|
|
85
|
+
const hex = Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
86
|
+
return `sha256:${hex}`;
|
|
87
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { diff, emitUp, snapshot, type ChangeOp, type SchemaSnapshot } from '../index.js';
|
|
2
|
+
import { migrationTarget, type MigrationProject } from '../project.js';
|
|
3
|
+
|
|
4
|
+
const EMPTY_SNAPSHOT: SchemaSnapshot = { version: 1, tables: [], extensions: [] };
|
|
5
|
+
|
|
6
|
+
export interface ExportResult {
|
|
7
|
+
readonly ops: readonly ChangeOp[];
|
|
8
|
+
readonly statements: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Reflect declarations and hand the resulting plan directly to the DDL emitter. */
|
|
12
|
+
export function exportSchema(project: MigrationProject): ExportResult {
|
|
13
|
+
const target = migrationTarget(project);
|
|
14
|
+
const next = snapshot(project.schemas);
|
|
15
|
+
const ops = diff(EMPTY_SNAPSHOT, next, { dialect: target });
|
|
16
|
+
target.migrations.validatePlan({ before: EMPTY_SNAPSHOT, after: next, operations: ops });
|
|
17
|
+
return {
|
|
18
|
+
ops,
|
|
19
|
+
statements: ops.map(operation => emitUp(operation, target)),
|
|
20
|
+
};
|
|
21
|
+
}
|