@metaobjectsdev/cli 0.11.6 → 0.12.0-rc.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/README.md +17 -3
- package/dist/src/commands/gen.d.ts +2 -1
- package/dist/src/commands/gen.d.ts.map +1 -1
- package/dist/src/commands/gen.js +8 -4
- package/dist/src/commands/gen.js.map +1 -1
- package/dist/src/commands/migrate.d.ts +4 -3
- package/dist/src/commands/migrate.d.ts.map +1 -1
- package/dist/src/commands/migrate.js +304 -206
- package/dist/src/commands/migrate.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +182 -7
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/args.d.ts +8 -0
- package/dist/src/lib/args.d.ts.map +1 -1
- package/dist/src/lib/args.js +21 -0
- package/dist/src/lib/args.js.map +1 -1
- package/dist/src/lib/format.d.ts +8 -0
- package/dist/src/lib/format.d.ts.map +1 -0
- package/dist/src/lib/format.js +18 -0
- package/dist/src/lib/format.js.map +1 -0
- package/dist/src/lib/kysely.d.ts.map +1 -1
- package/dist/src/lib/kysely.js +5 -2
- package/dist/src/lib/kysely.js.map +1 -1
- package/dist/src/lib/output-json.d.ts +4 -0
- package/dist/src/lib/output-json.d.ts.map +1 -0
- package/dist/src/lib/output-json.js +8 -0
- package/dist/src/lib/output-json.js.map +1 -0
- package/dist/src/lib/output.d.ts +23 -0
- package/dist/src/lib/output.d.ts.map +1 -1
- package/dist/src/lib/output.js +88 -0
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/pm-detect.d.ts +12 -0
- package/dist/src/lib/pm-detect.d.ts.map +1 -0
- package/dist/src/lib/pm-detect.js +52 -0
- package/dist/src/lib/pm-detect.js.map +1 -0
- package/package.json +17 -20
- package/src/commands/gen.ts +10 -4
- package/src/commands/migrate.ts +134 -10
- package/src/index.ts +183 -7
- package/src/lib/args.ts +34 -0
- package/src/lib/format.ts +23 -0
- package/src/lib/kysely.ts +5 -2
- package/src/lib/output-json.ts +10 -0
- package/src/lib/output.ts +100 -0
- package/src/lib/pm-detect.ts +53 -0
|
@@ -3,7 +3,9 @@ import { mkdir } from "node:fs/promises";
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { parseMigrateArgs } from "../lib/args.js";
|
|
5
5
|
import { resolveMigrateConfig, MIGRATE_DEFAULT_OUT_DIR } from "../lib/config.js";
|
|
6
|
-
import { formatMigrateResult } from "../lib/output.js";
|
|
6
|
+
import { formatMigrateResult, formatMigrateResultToon } from "../lib/output.js";
|
|
7
|
+
import { formatMigrateResultJson } from "../lib/output-json.js";
|
|
8
|
+
import { toonEncode } from "../lib/format.js";
|
|
7
9
|
import { buildKyselyFromUrl } from "../lib/kysely.js";
|
|
8
10
|
import { log } from "../lib/log.js";
|
|
9
11
|
import { loadMemory } from "@metaobjectsdev/sdk";
|
|
@@ -12,6 +14,64 @@ import { buildExpectedSchema, introspect, diff, emit, writeMigration, baselineFr
|
|
|
12
14
|
import { buildWranglerExecuteArgs, defaultWranglerRunner, } from "../lib/wrangler.js";
|
|
13
15
|
import { buildProjectionViews } from "@metaobjectsdev/codegen-ts";
|
|
14
16
|
import { tokensToAllowOptions, describeChange } from "../lib/allow.js";
|
|
17
|
+
const MIGRATE_HELP_TEXT = `meta migrate — diff metadata vs live DB; emit migration SQL files
|
|
18
|
+
|
|
19
|
+
USAGE:
|
|
20
|
+
meta migrate [baseline] [flags]
|
|
21
|
+
|
|
22
|
+
SUBCOMMANDS:
|
|
23
|
+
baseline Seed the committed reference snapshot (no migration emitted).
|
|
24
|
+
Required before the first offline generate.
|
|
25
|
+
|
|
26
|
+
MIGRATE FLAGS:
|
|
27
|
+
--db <url> DB connection URL (required for live-introspect / --apply / --rollback)
|
|
28
|
+
Supports: file:, libsql:, postgres:, postgresql:
|
|
29
|
+
--dialect sqlite|postgres|d1
|
|
30
|
+
Optional dialect override (auto-detected from URL scheme)
|
|
31
|
+
--out-dir <path> Migration directory (default: ./.metaobjects/migrations)
|
|
32
|
+
--slug <name> Required when changes are present (e.g., --slug add-user-shipping)
|
|
33
|
+
--allow <csv> Comma-separated destructive-change permissions:
|
|
34
|
+
drop-column,drop-table,type-change,drop-index,drop-fk,nullable-to-not-null
|
|
35
|
+
--on-ambiguous abort|rename|drop-add
|
|
36
|
+
How to handle ambiguous renames (default: abort)
|
|
37
|
+
--from-db Introspect live DB instead of using the committed snapshot
|
|
38
|
+
--apply Run pending migration files against the DB after writing
|
|
39
|
+
--rollback <target> Roll back applied migrations newer than <target>
|
|
40
|
+
--d1 <binding> D1 binding name from wrangler.toml (only with --dialect d1)
|
|
41
|
+
--remote Target remote D1 instead of local (only with --dialect d1)
|
|
42
|
+
--yes Skip the --remote --apply confirmation pause
|
|
43
|
+
--dry-run Print SQL to stdout, don't write
|
|
44
|
+
--help, -h Print this help
|
|
45
|
+
|
|
46
|
+
EXAMPLES:
|
|
47
|
+
meta migrate baseline --dialect sqlite
|
|
48
|
+
meta migrate --dialect sqlite --slug add-users
|
|
49
|
+
meta migrate --db file:local.db --slug add-orders
|
|
50
|
+
meta migrate --db postgresql://localhost/mydb --slug add-index --apply
|
|
51
|
+
`;
|
|
52
|
+
/** Emit a structured error on stdout (not stderr) in the active format, per axi. */
|
|
53
|
+
function emitStructuredError(error, hint, fmt) {
|
|
54
|
+
const payload = { error, hint };
|
|
55
|
+
if (fmt === "json") {
|
|
56
|
+
log.info(JSON.stringify(payload, null, 2));
|
|
57
|
+
}
|
|
58
|
+
else if (fmt === "toon") {
|
|
59
|
+
log.info(toonEncode(payload));
|
|
60
|
+
}
|
|
61
|
+
// text format: errors go to stderr via log.error() — the caller handles that path
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Sentinel thrown by sub-functions that have already emitted a structured error
|
|
65
|
+
* via emitStructuredError(). The top-level catch in migrateCommand re-throws
|
|
66
|
+
* this as-is without double-emitting.
|
|
67
|
+
*/
|
|
68
|
+
class AlreadyEmittedError extends Error {
|
|
69
|
+
exitCode;
|
|
70
|
+
constructor(exitCode) {
|
|
71
|
+
super("already-emitted");
|
|
72
|
+
this.exitCode = exitCode;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
15
75
|
function mapOnAmbiguous(v) {
|
|
16
76
|
return v === "drop-add" ? "drop+add" : v;
|
|
17
77
|
}
|
|
@@ -58,264 +118,300 @@ function ambiguousToEntries(amb) {
|
|
|
58
118
|
}
|
|
59
119
|
export async function migrateCommand(args, cwd,
|
|
60
120
|
/** Injectable wrangler runner — tests pass a mock; production uses the default. */
|
|
61
|
-
wranglerRunner) {
|
|
121
|
+
wranglerRunner, fmt = "text") {
|
|
122
|
+
// Intercept --help / -h before parseMigrateArgs (parseArgs strict mode rejects them).
|
|
123
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
124
|
+
log.info(MIGRATE_HELP_TEXT);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
62
127
|
let flags;
|
|
63
128
|
try {
|
|
64
129
|
flags = parseMigrateArgs(args);
|
|
65
130
|
}
|
|
66
131
|
catch (err) {
|
|
67
|
-
|
|
132
|
+
const msg = err.message;
|
|
133
|
+
log.error(`migrate: ${msg}`);
|
|
134
|
+
emitStructuredError(`migrate: ${msg}`, "run `meta migrate --help` for usage", fmt);
|
|
68
135
|
return 2;
|
|
69
136
|
}
|
|
70
137
|
const metaRoot = cwd;
|
|
71
138
|
const config = await resolveMigrateConfig(flags, metaRoot);
|
|
72
|
-
|
|
139
|
+
try {
|
|
140
|
+
if (config.dialect === "d1") {
|
|
141
|
+
if (config.baseline) {
|
|
142
|
+
log.error(`migrate baseline is not supported for dialect 'd1' (snapshots are a postgres/sqlite concept)`);
|
|
143
|
+
emitStructuredError(`migrate baseline is not supported for dialect 'd1'`, "drop 'baseline' for d1 — snapshots are a postgres/sqlite concept", fmt);
|
|
144
|
+
return 2;
|
|
145
|
+
}
|
|
146
|
+
if (config.databaseUrl !== undefined) {
|
|
147
|
+
log.error(`migrate: --db / DATABASE_URL is not used for dialect 'd1' — wrangler.toml owns connection`);
|
|
148
|
+
emitStructuredError(`migrate: --db / DATABASE_URL is not used for dialect 'd1'`, "remove --db / DATABASE_URL for d1 — wrangler.toml owns the connection", fmt);
|
|
149
|
+
return 2;
|
|
150
|
+
}
|
|
151
|
+
if (config.rollback !== undefined) {
|
|
152
|
+
log.error(`migrate: --rollback is not supported for dialect 'd1' (use 'wrangler d1 migrations' tooling)`);
|
|
153
|
+
emitStructuredError(`migrate: --rollback is not supported for dialect 'd1'`, "use 'wrangler d1 migrations' tooling to roll back d1", fmt);
|
|
154
|
+
return 2;
|
|
155
|
+
}
|
|
156
|
+
return await runD1Migrate(config, metaRoot, wranglerRunner ?? defaultWranglerRunner, fmt);
|
|
157
|
+
}
|
|
158
|
+
// `migrate baseline` — seed the committed reference snapshot, emit no migration.
|
|
73
159
|
if (config.baseline) {
|
|
74
|
-
|
|
75
|
-
|
|
160
|
+
return await runBaseline(config, metaRoot, fmt);
|
|
161
|
+
}
|
|
162
|
+
// Default = offline snapshot generation. The live-introspection path runs only
|
|
163
|
+
// when explicitly requested via --from-db, when --apply needs a connection, or
|
|
164
|
+
// for --rollback (which runs hand-authored down.sql against the live DB).
|
|
165
|
+
if (!config.fromDb && !config.apply && config.rollback === undefined) {
|
|
166
|
+
return await runOfflineGenerate(config, metaRoot, fmt);
|
|
76
167
|
}
|
|
77
|
-
if (config.databaseUrl
|
|
78
|
-
log.error(`migrate: --db
|
|
168
|
+
if (config.databaseUrl === undefined) {
|
|
169
|
+
log.error(`migrate: --db <url> required (or set DATABASE_URL, or add migrate.databaseUrl to .metaobjects/config.json)`);
|
|
170
|
+
emitStructuredError(`migrate: --db <url> required`, "pass --db <url>, set DATABASE_URL, or add migrate.databaseUrl to .metaobjects/config.json", fmt);
|
|
79
171
|
return 2;
|
|
80
172
|
}
|
|
173
|
+
// --rollback short-circuits the diff/emit pipeline: it runs the down.sql of
|
|
174
|
+
// every applied migration NEWER than <target> (target retained), in reverse
|
|
175
|
+
// order, ledger-tracked + advisory-locked. postgres/sqlite only.
|
|
81
176
|
if (config.rollback !== undefined) {
|
|
82
|
-
|
|
83
|
-
return 2;
|
|
177
|
+
return await runRollback(config, metaRoot);
|
|
84
178
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// when explicitly requested via --from-db, when --apply needs a connection, or
|
|
93
|
-
// for --rollback (which runs hand-authored down.sql against the live DB).
|
|
94
|
-
if (!config.fromDb && !config.apply && config.rollback === undefined) {
|
|
95
|
-
return await runOfflineGenerate(config, metaRoot);
|
|
96
|
-
}
|
|
97
|
-
if (config.databaseUrl === undefined) {
|
|
98
|
-
log.error(`migrate: --db <url> required (or set DATABASE_URL, or add migrate.databaseUrl to .metaobjects/config.json)`);
|
|
99
|
-
return 2;
|
|
100
|
-
}
|
|
101
|
-
// --rollback short-circuits the diff/emit pipeline: it runs the down.sql of
|
|
102
|
-
// every applied migration NEWER than <target> (target retained), in reverse
|
|
103
|
-
// order, ledger-tracked + advisory-locked. postgres/sqlite only.
|
|
104
|
-
if (config.rollback !== undefined) {
|
|
105
|
-
return await runRollback(config, metaRoot);
|
|
106
|
-
}
|
|
107
|
-
// Best-effort load of metaobjects.config.ts to pick up consumer-supplied
|
|
108
|
-
// providers. migrate's postgres/sqlite path also reads the config later
|
|
109
|
-
// for columnNamingStrategy; we load it once here and reuse below.
|
|
110
|
-
let postgresConfigProviders;
|
|
111
|
-
try {
|
|
112
|
-
const forgeConfig = await loadMetaobjectsConfig(metaRoot);
|
|
113
|
-
postgresConfigProviders = forgeConfig.providers;
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
postgresConfigProviders = undefined;
|
|
117
|
-
}
|
|
118
|
-
let metadata;
|
|
119
|
-
try {
|
|
120
|
-
metadata = await loadMemory(metaRoot, {
|
|
121
|
-
...(postgresConfigProviders !== undefined ? { providers: postgresConfigProviders } : {}),
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
catch (err) {
|
|
125
|
-
const msg = err.message;
|
|
126
|
-
if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
|
|
127
|
-
log.error(`no metaobjects/ found in ${metaRoot}; run 'meta init' to scaffold`);
|
|
179
|
+
// Best-effort load of metaobjects.config.ts to pick up consumer-supplied
|
|
180
|
+
// providers. migrate's postgres/sqlite path also reads the config later
|
|
181
|
+
// for columnNamingStrategy; we load it once here and reuse below.
|
|
182
|
+
let postgresConfigProviders;
|
|
183
|
+
try {
|
|
184
|
+
const forgeConfig = await loadMetaobjectsConfig(metaRoot);
|
|
185
|
+
postgresConfigProviders = forgeConfig.providers;
|
|
128
186
|
}
|
|
129
|
-
|
|
130
|
-
|
|
187
|
+
catch {
|
|
188
|
+
postgresConfigProviders = undefined;
|
|
131
189
|
}
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
let kysely;
|
|
135
|
-
try {
|
|
136
|
-
kysely = await buildKyselyFromUrl(config.databaseUrl, config.dialect);
|
|
137
|
-
}
|
|
138
|
-
catch (err) {
|
|
139
|
-
log.error(`migrate: ${err.message}`);
|
|
140
|
-
return 2;
|
|
141
|
-
}
|
|
142
|
-
let exitCode = 0;
|
|
143
|
-
let writtenPaths = [];
|
|
144
|
-
let appliedNames = [];
|
|
145
|
-
let blocked = [];
|
|
146
|
-
let ambiguous = [];
|
|
147
|
-
let changeCounts = {};
|
|
148
|
-
try {
|
|
149
|
-
// Column-naming strategy (from metaobjects.config) drives BOTH the table schema
|
|
150
|
-
// and projection view DDL — derive it once, up front, so every view path agrees.
|
|
151
|
-
let columnNamingStrategy = "snake_case";
|
|
190
|
+
let metadata;
|
|
152
191
|
try {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
192
|
+
metadata = await loadMemory(metaRoot, {
|
|
193
|
+
...(postgresConfigProviders !== undefined ? { providers: postgresConfigProviders } : {}),
|
|
194
|
+
});
|
|
156
195
|
}
|
|
157
|
-
catch {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
views: expectedViews,
|
|
169
|
-
});
|
|
170
|
-
let actual;
|
|
196
|
+
catch (err) {
|
|
197
|
+
const msg = err.message;
|
|
198
|
+
if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
|
|
199
|
+
log.error(`no metaobjects/ found in ${metaRoot}; run 'meta init' to scaffold`);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
log.error(`failed to load metadata: ${msg}`);
|
|
203
|
+
}
|
|
204
|
+
return 2;
|
|
205
|
+
}
|
|
206
|
+
let kysely;
|
|
171
207
|
try {
|
|
172
|
-
|
|
208
|
+
kysely = await buildKyselyFromUrl(config.databaseUrl, config.dialect);
|
|
173
209
|
}
|
|
174
210
|
catch (err) {
|
|
175
|
-
log.error(`migrate:
|
|
176
|
-
await kysely.close();
|
|
211
|
+
log.error(`migrate: ${err.message}`);
|
|
177
212
|
return 2;
|
|
178
213
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
let
|
|
214
|
+
let exitCode = 0;
|
|
215
|
+
let writtenPaths = [];
|
|
216
|
+
let appliedNames = [];
|
|
217
|
+
let applyFailed = false;
|
|
218
|
+
let blocked = [];
|
|
219
|
+
let ambiguous = [];
|
|
220
|
+
let changeCounts = {};
|
|
182
221
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
222
|
+
// Column-naming strategy (from metaobjects.config) drives BOTH the table schema
|
|
223
|
+
// and projection view DDL — derive it once, up front, so every view path agrees.
|
|
224
|
+
let columnNamingStrategy = "snake_case";
|
|
225
|
+
try {
|
|
226
|
+
const cfg = await loadMetaobjectsConfig(metaRoot);
|
|
227
|
+
if (cfg.columnNamingStrategy)
|
|
228
|
+
columnNamingStrategy = cfg.columnNamingStrategy;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// metaobjects.config.ts absent or invalid — use default snake_case
|
|
232
|
+
}
|
|
233
|
+
// Expected views from the SINGLE view-SQL source (codegen-ts emitViewDdl, via
|
|
234
|
+
// buildProjectionViews). Threaded into the schema-diff so the diff produces all
|
|
235
|
+
// view DDL (create/drop/replace + dependency-recreate) and emit() renders it —
|
|
236
|
+
// there is no separate view-migration emitter.
|
|
237
|
+
const expectedViews = buildProjectionViews(metadata, { dialect: kysely.dialect, columnNamingStrategy });
|
|
238
|
+
const expected = buildExpectedSchema(metadata, {
|
|
186
239
|
dialect: kysely.dialect,
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
collectedAmbiguous.push(a);
|
|
190
|
-
return onAmbiguousResolution;
|
|
191
|
-
},
|
|
240
|
+
columnNamingStrategy,
|
|
241
|
+
views: expectedViews,
|
|
192
242
|
});
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const output = formatMigrateResult({
|
|
200
|
-
dialect: kysely.dialect,
|
|
201
|
-
displayUrl: kysely.displayUrl,
|
|
202
|
-
changeCounts: {},
|
|
203
|
-
blocked: [],
|
|
204
|
-
ambiguous,
|
|
205
|
-
writtenPaths: [],
|
|
206
|
-
dryRun: config.dryRun,
|
|
207
|
-
}, { isTTY: !!process.stdout.isTTY });
|
|
208
|
-
log.info(output);
|
|
243
|
+
let actual;
|
|
244
|
+
try {
|
|
245
|
+
actual = await introspect(kysely.db, kysely.dialect);
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
log.error(`migrate: failed to connect to ${kysely.displayUrl}: ${err.message}`);
|
|
209
249
|
await kysely.close();
|
|
210
|
-
return
|
|
250
|
+
return 2;
|
|
211
251
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
// All changes — tables AND views — are emitted by the one schema-diff path.
|
|
216
|
-
// View DDL (create/drop/replace) is produced by diff()'s view passes (2b body
|
|
217
|
-
// comparison, 2c dependency-recreate) and rendered by every dialect's emitter;
|
|
218
|
-
// STAGE_ORDER sequences drop-view before and create-view after any column change
|
|
219
|
-
// a view reads. There is no separate view-migration emitter, and unchanged views
|
|
220
|
-
// produce no change (introspect reads the actual body, diff compares it).
|
|
221
|
-
if (diffResult.changes.length === 0) {
|
|
222
|
-
// no-op — output will say "No schema changes"
|
|
223
|
-
}
|
|
224
|
-
else {
|
|
225
|
-
let emitted;
|
|
252
|
+
const collectedAmbiguous = [];
|
|
253
|
+
const onAmbiguousResolution = mapOnAmbiguous(config.onAmbiguous);
|
|
254
|
+
let diffResult;
|
|
226
255
|
try {
|
|
227
|
-
|
|
256
|
+
diffResult = await diff({
|
|
257
|
+
expected,
|
|
258
|
+
actual,
|
|
228
259
|
dialect: kysely.dialect,
|
|
229
|
-
|
|
230
|
-
|
|
260
|
+
allow: tokensToAllowOptions(config.allow),
|
|
261
|
+
onAmbiguous: async (a) => {
|
|
262
|
+
collectedAmbiguous.push(a);
|
|
263
|
+
return onAmbiguousResolution;
|
|
264
|
+
},
|
|
231
265
|
});
|
|
232
266
|
}
|
|
233
267
|
catch (err) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
268
|
+
// diff() throws when onAmbiguous returns "abort" — surface as exit 1
|
|
269
|
+
// with the collected ambiguity list.
|
|
270
|
+
if (err.message.includes("aborted by onAmbiguous")) {
|
|
271
|
+
ambiguous = ambiguousToEntries(collectedAmbiguous);
|
|
272
|
+
const migrateResult = {
|
|
273
|
+
dialect: kysely.dialect,
|
|
274
|
+
displayUrl: kysely.displayUrl,
|
|
275
|
+
changeCounts: {},
|
|
276
|
+
blocked: [],
|
|
277
|
+
ambiguous,
|
|
278
|
+
writtenPaths: [],
|
|
279
|
+
dryRun: config.dryRun,
|
|
280
|
+
applied: [],
|
|
281
|
+
applyFailed: false,
|
|
282
|
+
};
|
|
283
|
+
const output = fmt === "toon" ? formatMigrateResultToon(migrateResult)
|
|
284
|
+
: fmt === "json" ? formatMigrateResultJson(migrateResult)
|
|
285
|
+
: formatMigrateResult(migrateResult, { isTTY: !!process.stdout.isTTY });
|
|
286
|
+
log.info(output);
|
|
287
|
+
await kysely.close();
|
|
288
|
+
return 1;
|
|
240
289
|
}
|
|
290
|
+
throw err;
|
|
241
291
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
292
|
+
changeCounts = summarizeChanges(diffResult.changes);
|
|
293
|
+
// All changes — tables AND views — are emitted by the one schema-diff path.
|
|
294
|
+
// View DDL (create/drop/replace) is produced by diff()'s view passes (2b body
|
|
295
|
+
// comparison, 2c dependency-recreate) and rendered by every dialect's emitter;
|
|
296
|
+
// STAGE_ORDER sequences drop-view before and create-view after any column change
|
|
297
|
+
// a view reads. There is no separate view-migration emitter, and unchanged views
|
|
298
|
+
// produce no change (introspect reads the actual body, diff compares it).
|
|
299
|
+
if (diffResult.changes.length === 0) {
|
|
300
|
+
// no-op — output will say "No schema changes"
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
let emitted;
|
|
304
|
+
try {
|
|
305
|
+
emitted = emit(diffResult.changes, {
|
|
306
|
+
dialect: kysely.dialect,
|
|
307
|
+
expectedSchema: expected,
|
|
308
|
+
...(actual.meta !== undefined ? { actualMeta: actual.meta } : {}),
|
|
309
|
+
});
|
|
247
310
|
}
|
|
248
|
-
|
|
249
|
-
|
|
311
|
+
catch (err) {
|
|
312
|
+
if (err instanceof BlockedChangesError) {
|
|
313
|
+
blocked = blockedToEntries(err);
|
|
314
|
+
exitCode = 1;
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
throw err;
|
|
318
|
+
}
|
|
250
319
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
320
|
+
if (exitCode === 0 && emitted) {
|
|
321
|
+
if (config.slug === undefined) {
|
|
322
|
+
log.error(`migrate: --slug <name> required when there are changes (e.g., --slug add-user-shipping)`);
|
|
323
|
+
await kysely.close();
|
|
324
|
+
return 2;
|
|
325
|
+
}
|
|
326
|
+
if (config.dryRun) {
|
|
327
|
+
log.info(`-- UP --\n${emitted.up}\n\n-- DOWN --\n${emitted.down}`);
|
|
258
328
|
}
|
|
329
|
+
else {
|
|
330
|
+
const outDir = resolvePath(metaRoot, config.outDir);
|
|
331
|
+
await mkdir(outDir, { recursive: true });
|
|
332
|
+
const res = await writeMigration({ up: emitted.up, down: emitted.down }, { dir: outDir, slug: config.slug });
|
|
333
|
+
writtenPaths = [res.upPath, res.downPath];
|
|
334
|
+
if (config.fromDb) {
|
|
335
|
+
log.info(`migrate: --from-db did not advance the committed snapshot; run 'meta migrate baseline --from-db' to re-sync`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// --apply: run pending committed migration files against the DB, tracked by
|
|
341
|
+
// the migration-history ledger, transactionally. Idempotency comes from the
|
|
342
|
+
// ledger (skip already-applied), NOT from re-diffing — so this also applies
|
|
343
|
+
// any previously-written-but-unapplied files in this run. Skipped on dry-run
|
|
344
|
+
// and when a prior step set a non-zero exit (e.g. blocked changes).
|
|
345
|
+
if (config.apply && exitCode === 0 && !config.dryRun) {
|
|
346
|
+
const outDir = resolvePath(metaRoot, config.outDir);
|
|
347
|
+
try {
|
|
348
|
+
// applyPending calls ensureLedger internally (idempotent), so no need
|
|
349
|
+
// to ensure it here. Pass the dialect so postgres gets schema-qualified
|
|
350
|
+
// ledger DDL + the session advisory lock (sqlite is a no-op there).
|
|
351
|
+
const result = await applyPending(kysely.db, outDir, {
|
|
352
|
+
dryRun: false,
|
|
353
|
+
dialect: kysely.dialect,
|
|
354
|
+
});
|
|
355
|
+
appliedNames = [...result.applied];
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
log.error(`migrate: apply failed: ${err.message}`);
|
|
359
|
+
exitCode = 1;
|
|
360
|
+
applyFailed = true;
|
|
259
361
|
}
|
|
260
362
|
}
|
|
261
363
|
}
|
|
262
|
-
|
|
263
|
-
// the migration-history ledger, transactionally. Idempotency comes from the
|
|
264
|
-
// ledger (skip already-applied), NOT from re-diffing — so this also applies
|
|
265
|
-
// any previously-written-but-unapplied files in this run. Skipped on dry-run
|
|
266
|
-
// and when a prior step set a non-zero exit (e.g. blocked changes).
|
|
267
|
-
if (config.apply && exitCode === 0 && !config.dryRun) {
|
|
268
|
-
const outDir = resolvePath(metaRoot, config.outDir);
|
|
364
|
+
finally {
|
|
269
365
|
try {
|
|
270
|
-
|
|
271
|
-
// to ensure it here. Pass the dialect so postgres gets schema-qualified
|
|
272
|
-
// ledger DDL + the session advisory lock (sqlite is a no-op there).
|
|
273
|
-
const result = await applyPending(kysely.db, outDir, {
|
|
274
|
-
dryRun: false,
|
|
275
|
-
dialect: kysely.dialect,
|
|
276
|
-
});
|
|
277
|
-
appliedNames = [...result.applied];
|
|
366
|
+
await kysely.close();
|
|
278
367
|
}
|
|
279
368
|
catch (err) {
|
|
280
|
-
log.
|
|
281
|
-
exitCode = 1;
|
|
369
|
+
log.warn(`migrate: failed to close DB cleanly: ${err.message}`);
|
|
282
370
|
}
|
|
283
371
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
372
|
+
const migrateResult = {
|
|
373
|
+
dialect: kysely.dialect,
|
|
374
|
+
displayUrl: kysely.displayUrl,
|
|
375
|
+
changeCounts,
|
|
376
|
+
blocked,
|
|
377
|
+
ambiguous,
|
|
378
|
+
writtenPaths,
|
|
379
|
+
dryRun: config.dryRun,
|
|
380
|
+
applied: appliedNames,
|
|
381
|
+
applyFailed,
|
|
382
|
+
};
|
|
383
|
+
const output = fmt === "toon" ? formatMigrateResultToon(migrateResult)
|
|
384
|
+
: fmt === "json" ? formatMigrateResultJson(migrateResult)
|
|
385
|
+
: formatMigrateResult(migrateResult, { isTTY: !!process.stdout.isTTY });
|
|
386
|
+
log.info(output);
|
|
387
|
+
if (config.apply && exitCode === 0) {
|
|
388
|
+
if (appliedNames.length > 0) {
|
|
389
|
+
log.info(`migrate: applied ${appliedNames.length} migration(s): ${appliedNames.join(", ")}`);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
log.info(`migrate: no pending migrations to apply`);
|
|
393
|
+
}
|
|
291
394
|
}
|
|
395
|
+
return exitCode;
|
|
292
396
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (config.apply && exitCode === 0) {
|
|
304
|
-
if (appliedNames.length > 0) {
|
|
305
|
-
log.info(`migrate: applied ${appliedNames.length} migration(s): ${appliedNames.join(", ")}`);
|
|
306
|
-
}
|
|
307
|
-
else {
|
|
308
|
-
log.info(`migrate: no pending migrations to apply`);
|
|
309
|
-
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
// AlreadyEmittedError: sub-function already called emitStructuredError — just
|
|
399
|
+
// propagate the exit code without double-emitting.
|
|
400
|
+
if (err instanceof AlreadyEmittedError)
|
|
401
|
+
return err.exitCode;
|
|
402
|
+
// Unexpected error: emit structured error on stdout in the active format, then exit 1.
|
|
403
|
+
const msg = err.message ?? String(err);
|
|
404
|
+
log.error(`migrate: unexpected error: ${msg}`);
|
|
405
|
+
emitStructuredError(`migrate: unexpected error: ${msg}`, "run `meta migrate --help` for usage", fmt);
|
|
406
|
+
return 1;
|
|
310
407
|
}
|
|
311
|
-
return exitCode;
|
|
312
408
|
}
|
|
313
409
|
/**
|
|
314
410
|
* `meta migrate baseline [--from-db]` — seed the committed reference snapshot.
|
|
315
411
|
* `--from-metadata` (default) derives it from metadata; `--from-db` introspects
|
|
316
412
|
* an existing database once. Emits no migration.
|
|
317
413
|
*/
|
|
318
|
-
export async function runBaseline(config, metaRoot) {
|
|
414
|
+
export async function runBaseline(config, metaRoot, _fmt = "text") {
|
|
319
415
|
if (config.dialect === undefined) {
|
|
320
416
|
log.error(`migrate baseline: --dialect required (or set migrate.dialect in .metaobjects/config.json)`);
|
|
321
417
|
return 2;
|
|
@@ -380,7 +476,7 @@ export async function runBaseline(config, metaRoot) {
|
|
|
380
476
|
* Scope: table/column/index/FK changes. Projection-view migrations stay on the
|
|
381
477
|
* introspection path (offline-view parity is a follow-up).
|
|
382
478
|
*/
|
|
383
|
-
export async function runOfflineGenerate(config, metaRoot) {
|
|
479
|
+
export async function runOfflineGenerate(config, metaRoot, fmt = "text") {
|
|
384
480
|
if (config.dialect === undefined) {
|
|
385
481
|
log.error(`migrate: --dialect required for offline generation (or use --from-db)`);
|
|
386
482
|
return 2;
|
|
@@ -404,7 +500,9 @@ export async function runOfflineGenerate(config, metaRoot) {
|
|
|
404
500
|
return 2;
|
|
405
501
|
}
|
|
406
502
|
if (snapshot === null) {
|
|
407
|
-
log.error(`migrate: no schema snapshot at ${path}; run
|
|
503
|
+
log.error(`migrate: no schema snapshot at ${path}; run \`meta migrate baseline --dialect ${config.dialect}\` first`);
|
|
504
|
+
// Structured next-step on stdout so callers / agents can parse it, in the active format.
|
|
505
|
+
emitStructuredError("no schema snapshot", `first run \`meta migrate baseline --dialect ${config.dialect}\``, fmt);
|
|
408
506
|
return 2;
|
|
409
507
|
}
|
|
410
508
|
const collectedAmbiguous = [];
|
|
@@ -522,7 +620,7 @@ async function runRollback(config, metaRoot) {
|
|
|
522
620
|
}
|
|
523
621
|
}
|
|
524
622
|
}
|
|
525
|
-
async function runD1Migrate(config, metaRoot, runner) {
|
|
623
|
+
async function runD1Migrate(config, metaRoot, runner, _fmt = "text") {
|
|
526
624
|
// 1. Resolve wrangler.toml + binding.
|
|
527
625
|
const wranglerConfigPath = config.d1.wranglerConfigPath
|
|
528
626
|
? resolvePath(metaRoot, config.d1.wranglerConfigPath)
|