@metaobjectsdev/cli 0.23.2 → 0.24.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 +78 -0
- package/dist/src/commands/docs.d.ts.map +1 -1
- package/dist/src/commands/docs.js +157 -59
- package/dist/src/commands/docs.js.map +1 -1
- package/dist/src/commands/export.d.ts.map +1 -1
- package/dist/src/commands/export.js +28 -6
- package/dist/src/commands/export.js.map +1 -1
- package/dist/src/commands/gen.d.ts.map +1 -1
- package/dist/src/commands/gen.js +66 -22
- package/dist/src/commands/gen.js.map +1 -1
- package/dist/src/commands/init.d.ts +8 -0
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +142 -52
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/migrate.d.ts +9 -2
- package/dist/src/commands/migrate.d.ts.map +1 -1
- package/dist/src/commands/migrate.js +399 -73
- package/dist/src/commands/migrate.js.map +1 -1
- package/dist/src/commands/prompt-snapshot.d.ts.map +1 -1
- package/dist/src/commands/prompt-snapshot.js +40 -18
- package/dist/src/commands/prompt-snapshot.js.map +1 -1
- package/dist/src/commands/upgrade.d.ts +2 -0
- package/dist/src/commands/upgrade.d.ts.map +1 -0
- package/dist/src/commands/upgrade.js +163 -0
- package/dist/src/commands/upgrade.js.map +1 -0
- package/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +360 -48
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +55 -19
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/allow.d.ts.map +1 -1
- package/dist/src/lib/allow.js +4 -0
- package/dist/src/lib/allow.js.map +1 -1
- package/dist/src/lib/args.d.ts +16 -2
- package/dist/src/lib/args.d.ts.map +1 -1
- package/dist/src/lib/args.js +19 -3
- package/dist/src/lib/args.js.map +1 -1
- package/dist/src/lib/codegen-drift.d.ts +8 -1
- package/dist/src/lib/codegen-drift.d.ts.map +1 -1
- package/dist/src/lib/codegen-drift.js +9 -1
- package/dist/src/lib/codegen-drift.js.map +1 -1
- package/dist/src/lib/detect-stack.d.ts +1 -1
- package/dist/src/lib/detect-stack.d.ts.map +1 -1
- package/dist/src/lib/detect-stack.js +22 -37
- package/dist/src/lib/detect-stack.js.map +1 -1
- package/dist/src/lib/load-metaobjects-config.d.ts +79 -1
- package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
- package/dist/src/lib/load-metaobjects-config.js +123 -1
- package/dist/src/lib/load-metaobjects-config.js.map +1 -1
- package/dist/src/lib/manifest-ignored-check.d.ts +12 -0
- package/dist/src/lib/manifest-ignored-check.d.ts.map +1 -0
- package/dist/src/lib/manifest-ignored-check.js +74 -0
- package/dist/src/lib/manifest-ignored-check.js.map +1 -0
- package/dist/src/lib/migrate-scope.d.ts +49 -0
- package/dist/src/lib/migrate-scope.d.ts.map +1 -0
- package/dist/src/lib/migrate-scope.js +92 -0
- package/dist/src/lib/migrate-scope.js.map +1 -0
- package/dist/src/lib/output.d.ts +1 -1
- package/dist/src/lib/output.d.ts.map +1 -1
- package/dist/src/lib/output.js +9 -3
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/requirement-check.d.ts.map +1 -1
- package/dist/src/lib/requirement-check.js +10 -57
- package/dist/src/lib/requirement-check.js.map +1 -1
- package/package.json +11 -11
- package/src/commands/docs.ts +174 -61
- package/src/commands/export.ts +30 -6
- package/src/commands/gen.ts +68 -22
- package/src/commands/init.ts +161 -58
- package/src/commands/migrate.ts +423 -75
- package/src/commands/prompt-snapshot.ts +43 -18
- package/src/commands/upgrade.ts +179 -0
- package/src/commands/verify.ts +397 -48
- package/src/index.ts +55 -19
- package/src/lib/allow.ts +4 -0
- package/src/lib/args.ts +35 -4
- package/src/lib/codegen-drift.ts +9 -0
- package/src/lib/detect-stack.ts +20 -33
- package/src/lib/load-metaobjects-config.ts +129 -2
- package/src/lib/manifest-ignored-check.ts +75 -0
- package/src/lib/migrate-scope.ts +102 -0
- package/src/lib/output.ts +8 -4
- package/src/lib/requirement-check.ts +10 -58
- package/dist/src/lib/verified-by-scan.d.ts +0 -20
- package/dist/src/lib/verified-by-scan.d.ts.map +0 -1
- package/dist/src/lib/verified-by-scan.js +0 -344
- package/dist/src/lib/verified-by-scan.js.map +0 -1
- package/src/lib/verified-by-scan.ts +0 -375
package/src/commands/migrate.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolve as resolvePath } from "node:path";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import { spawn } from "node:child_process";
|
|
4
5
|
import { parseMigrateArgs } from "../lib/args.js";
|
|
5
6
|
import { resolveMigrateConfig, MIGRATE_DEFAULT_OUT_DIR } from "../lib/config.js";
|
|
@@ -10,13 +11,17 @@ import type { OutputFormat } from "../lib/format.js";
|
|
|
10
11
|
import { toonEncode } from "../lib/format.js";
|
|
11
12
|
import { buildKyselyFromUrl, redactUrl } from "../lib/kysely.js";
|
|
12
13
|
import { log } from "../lib/log.js";
|
|
13
|
-
import { loadMemory } from "@metaobjectsdev/sdk";
|
|
14
|
-
import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js";
|
|
14
|
+
import { loadMemory, resolveCollection, resolveConfigDir, type Collection } from "@metaobjectsdev/sdk";
|
|
15
|
+
import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
|
|
16
|
+
import { migrateScopeMismatch, outOfScopeNote } from "../lib/migrate-scope.js";
|
|
15
17
|
import {
|
|
16
|
-
|
|
18
|
+
buildExpectedSchemaWithProvenance,
|
|
19
|
+
scopeExpectedSchema,
|
|
20
|
+
scopedDiffInputs,
|
|
17
21
|
introspect,
|
|
18
22
|
diff,
|
|
19
23
|
collectUnmanagedNames,
|
|
24
|
+
carryForwardOutOfScope,
|
|
20
25
|
emit,
|
|
21
26
|
writeMigration,
|
|
22
27
|
baselineFromMetadata,
|
|
@@ -24,6 +29,7 @@ import {
|
|
|
24
29
|
snapshotPath,
|
|
25
30
|
readSnapshot,
|
|
26
31
|
writeSnapshot,
|
|
32
|
+
qualifiedDbName,
|
|
27
33
|
BlockedChangesError,
|
|
28
34
|
PrimaryKeyChangeError,
|
|
29
35
|
renderD1,
|
|
@@ -41,6 +47,9 @@ import {
|
|
|
41
47
|
type D1Binding,
|
|
42
48
|
type EmitResult,
|
|
43
49
|
type D1Runner,
|
|
50
|
+
type SchemaProvenance,
|
|
51
|
+
type SchemaSnapshot,
|
|
52
|
+
type TableDescriptor,
|
|
44
53
|
} from "@metaobjectsdev/migrate-ts";
|
|
45
54
|
import {
|
|
46
55
|
buildWranglerExecuteArgs,
|
|
@@ -60,8 +69,11 @@ SUBCOMMANDS:
|
|
|
60
69
|
(use with --from-db). NOTE: for a brand-new/empty database use
|
|
61
70
|
the greenfield example below, NOT baseline — an offline baseline
|
|
62
71
|
records your metadata as already-applied and emits no CREATE TABLE.
|
|
63
|
-
apply-pending Replay committed migration files against --db (no diff)
|
|
64
|
-
|
|
72
|
+
apply-pending Replay committed migration files against --db (no diff).
|
|
73
|
+
Provisions a fresh/CI database when the chain BUILDS the
|
|
74
|
+
schema — 'meta verify --replay' proves that it does. A
|
|
75
|
+
database adopted via 'baseline --from-db' has no such
|
|
76
|
+
chain. postgres/sqlite only.
|
|
65
77
|
|
|
66
78
|
MIGRATE FLAGS:
|
|
67
79
|
--db <url> DB connection URL (required for live-introspect / --apply / --rollback)
|
|
@@ -81,7 +93,13 @@ MIGRATE FLAGS:
|
|
|
81
93
|
--allow <csv> Comma-separated destructive-change permissions:
|
|
82
94
|
drop-column,drop-table,type-change,drop-index,drop-fk,
|
|
83
95
|
drop-check,drop-view,drop-view-cascade,
|
|
84
|
-
adopt-view,nullable-to-not-null,drop-identity-default
|
|
96
|
+
adopt-view,nullable-to-not-null,drop-identity-default,
|
|
97
|
+
drop-unmanaged
|
|
98
|
+
drop-unmanaged permits dropping a table/view the committed
|
|
99
|
+
snapshot never contained — one this toolchain never managed.
|
|
100
|
+
Without it that drop is refused, because the migration it
|
|
101
|
+
writes cannot replay against a database where the object
|
|
102
|
+
never existed.
|
|
85
103
|
--on-ambiguous abort|rename|drop-add
|
|
86
104
|
How to handle ambiguous renames (default: abort)
|
|
87
105
|
--from-db Introspect live DB instead of using the committed snapshot
|
|
@@ -122,6 +140,74 @@ function resolveFormatOutDir(config: ResolvedMigrateConfig, metaRoot: string): s
|
|
|
122
140
|
return resolvePath(metaRoot, config.outDir);
|
|
123
141
|
}
|
|
124
142
|
|
|
143
|
+
/**
|
|
144
|
+
* D1's OWN directory convention — `--out-dir` > `wrangler.toml`'s
|
|
145
|
+
* `migrations_dir` > `"migrations"` — kept apart from `resolveFormatOutDir`
|
|
146
|
+
* because the middle term is not knowable until a wrangler binding has been
|
|
147
|
+
* resolved (`runD1Migrate` step 1), while every other dialect can answer
|
|
148
|
+
* immediately from `config` alone.
|
|
149
|
+
*/
|
|
150
|
+
function resolveD1OutDir(
|
|
151
|
+
config: ResolvedMigrateConfig,
|
|
152
|
+
metaRoot: string,
|
|
153
|
+
migrationsDirHint: string | undefined,
|
|
154
|
+
): string {
|
|
155
|
+
const isDefaultOutDir = config.outDir === MIGRATE_DEFAULT_OUT_DIR;
|
|
156
|
+
return resolvePath(metaRoot, isDefaultOutDir ? (migrationsDirHint ?? "migrations") : config.outDir);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Say so when the migrations directory this run will use is NOT the one sitting
|
|
161
|
+
* in the working directory.
|
|
162
|
+
*
|
|
163
|
+
* `metaRoot` is now the discovered project root rather than ambient cwd, and the
|
|
164
|
+
* migrations directory follows it. That is the right call — the ledger belongs
|
|
165
|
+
* with the config that declares it — but it is a behaviour change for the two
|
|
166
|
+
* subcommands that load no metadata at all: `apply-pending` and `--rollback`
|
|
167
|
+
* used cwd unconditionally, so a subdirectory holding `.metaobjects/migrations`
|
|
168
|
+
* under a project root that also has one now replays the ROOT's ledger.
|
|
169
|
+
* Replaying somebody else's migration history silently is the worst outcome
|
|
170
|
+
* available here, so it is announced.
|
|
171
|
+
*
|
|
172
|
+
* Conditioned on the local directory EXISTING, so the ordinary case — a run from
|
|
173
|
+
* anywhere inside a project with one ledger at its root — says nothing.
|
|
174
|
+
* `--out-dir` (and a `migrate.outDir` in the config) is honoured: the caller
|
|
175
|
+
* must pass the directory THIS run will actually WRITE to, never a default
|
|
176
|
+
* that was overridden — a deliberate redirection is compared, not a stale
|
|
177
|
+
* guess. That answer comes from **two** call sites, because there are two
|
|
178
|
+
* directory conventions: every dialect but d1 resolves via
|
|
179
|
+
* `resolveFormatOutDir` before the format/dialect dispatch below; d1 has its
|
|
180
|
+
* own convention (`resolveD1OutDir`, wrangler.toml's `migrations_dir`) that is
|
|
181
|
+
* unknowable until its binding resolves, so it calls this again for itself
|
|
182
|
+
* from inside `runD1Migrate`, once that binding is in hand.
|
|
183
|
+
*/
|
|
184
|
+
function warnIfLedgerRelocated(cwd: string, resolvedOutDir: string): void {
|
|
185
|
+
const local = resolvePath(cwd, MIGRATE_DEFAULT_OUT_DIR);
|
|
186
|
+
if (resolvedOutDir === local || !existsSync(local)) return;
|
|
187
|
+
log.warn(
|
|
188
|
+
`migrate: using the migrations directory ${resolvedOutDir}, not the ${local} ` +
|
|
189
|
+
`in this working directory — the ledger belongs to the project root that declares it. ` +
|
|
190
|
+
`Pass --out-dir ${local} to use the local one.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Report what a declared `migrate.scope` left out (wording: `outOfScopeNote`).
|
|
196
|
+
*
|
|
197
|
+
* STDOUT in text format, STDERR otherwise. `--format json` / `--format toon` put a
|
|
198
|
+
* single machine-readable document on stdout, and a prose line ahead of it breaks
|
|
199
|
+
* `| jq` outright — the same split `emitStructuredError` makes just below.
|
|
200
|
+
* Routed to stderr rather than dropped, because the non-TTY default format is toon
|
|
201
|
+
* (`resolveFormat`): suppressing it outright would silence the note for every
|
|
202
|
+
* piped and CI run, which is most of them.
|
|
203
|
+
*/
|
|
204
|
+
function logOutOfScope(names: readonly string[], fmt: OutputFormat): void {
|
|
205
|
+
if (names.length === 0) return;
|
|
206
|
+
const msg = outOfScopeNote("migrate", names);
|
|
207
|
+
if (fmt === "text") log.info(msg);
|
|
208
|
+
else log.warn(msg);
|
|
209
|
+
}
|
|
210
|
+
|
|
125
211
|
function emitStructuredError(error: string, hint: string, fmt: OutputFormat): void {
|
|
126
212
|
const payload = { error, hint };
|
|
127
213
|
if (fmt === "json") {
|
|
@@ -132,6 +218,27 @@ function emitStructuredError(error: string, hint: string, fmt: OutputFormat): vo
|
|
|
132
218
|
// text format: errors go to stderr via log.error() — the caller handles that path
|
|
133
219
|
}
|
|
134
220
|
|
|
221
|
+
/**
|
|
222
|
+
* The refusal for a `migrate.scope` that matches nothing, as all three of migrate's
|
|
223
|
+
* pipelines (online, offline, D1) issue it.
|
|
224
|
+
*
|
|
225
|
+
* Returns the exit code to return, or `undefined` when there is nothing to refuse.
|
|
226
|
+
* Three byte-identical copies of the report-and-exit differed only in a local
|
|
227
|
+
* variable name; the hint string and the exit code are one decision, recorded once
|
|
228
|
+
* — a configuration error, so exit 2.
|
|
229
|
+
*/
|
|
230
|
+
function refuseScopeMismatch(
|
|
231
|
+
collection: Collection,
|
|
232
|
+
provenance: () => SchemaProvenance,
|
|
233
|
+
fmt: OutputFormat,
|
|
234
|
+
): number | undefined {
|
|
235
|
+
const mismatch = migrateScopeMismatch(collection, provenance);
|
|
236
|
+
if (mismatch === undefined) return undefined;
|
|
237
|
+
log.error(`migrate: ${mismatch}`);
|
|
238
|
+
emitStructuredError(`migrate: ${mismatch}`, "fix or remove migrate.scope in .metaobjects/config.json", fmt);
|
|
239
|
+
return 2;
|
|
240
|
+
}
|
|
241
|
+
|
|
135
242
|
/**
|
|
136
243
|
* Sentinel thrown by sub-functions that have already emitted a structured error
|
|
137
244
|
* via emitStructuredError(). The top-level catch in migrateCommand re-throws
|
|
@@ -196,6 +303,71 @@ function summarizeChanges(changes: Change[]): Record<string, number> {
|
|
|
196
303
|
return counts;
|
|
197
304
|
}
|
|
198
305
|
|
|
306
|
+
/**
|
|
307
|
+
* The qualified names of tables/views/constraints this diff proposes to DROP that
|
|
308
|
+
* the committed snapshot never contained — i.e. objects this toolchain never
|
|
309
|
+
* managed (#313).
|
|
310
|
+
*
|
|
311
|
+
* FAILS OPEN. No snapshot on disk, or one that cannot be read, yields an empty list:
|
|
312
|
+
* a project that has never generated one is not in an error state, and refusing there
|
|
313
|
+
* would break the first `meta migrate` of every greenfield project. A parse failure is
|
|
314
|
+
* migrate's own error to raise elsewhere, with its own message, not a silent refusal
|
|
315
|
+
* here.
|
|
316
|
+
*
|
|
317
|
+
* Table/view names come from `qualifiedDbName` and nothing else. Three independent
|
|
318
|
+
* sets already have to agree on this spelling — the diff's identity maps, the
|
|
319
|
+
* `@unmanaged` exclusion set, and the out-of-scope set — and a fourth encoding of
|
|
320
|
+
* "absent schema means public" would silently un-guard every object it disagreed
|
|
321
|
+
* about.
|
|
322
|
+
*
|
|
323
|
+
* `drop-fk`/`drop-check`/constraint-backed `drop-index` are checked at the
|
|
324
|
+
* CONSTRAINT grain, not just the table's: the emitter's `IF EXISTS` on the
|
|
325
|
+
* enclosing `ALTER TABLE` (the SQL half of this same #313 gap) only stops the
|
|
326
|
+
* replay from failing outright — it says nothing about whether AUTHORING the
|
|
327
|
+
* drop was ever supposed to be permission-free. A table can be fully managed
|
|
328
|
+
* while carrying a constraint another tool added directly against the live
|
|
329
|
+
* database; that constraint's name is absent from the snapshotted table's own
|
|
330
|
+
* `foreignKeys`/`checks`/`indexes`, exactly like a whole unmanaged table is
|
|
331
|
+
* absent from `snapshot.tables`.
|
|
332
|
+
*/
|
|
333
|
+
async function snapshotAbsentDrops(changes: Change[], snapPath: string): Promise<string[]> {
|
|
334
|
+
let snapshot: SchemaSnapshot | null;
|
|
335
|
+
try {
|
|
336
|
+
snapshot = await readSnapshot(snapPath);
|
|
337
|
+
} catch {
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
if (snapshot === null) return [];
|
|
341
|
+
|
|
342
|
+
const managedTables = new Map<string, TableDescriptor>();
|
|
343
|
+
for (const t of snapshot.tables) managedTables.set(qualifiedDbName(t), t);
|
|
344
|
+
const managedViews = new Set<string>();
|
|
345
|
+
for (const v of snapshot.views) managedViews.add(qualifiedDbName(v));
|
|
346
|
+
|
|
347
|
+
const absent: string[] = [];
|
|
348
|
+
for (const c of changes) {
|
|
349
|
+
if (c.kind === "drop-table") {
|
|
350
|
+
const name = qualifiedDbName({ name: c.table, schema: c.schema });
|
|
351
|
+
if (!managedTables.has(name)) absent.push(name);
|
|
352
|
+
} else if (c.kind === "drop-view") {
|
|
353
|
+
const name = qualifiedDbName({ name: c.view, schema: c.schema });
|
|
354
|
+
if (!managedViews.has(name)) absent.push(name);
|
|
355
|
+
} else if (c.kind === "drop-fk" || c.kind === "drop-check" || c.kind === "drop-index") {
|
|
356
|
+
const tableName = qualifiedDbName({ name: c.table, schema: c.schema });
|
|
357
|
+
const table = managedTables.get(tableName);
|
|
358
|
+
const constraintName = c.kind === "drop-fk" ? c.fk : c.kind === "drop-check" ? c.check : c.index;
|
|
359
|
+
const recorded =
|
|
360
|
+
c.kind === "drop-fk" ? table?.foreignKeys
|
|
361
|
+
: c.kind === "drop-check" ? table?.checks
|
|
362
|
+
: table?.indexes;
|
|
363
|
+
if (recorded === undefined || !recorded.some((d) => d.name === constraintName)) {
|
|
364
|
+
absent.push(`${tableName}.${constraintName}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return absent;
|
|
369
|
+
}
|
|
370
|
+
|
|
199
371
|
function allowFlagFor(kind: string): string {
|
|
200
372
|
switch (kind) {
|
|
201
373
|
case "drop-column": return "drop-column";
|
|
@@ -257,8 +429,56 @@ export async function migrateCommand(
|
|
|
257
429
|
return 2;
|
|
258
430
|
}
|
|
259
431
|
|
|
260
|
-
|
|
432
|
+
// The project root is the directory whose `.metaobjects/config.json` governs
|
|
433
|
+
// this run — the same directory `resolveCollection` resolves the metadata
|
|
434
|
+
// from, found the same way (design §4.6.1: "Per-port generator config is then
|
|
435
|
+
// read from that same directory"). Everything below is relative to it: the
|
|
436
|
+
// `.metaobjects/config.json` operational block, `metaobjects.config.ts`'s
|
|
437
|
+
// `columnNamingStrategy`, the migrations `outDir`, `wrangler.toml` discovery.
|
|
438
|
+
//
|
|
439
|
+
// Read from ambient cwd instead, as this did, they DIVERGE the moment the two
|
|
440
|
+
// differ: run `meta migrate` from a subdirectory of a project whose root
|
|
441
|
+
// declares `columnNamingStrategy: "literal"` and the metadata resolves from
|
|
442
|
+
// the ancestor while the strategy silently defaults to snake_case — emitting a
|
|
443
|
+
// migration that renames every column. Newly reachable, too: before metadata
|
|
444
|
+
// sources were resolvable that invocation just failed with "no metaobjects/
|
|
445
|
+
// found".
|
|
446
|
+
//
|
|
447
|
+
// `resolveConfigDir` rather than `resolveCollection` deliberately: this must
|
|
448
|
+
// not require metadata to EXIST. `migrate apply-pending` and `--rollback`
|
|
449
|
+
// replay committed SQL and load no metadata at all, and making them fail on a
|
|
450
|
+
// project with no model would be a regression. It is the SAME walk
|
|
451
|
+
// `resolveCollection` runs (one exported definition in the sdk's
|
|
452
|
+
// `discovery.ts`, not two that agree by construction), so the directory this
|
|
453
|
+
// resolves and the directory the metadata comes from cannot diverge.
|
|
454
|
+
const metaRoot = await resolveConfigDir(cwd);
|
|
455
|
+
// `metaobjects.config.ts` is the one thing here NOT governed by that walk. It
|
|
456
|
+
// answers a different question from `.metaobjects/config.json` (design §4.6 —
|
|
457
|
+
// per-port "how is code generated here?" versus port-neutral "where does metadata
|
|
458
|
+
// come from?"), and in a Maven- or pip-rooted monorepo the two legitimately sit in
|
|
459
|
+
// different directories. Reading it from `metaRoot` there silently defaults
|
|
460
|
+
// `columnNamingStrategy` for a package that declares one — the exact
|
|
461
|
+
// rename-every-column failure the co-location was introduced to prevent, arrived
|
|
462
|
+
// at from the other side (#326). Nearest wins, so a subdirectory declaring nothing
|
|
463
|
+
// still walks up to the project root's config; identical to `metaRoot` in every
|
|
464
|
+
// `meta init` project, where the two files sit together.
|
|
465
|
+
const genRoot = resolveGenConfigDir(cwd, metaRoot);
|
|
261
466
|
const config = await resolveMigrateConfig(flags, metaRoot);
|
|
467
|
+
// `resolveFormatOutDir`, not `resolvePath(metaRoot, config.outDir)`: under
|
|
468
|
+
// `--migration-format flyway` with a default outDir the run writes to
|
|
469
|
+
// Flyway's conventional location instead, so the unredirected path names a
|
|
470
|
+
// directory this invocation will never touch.
|
|
471
|
+
//
|
|
472
|
+
// Skipped for a plain `--dialect d1` run (format !== flyway): d1 resolves
|
|
473
|
+
// its OWN directory from wrangler.toml, unknowable until its binding
|
|
474
|
+
// resolves, so it issues this warning for itself from inside
|
|
475
|
+
// `runD1Migrate` instead — `resolveFormatOutDir` here would name the
|
|
476
|
+
// Kysely-path default, a directory that run never writes to. A d1 +
|
|
477
|
+
// `--migration-format flyway` run is refused just below before either
|
|
478
|
+
// directory is ever touched, so THAT combination still wants this one.
|
|
479
|
+
if (config.dialect !== "d1" || config.format === "flyway") {
|
|
480
|
+
warnIfLedgerRelocated(cwd, resolveFormatOutDir(config, metaRoot));
|
|
481
|
+
}
|
|
262
482
|
|
|
263
483
|
try {
|
|
264
484
|
// #192 — Flyway owns apply + history (flyway_schema_history). We generate the
|
|
@@ -341,12 +561,12 @@ export async function migrateCommand(
|
|
|
341
561
|
);
|
|
342
562
|
return 2;
|
|
343
563
|
}
|
|
344
|
-
return await runD1Migrate(config, metaRoot, wranglerRunner ?? defaultWranglerRunner, fmt);
|
|
564
|
+
return await runD1Migrate(config, metaRoot, cwd, wranglerRunner ?? defaultWranglerRunner, fmt);
|
|
345
565
|
}
|
|
346
566
|
|
|
347
567
|
// `migrate baseline` — seed the committed reference snapshot, emit no migration.
|
|
348
568
|
if (config.baseline) {
|
|
349
|
-
return await runBaseline(config, metaRoot, fmt);
|
|
569
|
+
return await runBaseline(config, metaRoot, fmt, genRoot);
|
|
350
570
|
}
|
|
351
571
|
|
|
352
572
|
// `migrate apply-pending` — replay committed migration files; no diff, no metadata load.
|
|
@@ -358,7 +578,7 @@ export async function migrateCommand(
|
|
|
358
578
|
// when explicitly requested via --from-db, when --apply needs a connection, or
|
|
359
579
|
// for --rollback (which runs hand-authored down.sql against the live DB).
|
|
360
580
|
if (!config.fromDb && !config.apply && config.rollback === undefined) {
|
|
361
|
-
return await runOfflineGenerate(config, metaRoot, fmt);
|
|
581
|
+
return await runOfflineGenerate(config, metaRoot, fmt, genRoot);
|
|
362
582
|
}
|
|
363
583
|
|
|
364
584
|
if (config.databaseUrl === undefined) {
|
|
@@ -381,26 +601,34 @@ export async function migrateCommand(
|
|
|
381
601
|
// Best-effort load of metaobjects.config.ts to pick up consumer-supplied
|
|
382
602
|
// providers. migrate's postgres/sqlite path also reads the config later
|
|
383
603
|
// for columnNamingStrategy; we load it once here and reuse below.
|
|
384
|
-
let
|
|
604
|
+
let postgresLoadOptions: ReturnType<typeof loadMemoryOptionsFrom> = {};
|
|
385
605
|
try {
|
|
386
|
-
|
|
387
|
-
postgresConfigProviders = forgeConfig.providers;
|
|
606
|
+
postgresLoadOptions = loadMemoryOptionsFrom(await loadMetaobjectsConfig(genRoot));
|
|
388
607
|
} catch {
|
|
389
|
-
|
|
608
|
+
postgresLoadOptions = {};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Discovery and load are two separate failure modes, kept in separate try blocks
|
|
612
|
+
// (the `meta gen` pattern): a broad catch around both reports a genuine ParseError
|
|
613
|
+
// as "no metadata found", masking the real failure. `resolveCollection` raises
|
|
614
|
+
// ERR_COLLECTION_NOT_FOUND with its own message — the same exit 2 the hand-rolled
|
|
615
|
+
// ENOENT sniff used to produce.
|
|
616
|
+
let collection;
|
|
617
|
+
try {
|
|
618
|
+
collection = await resolveCollection(metaRoot);
|
|
619
|
+
} catch (err) {
|
|
620
|
+
log.error((err as Error).message);
|
|
621
|
+
return 2;
|
|
390
622
|
}
|
|
391
623
|
|
|
392
624
|
let metadata;
|
|
393
625
|
try {
|
|
394
|
-
metadata = await loadMemory(
|
|
395
|
-
|
|
626
|
+
metadata = await loadMemory(collection.configDir, {
|
|
627
|
+
files: collection.files,
|
|
628
|
+
...postgresLoadOptions,
|
|
396
629
|
});
|
|
397
630
|
} catch (err) {
|
|
398
|
-
|
|
399
|
-
if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
|
|
400
|
-
log.error(`no metaobjects/ found in ${metaRoot}; run 'meta init' to scaffold`);
|
|
401
|
-
} else {
|
|
402
|
-
log.error(`failed to load metadata: ${msg}`);
|
|
403
|
-
}
|
|
631
|
+
log.error(`failed to load metadata: ${(err as Error).message}`);
|
|
404
632
|
return 2;
|
|
405
633
|
}
|
|
406
634
|
|
|
@@ -425,7 +653,7 @@ export async function migrateCommand(
|
|
|
425
653
|
// and projection view DDL — derive it once, up front, so every view path agrees.
|
|
426
654
|
let columnNamingStrategy: "snake_case" | "literal" | "kebab-case" = "snake_case";
|
|
427
655
|
try {
|
|
428
|
-
const cfg = await loadMetaobjectsConfig(
|
|
656
|
+
const cfg = await loadMetaobjectsConfig(genRoot);
|
|
429
657
|
if (cfg.columnNamingStrategy) columnNamingStrategy = cfg.columnNamingStrategy;
|
|
430
658
|
} catch {
|
|
431
659
|
// metaobjects.config.ts absent or invalid — use default snake_case
|
|
@@ -435,11 +663,20 @@ export async function migrateCommand(
|
|
|
435
663
|
// view DDL (create/drop/replace + dependency-recreate) and emit() renders it —
|
|
436
664
|
// there is no separate view-migration emitter.
|
|
437
665
|
const expectedViews = buildProjectionViews(metadata, { dialect: kysely.dialect, columnNamingStrategy });
|
|
438
|
-
const
|
|
666
|
+
const built = buildExpectedSchemaWithProvenance(metadata, {
|
|
439
667
|
dialect: kysely.dialect,
|
|
440
668
|
columnNamingStrategy,
|
|
441
669
|
views: expectedViews,
|
|
442
670
|
});
|
|
671
|
+
const scopeRc = refuseScopeMismatch(collection, () => built.provenance, fmt);
|
|
672
|
+
if (scopeRc !== undefined) return scopeRc;
|
|
673
|
+
// Per-command scope: objects outside `migrate.scope` are another owner's. They
|
|
674
|
+
// leave the expected schema here and are suppressed on the actual side below —
|
|
675
|
+
// dropping them from `expected` ALONE would propose DROP TABLE for every one of
|
|
676
|
+
// them that exists in the database.
|
|
677
|
+
const scoped = scopeExpectedSchema(built, collection.inMigrateScope);
|
|
678
|
+
const expected = scoped.snapshot;
|
|
679
|
+
logOutOfScope(scoped.outOfScope, fmt);
|
|
443
680
|
let actual;
|
|
444
681
|
try {
|
|
445
682
|
actual = await introspect(kysely.db, kysely.dialect);
|
|
@@ -455,7 +692,12 @@ export async function migrateCommand(
|
|
|
455
692
|
let diffResult;
|
|
456
693
|
try {
|
|
457
694
|
diffResult = await diff({
|
|
458
|
-
|
|
695
|
+
// The three scoped-diff obligations as one value (migrate-ts's scope.ts
|
|
696
|
+
// header has the mechanism): the narrowed expected side, `unmanagedNames`
|
|
697
|
+
// merging #208 §7's declared-@unmanaged set with the out-of-scope names so
|
|
698
|
+
// neither is created or dropped, and the schema scope pinned to the
|
|
699
|
+
// UNSCOPED model so narrowing can never widen the run.
|
|
700
|
+
...scopedDiffInputs(scoped, collectUnmanagedNames(metadata)),
|
|
459
701
|
actual,
|
|
460
702
|
dialect: kysely.dialect,
|
|
461
703
|
allow: tokensToAllowOptions(config.allow),
|
|
@@ -463,9 +705,6 @@ export async function migrateCommand(
|
|
|
463
705
|
// has no expressible migration; refuse loudly instead of emitting SQL that drops
|
|
464
706
|
// the constraint and breaks referencing FKs at apply.
|
|
465
707
|
refusePrimaryKeyChange: true,
|
|
466
|
-
// #208 §7 — declared-@unmanaged objects are external: exclude them from the
|
|
467
|
-
// actual side so migrate proposes neither create nor drop for them.
|
|
468
|
-
unmanagedNames: collectUnmanagedNames(metadata),
|
|
469
708
|
onAmbiguous: async (a) => {
|
|
470
709
|
collectedAmbiguous.push(a);
|
|
471
710
|
return onAmbiguousResolution;
|
|
@@ -507,6 +746,44 @@ export async function migrateCommand(
|
|
|
507
746
|
|
|
508
747
|
changeCounts = summarizeChanges(diffResult.changes);
|
|
509
748
|
|
|
749
|
+
// #313 — refuse to AUTHOR a drop for an object the committed snapshot never
|
|
750
|
+
// contained. This path diffs metadata against introspection and never reads the
|
|
751
|
+
// snapshot, so an object another tool owns reads as "in the DB, not in the model"
|
|
752
|
+
// and is proposed for a drop; the migration that results cannot replay against a
|
|
753
|
+
// database where that object never existed, which is how a chain stays broken for
|
|
754
|
+
// months. `classify.ts` already states the doctrine — objects present in the DB
|
|
755
|
+
// but not the snapshot "must never be treated as actionable drift or
|
|
756
|
+
// auto-dropped" — and this is where it is finally enforced.
|
|
757
|
+
//
|
|
758
|
+
// It does not false-fire on the brownfield cases, because both of them ADD to the
|
|
759
|
+
// snapshot: a `baseline --from-db` snapshot contains the foreign table, and a
|
|
760
|
+
// scoped project carries its out-of-scope entries forward. The guard fires
|
|
761
|
+
// precisely when nothing ever claimed the object.
|
|
762
|
+
//
|
|
763
|
+
// Only on THIS path, and that is not an omission: the offline path diffs metadata
|
|
764
|
+
// against the committed snapshot, so it proposes a drop only for an object the
|
|
765
|
+
// snapshot HAS. A snapshot-absent drop is unreachable there by construction.
|
|
766
|
+
const unmanagedDrops = await snapshotAbsentDrops(
|
|
767
|
+
diffResult.changes,
|
|
768
|
+
snapshotPath(resolvePath(metaRoot, config.outDir), kysely.dialect),
|
|
769
|
+
);
|
|
770
|
+
if (unmanagedDrops.length > 0 && tokensToAllowOptions(config.allow).dropUnmanaged !== true) {
|
|
771
|
+
const named = unmanagedDrops.join(", ");
|
|
772
|
+
log.error(
|
|
773
|
+
`migrate: refusing to drop ${named} — absent from the committed schema snapshot, so this ` +
|
|
774
|
+
`toolchain never managed ${unmanagedDrops.length === 1 ? "it" : "them"} and the migration ` +
|
|
775
|
+
`could not replay against a database where ${unmanagedDrops.length === 1 ? "it" : "they"} ` +
|
|
776
|
+
`never existed. Re-run with '--allow drop-unmanaged' if the drop is intended.`,
|
|
777
|
+
);
|
|
778
|
+
emitStructuredError(
|
|
779
|
+
`migrate: refusing to drop ${named} — absent from the committed schema snapshot`,
|
|
780
|
+
"re-run with '--allow drop-unmanaged' if the drop is intended",
|
|
781
|
+
fmt,
|
|
782
|
+
);
|
|
783
|
+
await kysely.close();
|
|
784
|
+
return 2;
|
|
785
|
+
}
|
|
786
|
+
|
|
510
787
|
// All changes — tables AND views — are emitted by the one schema-diff path.
|
|
511
788
|
// View DDL (create/drop/replace) is produced by diff()'s view passes (2b body
|
|
512
789
|
// comparison, 2c dependency-recreate) and rendered by every dialect's emitter;
|
|
@@ -610,9 +887,25 @@ export async function migrateCommand(
|
|
|
610
887
|
// native ALTER vs recreate-and-copy on older SQLite.
|
|
611
888
|
if (!config.dryRun && exitCode === 0 && !applyFailed && writtenPaths.length > 0) {
|
|
612
889
|
try {
|
|
890
|
+
// The COMMITTED snapshot keeps what `migrate.scope` excluded: writing the
|
|
891
|
+
// narrowed schema would delete every out-of-scope entry, so a later widening
|
|
892
|
+
// would propose CREATE TABLE for a table that exists and fail at apply. The
|
|
893
|
+
// out-of-scope entries come from `actual` — they are in the database, which
|
|
894
|
+
// is the same thing `baseline --from-db` records. Identical object, and so a
|
|
895
|
+
// byte-identical snapshot, for an unscoped run.
|
|
896
|
+
//
|
|
897
|
+
// The trade-off, stated so it is not rediscovered: those carried entries are
|
|
898
|
+
// INTROSPECTED descriptors, not metadata-built ones, so they can differ
|
|
899
|
+
// cosmetically from what this model would have emitted for the same table
|
|
900
|
+
// (column order, a default's rendered form). Removing the scope later can
|
|
901
|
+
// therefore produce one round of alter churn. That is strictly better than
|
|
902
|
+
// the alternative it replaced — a `CREATE TABLE` for a table that exists,
|
|
903
|
+
// which fails at apply — and it is the same mixed-provenance snapshot
|
|
904
|
+
// `baseline --from-db` writes for every table it adopts.
|
|
905
|
+
const committed = carryForwardOutOfScope(expected, actual, scoped.outOfScope);
|
|
613
906
|
await writeSnapshot(
|
|
614
907
|
snapshotPath(resolvePath(metaRoot, config.outDir), kysely.dialect),
|
|
615
|
-
actual.meta !== undefined ? { ...
|
|
908
|
+
actual.meta !== undefined ? { ...committed, meta: actual.meta } : committed,
|
|
616
909
|
);
|
|
617
910
|
} catch (err) {
|
|
618
911
|
// The migration itself is written (and possibly applied) — report the
|
|
@@ -676,6 +969,10 @@ export async function runBaseline(
|
|
|
676
969
|
config: ResolvedMigrateConfig,
|
|
677
970
|
metaRoot: string,
|
|
678
971
|
fmt: OutputFormat = "text",
|
|
972
|
+
/** Directory whose `metaobjects.config.ts` governs — see `migrateCommand`'s
|
|
973
|
+
* `genRoot`. Defaults to `metaRoot`, the co-located case every `meta init`
|
|
974
|
+
* project is in and the only one the direct-call test suites construct. */
|
|
975
|
+
genRoot: string = metaRoot,
|
|
679
976
|
): Promise<number> {
|
|
680
977
|
if (config.dialect === undefined) {
|
|
681
978
|
log.error(`migrate baseline: --dialect required (or set migrate.dialect in .metaobjects/config.json)`);
|
|
@@ -731,23 +1028,32 @@ export async function runBaseline(
|
|
|
731
1028
|
`your metadata; for a new/empty database run \`${greenfieldCreateCmd(config.dialect)}\` instead.`,
|
|
732
1029
|
);
|
|
733
1030
|
let metadata;
|
|
734
|
-
// Load metaobjects.config.ts ONCE, up front, for BOTH the consumer providers
|
|
1031
|
+
// Load metaobjects.config.ts ONCE, up front, for BOTH the consumer providers/libraries
|
|
735
1032
|
// and the columnNamingStrategy — mirroring the DB path (and `meta gen`) so
|
|
736
1033
|
// offline baseline resolves config-registered custom subtypes too (#157).
|
|
737
|
-
let
|
|
738
|
-
| readonly import("@metaobjectsdev/codegen-ts").MetaDataTypeProvider[]
|
|
739
|
-
| undefined;
|
|
1034
|
+
let baselineLoadOptions: ReturnType<typeof loadMemoryOptionsFrom> = {};
|
|
740
1035
|
let baselineStrategy: "snake_case" | "literal" | "kebab-case" = "snake_case";
|
|
741
1036
|
try {
|
|
742
|
-
const cfg = await loadMetaobjectsConfig(
|
|
743
|
-
|
|
1037
|
+
const cfg = await loadMetaobjectsConfig(genRoot);
|
|
1038
|
+
baselineLoadOptions = loadMemoryOptionsFrom(cfg);
|
|
744
1039
|
if (cfg.columnNamingStrategy) baselineStrategy = cfg.columnNamingStrategy;
|
|
745
1040
|
} catch {
|
|
746
|
-
// config absent — no custom providers, default snake_case
|
|
1041
|
+
// config absent — no custom providers, no libraries, default snake_case
|
|
747
1042
|
}
|
|
1043
|
+
// `baseline` records a STARTING POINT, so it is deliberately NOT scoped: the
|
|
1044
|
+
// `--from-db` arm captures whatever the database holds (there is no provenance
|
|
1045
|
+
// for an introspected table), and an offline baseline that recorded less would
|
|
1046
|
+
// disagree with it. An out-of-scope table sitting in the snapshot is harmless:
|
|
1047
|
+
// every later run suppresses it on both sides of the diff, and an accepted
|
|
1048
|
+
// scoped run carries it FORWARD (`carryForwardOutOfScope`) rather than dropping
|
|
1049
|
+
// it — which is what makes that true. Committing the narrowed schema instead
|
|
1050
|
+
// would delete the entry, and removing the scope later would then propose
|
|
1051
|
+
// CREATE TABLE for a table that exists.
|
|
748
1052
|
try {
|
|
749
|
-
|
|
750
|
-
|
|
1053
|
+
const collection = await resolveCollection(metaRoot);
|
|
1054
|
+
metadata = await loadMemory(collection.configDir, {
|
|
1055
|
+
files: collection.files,
|
|
1056
|
+
...baselineLoadOptions,
|
|
751
1057
|
});
|
|
752
1058
|
} catch (err) {
|
|
753
1059
|
log.error(`migrate baseline: failed to load metadata: ${(err as Error).message}`);
|
|
@@ -859,36 +1165,53 @@ export async function runOfflineGenerate(
|
|
|
859
1165
|
config: ResolvedMigrateConfig,
|
|
860
1166
|
metaRoot: string,
|
|
861
1167
|
fmt: OutputFormat = "text",
|
|
1168
|
+
/** Directory whose `metaobjects.config.ts` governs — see `migrateCommand`'s
|
|
1169
|
+
* `genRoot`. Defaults to `metaRoot`, the co-located case. */
|
|
1170
|
+
genRoot: string = metaRoot,
|
|
862
1171
|
): Promise<number> {
|
|
863
1172
|
if (config.dialect === undefined) {
|
|
864
1173
|
log.error(`migrate: --dialect required for offline generation (or use --from-db)`);
|
|
865
1174
|
return 2;
|
|
866
1175
|
}
|
|
867
|
-
// Load metaobjects.config.ts ONCE, up front, for BOTH the consumer providers
|
|
1176
|
+
// Load metaobjects.config.ts ONCE, up front, for BOTH the consumer providers/libraries
|
|
868
1177
|
// and the columnNamingStrategy — mirroring the DB path (and `meta gen`) so
|
|
869
1178
|
// offline generate resolves config-registered custom subtypes too (#157).
|
|
870
|
-
let
|
|
871
|
-
| readonly import("@metaobjectsdev/codegen-ts").MetaDataTypeProvider[]
|
|
872
|
-
| undefined;
|
|
1179
|
+
let offlineLoadOptions: ReturnType<typeof loadMemoryOptionsFrom> = {};
|
|
873
1180
|
let offlineStrategy: "snake_case" | "literal" | "kebab-case" = "snake_case";
|
|
874
1181
|
try {
|
|
875
|
-
const cfg = await loadMetaobjectsConfig(
|
|
876
|
-
|
|
1182
|
+
const cfg = await loadMetaobjectsConfig(genRoot);
|
|
1183
|
+
offlineLoadOptions = loadMemoryOptionsFrom(cfg);
|
|
877
1184
|
if (cfg.columnNamingStrategy) offlineStrategy = cfg.columnNamingStrategy;
|
|
878
1185
|
} catch {
|
|
879
|
-
// config absent — no custom providers, default snake_case
|
|
1186
|
+
// config absent — no custom providers, no libraries, default snake_case
|
|
880
1187
|
}
|
|
881
1188
|
|
|
882
1189
|
let metadata;
|
|
1190
|
+
let collection;
|
|
883
1191
|
try {
|
|
884
|
-
|
|
885
|
-
|
|
1192
|
+
collection = await resolveCollection(metaRoot);
|
|
1193
|
+
metadata = await loadMemory(collection.configDir, {
|
|
1194
|
+
files: collection.files,
|
|
1195
|
+
...offlineLoadOptions,
|
|
886
1196
|
});
|
|
887
1197
|
} catch (err) {
|
|
888
1198
|
log.error(`migrate: failed to load metadata: ${(err as Error).message}`);
|
|
889
1199
|
return 2;
|
|
890
1200
|
}
|
|
891
1201
|
|
|
1202
|
+
const offlineDialect = config.dialect;
|
|
1203
|
+
const offlineViews = buildProjectionViews(metadata, { dialect: offlineDialect, columnNamingStrategy: offlineStrategy });
|
|
1204
|
+
const scopeRc = refuseScopeMismatch(
|
|
1205
|
+
collection,
|
|
1206
|
+
() => buildExpectedSchemaWithProvenance(metadata, {
|
|
1207
|
+
dialect: offlineDialect,
|
|
1208
|
+
columnNamingStrategy: offlineStrategy,
|
|
1209
|
+
views: offlineViews,
|
|
1210
|
+
}).provenance,
|
|
1211
|
+
fmt,
|
|
1212
|
+
);
|
|
1213
|
+
if (scopeRc !== undefined) return scopeRc;
|
|
1214
|
+
|
|
892
1215
|
const outDir = resolvePath(metaRoot, config.outDir);
|
|
893
1216
|
const path = snapshotPath(outDir, config.dialect);
|
|
894
1217
|
let snapshot;
|
|
@@ -916,7 +1239,7 @@ export async function runOfflineGenerate(
|
|
|
916
1239
|
const collectedAmbiguous: AmbiguousChange[] = [];
|
|
917
1240
|
const onAmbiguousResolution = mapOnAmbiguous(config.onAmbiguous);
|
|
918
1241
|
|
|
919
|
-
const
|
|
1242
|
+
const offlineScope = collection.inMigrateScope;
|
|
920
1243
|
|
|
921
1244
|
let plan;
|
|
922
1245
|
try {
|
|
@@ -926,6 +1249,8 @@ export async function runOfflineGenerate(
|
|
|
926
1249
|
snapshot,
|
|
927
1250
|
columnNamingStrategy: offlineStrategy,
|
|
928
1251
|
views: offlineViews,
|
|
1252
|
+
// Per-command scope — narrows BOTH sides of the offline diff (see planOffline).
|
|
1253
|
+
...(offlineScope !== undefined ? { inScope: offlineScope } : {}),
|
|
929
1254
|
allow: tokensToAllowOptions(config.allow),
|
|
930
1255
|
onAmbiguous: async (a) => {
|
|
931
1256
|
collectedAmbiguous.push(a);
|
|
@@ -946,7 +1271,11 @@ export async function runOfflineGenerate(
|
|
|
946
1271
|
throw err;
|
|
947
1272
|
}
|
|
948
1273
|
|
|
949
|
-
|
|
1274
|
+
// `nextSnapshot` is what gets COMMITTED (it retains this run's out-of-scope
|
|
1275
|
+
// entries); `expected` is the governed side the emitter renders against. Equal
|
|
1276
|
+
// for an unscoped run.
|
|
1277
|
+
const { diff: diffResult, nextSnapshot, expected: governedExpected } = plan;
|
|
1278
|
+
logOutOfScope(plan.outOfScope, fmt);
|
|
950
1279
|
|
|
951
1280
|
if (diffResult.blocked.length > 0) {
|
|
952
1281
|
log.error(`migrate: ${diffResult.blocked.length} destructive change(s) blocked; re-run with --allow <tokens>`);
|
|
@@ -963,7 +1292,7 @@ export async function runOfflineGenerate(
|
|
|
963
1292
|
|
|
964
1293
|
const emitResult = emit(diffResult.changes, {
|
|
965
1294
|
dialect: config.dialect,
|
|
966
|
-
expectedSchema:
|
|
1295
|
+
expectedSchema: governedExpected,
|
|
967
1296
|
actualSchema: snapshot,
|
|
968
1297
|
...(snapshot.meta ? { actualMeta: snapshot.meta } : {}),
|
|
969
1298
|
});
|
|
@@ -1052,9 +1381,16 @@ async function runRollback(
|
|
|
1052
1381
|
async function runD1Migrate(
|
|
1053
1382
|
config: ResolvedMigrateConfig,
|
|
1054
1383
|
metaRoot: string,
|
|
1384
|
+
cwd: string,
|
|
1055
1385
|
runner: WranglerRunner,
|
|
1056
1386
|
fmt: OutputFormat = "text",
|
|
1057
1387
|
): Promise<number> {
|
|
1388
|
+
// Same split as `migrateCommand`: `.metaobjects/` state hangs off `metaRoot`,
|
|
1389
|
+
// `metaobjects.config.ts` off its own nearest-ancestor walk (#326). Recomputed
|
|
1390
|
+
// rather than threaded — it is a pure function of the two arguments this already
|
|
1391
|
+
// takes, so the two cannot disagree.
|
|
1392
|
+
const genRoot = resolveGenConfigDir(cwd, metaRoot);
|
|
1393
|
+
|
|
1058
1394
|
// 1. Resolve wrangler.toml + binding.
|
|
1059
1395
|
const wranglerConfigPath = config.d1.wranglerConfigPath
|
|
1060
1396
|
? resolvePath(metaRoot, config.d1.wranglerConfigPath)
|
|
@@ -1079,6 +1415,12 @@ async function runD1Migrate(
|
|
|
1079
1415
|
binding = { binding: config.d1.binding!, database_name: "", database_id: "", migrations_dir: undefined };
|
|
1080
1416
|
}
|
|
1081
1417
|
|
|
1418
|
+
// The binding — and with it wrangler.toml's `migrations_dir` — is only now
|
|
1419
|
+
// known, so this is the earliest point d1 can honestly answer "where will
|
|
1420
|
+
// this run write?" (the caller skipped its own generic check for exactly
|
|
1421
|
+
// this reason; see the guard around that call).
|
|
1422
|
+
warnIfLedgerRelocated(cwd, resolveD1OutDir(config, metaRoot, binding.migrations_dir));
|
|
1423
|
+
|
|
1082
1424
|
// 2. Build a D1Runner closure over the wrangler runner.
|
|
1083
1425
|
const d1Runner: D1Runner = async (sql) => {
|
|
1084
1426
|
const args = buildWranglerExecuteArgs({
|
|
@@ -1093,39 +1435,51 @@ async function runD1Migrate(
|
|
|
1093
1435
|
|
|
1094
1436
|
// 3. Load metadata. Best-effort config read for consumer providers; falls
|
|
1095
1437
|
// back to default core+forge bundle if metaobjects.config.ts is absent.
|
|
1096
|
-
let
|
|
1438
|
+
let d1LoadOptions: ReturnType<typeof loadMemoryOptionsFrom> = {};
|
|
1097
1439
|
try {
|
|
1098
|
-
|
|
1099
|
-
d1ConfigProviders = forgeConfig.providers;
|
|
1440
|
+
d1LoadOptions = loadMemoryOptionsFrom(await loadMetaobjectsConfig(genRoot));
|
|
1100
1441
|
} catch {
|
|
1101
|
-
|
|
1442
|
+
d1LoadOptions = {};
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Discovery and load are separate failure modes (the `meta gen` pattern);
|
|
1446
|
+
// `resolveCollection`'s own ERR_COLLECTION_NOT_FOUND replaces the hand-rolled
|
|
1447
|
+
// ENOENT sniff, with the same exit 2.
|
|
1448
|
+
let collection;
|
|
1449
|
+
try {
|
|
1450
|
+
collection = await resolveCollection(metaRoot);
|
|
1451
|
+
} catch (err) {
|
|
1452
|
+
log.error((err as Error).message);
|
|
1453
|
+
return 2;
|
|
1102
1454
|
}
|
|
1103
1455
|
|
|
1104
1456
|
let metadata;
|
|
1105
1457
|
try {
|
|
1106
|
-
metadata = await loadMemory(
|
|
1107
|
-
|
|
1458
|
+
metadata = await loadMemory(collection.configDir, {
|
|
1459
|
+
files: collection.files,
|
|
1460
|
+
...d1LoadOptions,
|
|
1108
1461
|
});
|
|
1109
1462
|
} catch (err) {
|
|
1110
|
-
|
|
1111
|
-
if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
|
|
1112
|
-
log.error(`no metaobjects/ found in ${metaRoot}; run 'meta init' to scaffold`);
|
|
1113
|
-
} else {
|
|
1114
|
-
log.error(`migrate: failed to load metadata: ${msg}`);
|
|
1115
|
-
}
|
|
1463
|
+
log.error(`migrate: failed to load metadata: ${(err as Error).message}`);
|
|
1116
1464
|
return 2;
|
|
1117
1465
|
}
|
|
1118
1466
|
|
|
1119
1467
|
// 4. Build expected schema + introspect actual.
|
|
1120
1468
|
let columnNamingStrategy: "snake_case" | "literal" | "kebab-case" = "snake_case";
|
|
1121
1469
|
try {
|
|
1122
|
-
const cfg = await loadMetaobjectsConfig(
|
|
1470
|
+
const cfg = await loadMetaobjectsConfig(genRoot);
|
|
1123
1471
|
if (cfg.columnNamingStrategy) columnNamingStrategy = cfg.columnNamingStrategy;
|
|
1124
1472
|
} catch {
|
|
1125
1473
|
// metaobjects.config.ts absent or invalid — use default snake_case
|
|
1126
1474
|
}
|
|
1127
1475
|
const expectedViews = buildProjectionViews(metadata, { dialect: "d1", columnNamingStrategy });
|
|
1128
|
-
const
|
|
1476
|
+
const built = buildExpectedSchemaWithProvenance(metadata, { dialect: "d1", columnNamingStrategy, views: expectedViews });
|
|
1477
|
+
const scopeRc = refuseScopeMismatch(collection, () => built.provenance, fmt);
|
|
1478
|
+
if (scopeRc !== undefined) return scopeRc;
|
|
1479
|
+
// Per-command scope — both-sided, exactly as on the Kysely path above.
|
|
1480
|
+
const scoped = scopeExpectedSchema(built, collection.inMigrateScope);
|
|
1481
|
+
const expected = scoped.snapshot;
|
|
1482
|
+
logOutOfScope(scoped.outOfScope, fmt);
|
|
1129
1483
|
let actual;
|
|
1130
1484
|
try {
|
|
1131
1485
|
actual = await introspectD1({
|
|
@@ -1145,7 +1499,8 @@ async function runD1Migrate(
|
|
|
1145
1499
|
let diffResult;
|
|
1146
1500
|
try {
|
|
1147
1501
|
diffResult = await diff({
|
|
1148
|
-
|
|
1502
|
+
// The three scoped-diff obligations, exactly as on the online path above.
|
|
1503
|
+
...scopedDiffInputs(scoped, collectUnmanagedNames(metadata)),
|
|
1149
1504
|
actual,
|
|
1150
1505
|
// D1 is SQLite at the SQL level — the dialect activates the sqlite diff
|
|
1151
1506
|
// semantics (structural FK matching: SQLite stores no FK names; CHECK
|
|
@@ -1157,8 +1512,6 @@ async function runD1Migrate(
|
|
|
1157
1512
|
// has no expressible migration; refuse loudly instead of emitting SQL that drops
|
|
1158
1513
|
// the constraint and breaks referencing FKs at apply (same failure as the online path).
|
|
1159
1514
|
refusePrimaryKeyChange: true,
|
|
1160
|
-
// #208 §7 — declared-@unmanaged objects are external (see the online path above).
|
|
1161
|
-
unmanagedNames: collectUnmanagedNames(metadata),
|
|
1162
1515
|
onAmbiguous: async (a) => {
|
|
1163
1516
|
collectedAmbiguous.push(a);
|
|
1164
1517
|
return onAmbiguousResolution;
|
|
@@ -1217,14 +1570,9 @@ async function runD1Migrate(
|
|
|
1217
1570
|
const combinedUp = emitResult.up;
|
|
1218
1571
|
const combinedDown = emitResult.down;
|
|
1219
1572
|
|
|
1220
|
-
// Migration dir resolution
|
|
1221
|
-
//
|
|
1222
|
-
|
|
1223
|
-
const isDefaultOutDir = config.outDir === MIGRATE_DEFAULT_OUT_DIR;
|
|
1224
|
-
const migrationsDir = resolvePath(
|
|
1225
|
-
metaRoot,
|
|
1226
|
-
isDefaultOutDir ? (binding.migrations_dir ?? "migrations") : config.outDir,
|
|
1227
|
-
);
|
|
1573
|
+
// Migration dir resolution — same convention `warnIfLedgerRelocated` was
|
|
1574
|
+
// just given above, so the two cannot drift apart.
|
|
1575
|
+
const migrationsDir = resolveD1OutDir(config, metaRoot, binding.migrations_dir);
|
|
1228
1576
|
|
|
1229
1577
|
if (config.dryRun) {
|
|
1230
1578
|
log.info(`-- UP --\n${combinedUp}\n\n-- DOWN --\n${combinedDown}`);
|