@metaobjectsdev/cli 0.23.2 → 0.24.0

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.
Files changed (89) hide show
  1. package/README.md +78 -0
  2. package/dist/src/commands/docs.d.ts.map +1 -1
  3. package/dist/src/commands/docs.js +148 -50
  4. package/dist/src/commands/docs.js.map +1 -1
  5. package/dist/src/commands/export.d.ts.map +1 -1
  6. package/dist/src/commands/export.js +28 -6
  7. package/dist/src/commands/export.js.map +1 -1
  8. package/dist/src/commands/gen.d.ts.map +1 -1
  9. package/dist/src/commands/gen.js +61 -22
  10. package/dist/src/commands/gen.js.map +1 -1
  11. package/dist/src/commands/init.d.ts +8 -0
  12. package/dist/src/commands/init.d.ts.map +1 -1
  13. package/dist/src/commands/init.js +142 -52
  14. package/dist/src/commands/init.js.map +1 -1
  15. package/dist/src/commands/migrate.d.ts +9 -2
  16. package/dist/src/commands/migrate.d.ts.map +1 -1
  17. package/dist/src/commands/migrate.js +399 -73
  18. package/dist/src/commands/migrate.js.map +1 -1
  19. package/dist/src/commands/prompt-snapshot.d.ts.map +1 -1
  20. package/dist/src/commands/prompt-snapshot.js +40 -18
  21. package/dist/src/commands/prompt-snapshot.js.map +1 -1
  22. package/dist/src/commands/upgrade.d.ts +2 -0
  23. package/dist/src/commands/upgrade.d.ts.map +1 -0
  24. package/dist/src/commands/upgrade.js +139 -0
  25. package/dist/src/commands/upgrade.js.map +1 -0
  26. package/dist/src/commands/verify.d.ts.map +1 -1
  27. package/dist/src/commands/verify.js +336 -48
  28. package/dist/src/commands/verify.js.map +1 -1
  29. package/dist/src/index.d.ts.map +1 -1
  30. package/dist/src/index.js +46 -15
  31. package/dist/src/index.js.map +1 -1
  32. package/dist/src/lib/allow.d.ts.map +1 -1
  33. package/dist/src/lib/allow.js +4 -0
  34. package/dist/src/lib/allow.js.map +1 -1
  35. package/dist/src/lib/args.d.ts +16 -2
  36. package/dist/src/lib/args.d.ts.map +1 -1
  37. package/dist/src/lib/args.js +19 -3
  38. package/dist/src/lib/args.js.map +1 -1
  39. package/dist/src/lib/codegen-drift.d.ts +8 -1
  40. package/dist/src/lib/codegen-drift.d.ts.map +1 -1
  41. package/dist/src/lib/codegen-drift.js +9 -1
  42. package/dist/src/lib/codegen-drift.js.map +1 -1
  43. package/dist/src/lib/detect-stack.d.ts +1 -1
  44. package/dist/src/lib/detect-stack.d.ts.map +1 -1
  45. package/dist/src/lib/detect-stack.js +22 -37
  46. package/dist/src/lib/detect-stack.js.map +1 -1
  47. package/dist/src/lib/load-metaobjects-config.d.ts +48 -1
  48. package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
  49. package/dist/src/lib/load-metaobjects-config.js +80 -1
  50. package/dist/src/lib/load-metaobjects-config.js.map +1 -1
  51. package/dist/src/lib/manifest-ignored-check.d.ts +12 -0
  52. package/dist/src/lib/manifest-ignored-check.d.ts.map +1 -0
  53. package/dist/src/lib/manifest-ignored-check.js +74 -0
  54. package/dist/src/lib/manifest-ignored-check.js.map +1 -0
  55. package/dist/src/lib/migrate-scope.d.ts +49 -0
  56. package/dist/src/lib/migrate-scope.d.ts.map +1 -0
  57. package/dist/src/lib/migrate-scope.js +92 -0
  58. package/dist/src/lib/migrate-scope.js.map +1 -0
  59. package/dist/src/lib/output.d.ts +1 -1
  60. package/dist/src/lib/output.d.ts.map +1 -1
  61. package/dist/src/lib/output.js +9 -3
  62. package/dist/src/lib/output.js.map +1 -1
  63. package/dist/src/lib/requirement-check.d.ts.map +1 -1
  64. package/dist/src/lib/requirement-check.js +10 -57
  65. package/dist/src/lib/requirement-check.js.map +1 -1
  66. package/package.json +11 -11
  67. package/src/commands/docs.ts +158 -49
  68. package/src/commands/export.ts +30 -6
  69. package/src/commands/gen.ts +62 -22
  70. package/src/commands/init.ts +161 -58
  71. package/src/commands/migrate.ts +423 -75
  72. package/src/commands/prompt-snapshot.ts +43 -18
  73. package/src/commands/upgrade.ts +156 -0
  74. package/src/commands/verify.ts +373 -48
  75. package/src/index.ts +46 -15
  76. package/src/lib/allow.ts +4 -0
  77. package/src/lib/args.ts +35 -4
  78. package/src/lib/codegen-drift.ts +9 -0
  79. package/src/lib/detect-stack.ts +20 -33
  80. package/src/lib/load-metaobjects-config.ts +84 -2
  81. package/src/lib/manifest-ignored-check.ts +75 -0
  82. package/src/lib/migrate-scope.ts +102 -0
  83. package/src/lib/output.ts +8 -4
  84. package/src/lib/requirement-check.ts +10 -58
  85. package/dist/src/lib/verified-by-scan.d.ts +0 -20
  86. package/dist/src/lib/verified-by-scan.d.ts.map +0 -1
  87. package/dist/src/lib/verified-by-scan.js +0 -344
  88. package/dist/src/lib/verified-by-scan.js.map +0 -1
  89. package/src/lib/verified-by-scan.ts +0 -375
@@ -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";
@@ -8,9 +9,10 @@ import { formatMigrateResultJson } from "../lib/output-json.js";
8
9
  import { toonEncode } from "../lib/format.js";
9
10
  import { buildKyselyFromUrl, redactUrl } from "../lib/kysely.js";
10
11
  import { log } from "../lib/log.js";
11
- import { loadMemory } from "@metaobjectsdev/sdk";
12
- import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js";
13
- import { buildExpectedSchema, introspect, diff, collectUnmanagedNames, emit, writeMigration, baselineFromMetadata, planOffline, snapshotPath, readSnapshot, writeSnapshot, BlockedChangesError, PrimaryKeyChangeError, renderD1, writeMigrationD1, writeMigrationFlyway, introspectD1, applyPending, rollbackTo, findWranglerConfig, parseWranglerConfig, resolveD1Binding, } from "@metaobjectsdev/migrate-ts";
12
+ import { loadMemory, resolveCollection, resolveConfigDir } from "@metaobjectsdev/sdk";
13
+ import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
14
+ import { migrateScopeMismatch, outOfScopeNote } from "../lib/migrate-scope.js";
15
+ import { buildExpectedSchemaWithProvenance, scopeExpectedSchema, scopedDiffInputs, introspect, diff, collectUnmanagedNames, carryForwardOutOfScope, emit, writeMigration, baselineFromMetadata, planOffline, snapshotPath, readSnapshot, writeSnapshot, qualifiedDbName, BlockedChangesError, PrimaryKeyChangeError, renderD1, writeMigrationD1, writeMigrationFlyway, introspectD1, applyPending, rollbackTo, findWranglerConfig, parseWranglerConfig, resolveD1Binding, } from "@metaobjectsdev/migrate-ts";
14
16
  import { buildWranglerExecuteArgs, defaultWranglerRunner, } from "../lib/wrangler.js";
15
17
  import { buildProjectionViews } from "@metaobjectsdev/codegen-ts";
16
18
  import { tokensToAllowOptions, describeChange } from "../lib/allow.js";
@@ -24,8 +26,11 @@ SUBCOMMANDS:
24
26
  (use with --from-db). NOTE: for a brand-new/empty database use
25
27
  the greenfield example below, NOT baseline — an offline baseline
26
28
  records your metadata as already-applied and emits no CREATE TABLE.
27
- apply-pending Replay committed migration files against --db (no diff);
28
- provisions a fresh/CI database. postgres/sqlite only.
29
+ apply-pending Replay committed migration files against --db (no diff).
30
+ Provisions a fresh/CI database when the chain BUILDS the
31
+ schema — 'meta verify --replay' proves that it does. A
32
+ database adopted via 'baseline --from-db' has no such
33
+ chain. postgres/sqlite only.
29
34
 
30
35
  MIGRATE FLAGS:
31
36
  --db <url> DB connection URL (required for live-introspect / --apply / --rollback)
@@ -45,7 +50,13 @@ MIGRATE FLAGS:
45
50
  --allow <csv> Comma-separated destructive-change permissions:
46
51
  drop-column,drop-table,type-change,drop-index,drop-fk,
47
52
  drop-check,drop-view,drop-view-cascade,
48
- adopt-view,nullable-to-not-null,drop-identity-default
53
+ adopt-view,nullable-to-not-null,drop-identity-default,
54
+ drop-unmanaged
55
+ drop-unmanaged permits dropping a table/view the committed
56
+ snapshot never contained — one this toolchain never managed.
57
+ Without it that drop is refused, because the migration it
58
+ writes cannot replay against a database where the object
59
+ never existed.
49
60
  --on-ambiguous abort|rename|drop-add
50
61
  How to handle ambiguous renames (default: abort)
51
62
  --from-db Introspect live DB instead of using the committed snapshot
@@ -83,6 +94,69 @@ function resolveFormatOutDir(config, metaRoot) {
83
94
  }
84
95
  return resolvePath(metaRoot, config.outDir);
85
96
  }
97
+ /**
98
+ * D1's OWN directory convention — `--out-dir` > `wrangler.toml`'s
99
+ * `migrations_dir` > `"migrations"` — kept apart from `resolveFormatOutDir`
100
+ * because the middle term is not knowable until a wrangler binding has been
101
+ * resolved (`runD1Migrate` step 1), while every other dialect can answer
102
+ * immediately from `config` alone.
103
+ */
104
+ function resolveD1OutDir(config, metaRoot, migrationsDirHint) {
105
+ const isDefaultOutDir = config.outDir === MIGRATE_DEFAULT_OUT_DIR;
106
+ return resolvePath(metaRoot, isDefaultOutDir ? (migrationsDirHint ?? "migrations") : config.outDir);
107
+ }
108
+ /**
109
+ * Say so when the migrations directory this run will use is NOT the one sitting
110
+ * in the working directory.
111
+ *
112
+ * `metaRoot` is now the discovered project root rather than ambient cwd, and the
113
+ * migrations directory follows it. That is the right call — the ledger belongs
114
+ * with the config that declares it — but it is a behaviour change for the two
115
+ * subcommands that load no metadata at all: `apply-pending` and `--rollback`
116
+ * used cwd unconditionally, so a subdirectory holding `.metaobjects/migrations`
117
+ * under a project root that also has one now replays the ROOT's ledger.
118
+ * Replaying somebody else's migration history silently is the worst outcome
119
+ * available here, so it is announced.
120
+ *
121
+ * Conditioned on the local directory EXISTING, so the ordinary case — a run from
122
+ * anywhere inside a project with one ledger at its root — says nothing.
123
+ * `--out-dir` (and a `migrate.outDir` in the config) is honoured: the caller
124
+ * must pass the directory THIS run will actually WRITE to, never a default
125
+ * that was overridden — a deliberate redirection is compared, not a stale
126
+ * guess. That answer comes from **two** call sites, because there are two
127
+ * directory conventions: every dialect but d1 resolves via
128
+ * `resolveFormatOutDir` before the format/dialect dispatch below; d1 has its
129
+ * own convention (`resolveD1OutDir`, wrangler.toml's `migrations_dir`) that is
130
+ * unknowable until its binding resolves, so it calls this again for itself
131
+ * from inside `runD1Migrate`, once that binding is in hand.
132
+ */
133
+ function warnIfLedgerRelocated(cwd, resolvedOutDir) {
134
+ const local = resolvePath(cwd, MIGRATE_DEFAULT_OUT_DIR);
135
+ if (resolvedOutDir === local || !existsSync(local))
136
+ return;
137
+ log.warn(`migrate: using the migrations directory ${resolvedOutDir}, not the ${local} ` +
138
+ `in this working directory — the ledger belongs to the project root that declares it. ` +
139
+ `Pass --out-dir ${local} to use the local one.`);
140
+ }
141
+ /**
142
+ * Report what a declared `migrate.scope` left out (wording: `outOfScopeNote`).
143
+ *
144
+ * STDOUT in text format, STDERR otherwise. `--format json` / `--format toon` put a
145
+ * single machine-readable document on stdout, and a prose line ahead of it breaks
146
+ * `| jq` outright — the same split `emitStructuredError` makes just below.
147
+ * Routed to stderr rather than dropped, because the non-TTY default format is toon
148
+ * (`resolveFormat`): suppressing it outright would silence the note for every
149
+ * piped and CI run, which is most of them.
150
+ */
151
+ function logOutOfScope(names, fmt) {
152
+ if (names.length === 0)
153
+ return;
154
+ const msg = outOfScopeNote("migrate", names);
155
+ if (fmt === "text")
156
+ log.info(msg);
157
+ else
158
+ log.warn(msg);
159
+ }
86
160
  function emitStructuredError(error, hint, fmt) {
87
161
  const payload = { error, hint };
88
162
  if (fmt === "json") {
@@ -93,6 +167,23 @@ function emitStructuredError(error, hint, fmt) {
93
167
  }
94
168
  // text format: errors go to stderr via log.error() — the caller handles that path
95
169
  }
170
+ /**
171
+ * The refusal for a `migrate.scope` that matches nothing, as all three of migrate's
172
+ * pipelines (online, offline, D1) issue it.
173
+ *
174
+ * Returns the exit code to return, or `undefined` when there is nothing to refuse.
175
+ * Three byte-identical copies of the report-and-exit differed only in a local
176
+ * variable name; the hint string and the exit code are one decision, recorded once
177
+ * — a configuration error, so exit 2.
178
+ */
179
+ function refuseScopeMismatch(collection, provenance, fmt) {
180
+ const mismatch = migrateScopeMismatch(collection, provenance);
181
+ if (mismatch === undefined)
182
+ return undefined;
183
+ log.error(`migrate: ${mismatch}`);
184
+ emitStructuredError(`migrate: ${mismatch}`, "fix or remove migrate.scope in .metaobjects/config.json", fmt);
185
+ return 2;
186
+ }
96
187
  /**
97
188
  * Sentinel thrown by sub-functions that have already emitted a structured error
98
189
  * via emitStructuredError(). The top-level catch in migrateCommand re-throws
@@ -151,6 +242,75 @@ function summarizeChanges(changes) {
151
242
  }
152
243
  return counts;
153
244
  }
245
+ /**
246
+ * The qualified names of tables/views/constraints this diff proposes to DROP that
247
+ * the committed snapshot never contained — i.e. objects this toolchain never
248
+ * managed (#313).
249
+ *
250
+ * FAILS OPEN. No snapshot on disk, or one that cannot be read, yields an empty list:
251
+ * a project that has never generated one is not in an error state, and refusing there
252
+ * would break the first `meta migrate` of every greenfield project. A parse failure is
253
+ * migrate's own error to raise elsewhere, with its own message, not a silent refusal
254
+ * here.
255
+ *
256
+ * Table/view names come from `qualifiedDbName` and nothing else. Three independent
257
+ * sets already have to agree on this spelling — the diff's identity maps, the
258
+ * `@unmanaged` exclusion set, and the out-of-scope set — and a fourth encoding of
259
+ * "absent schema means public" would silently un-guard every object it disagreed
260
+ * about.
261
+ *
262
+ * `drop-fk`/`drop-check`/constraint-backed `drop-index` are checked at the
263
+ * CONSTRAINT grain, not just the table's: the emitter's `IF EXISTS` on the
264
+ * enclosing `ALTER TABLE` (the SQL half of this same #313 gap) only stops the
265
+ * replay from failing outright — it says nothing about whether AUTHORING the
266
+ * drop was ever supposed to be permission-free. A table can be fully managed
267
+ * while carrying a constraint another tool added directly against the live
268
+ * database; that constraint's name is absent from the snapshotted table's own
269
+ * `foreignKeys`/`checks`/`indexes`, exactly like a whole unmanaged table is
270
+ * absent from `snapshot.tables`.
271
+ */
272
+ async function snapshotAbsentDrops(changes, snapPath) {
273
+ let snapshot;
274
+ try {
275
+ snapshot = await readSnapshot(snapPath);
276
+ }
277
+ catch {
278
+ return [];
279
+ }
280
+ if (snapshot === null)
281
+ return [];
282
+ const managedTables = new Map();
283
+ for (const t of snapshot.tables)
284
+ managedTables.set(qualifiedDbName(t), t);
285
+ const managedViews = new Set();
286
+ for (const v of snapshot.views)
287
+ managedViews.add(qualifiedDbName(v));
288
+ const absent = [];
289
+ for (const c of changes) {
290
+ if (c.kind === "drop-table") {
291
+ const name = qualifiedDbName({ name: c.table, schema: c.schema });
292
+ if (!managedTables.has(name))
293
+ absent.push(name);
294
+ }
295
+ else if (c.kind === "drop-view") {
296
+ const name = qualifiedDbName({ name: c.view, schema: c.schema });
297
+ if (!managedViews.has(name))
298
+ absent.push(name);
299
+ }
300
+ else if (c.kind === "drop-fk" || c.kind === "drop-check" || c.kind === "drop-index") {
301
+ const tableName = qualifiedDbName({ name: c.table, schema: c.schema });
302
+ const table = managedTables.get(tableName);
303
+ const constraintName = c.kind === "drop-fk" ? c.fk : c.kind === "drop-check" ? c.check : c.index;
304
+ const recorded = c.kind === "drop-fk" ? table?.foreignKeys
305
+ : c.kind === "drop-check" ? table?.checks
306
+ : table?.indexes;
307
+ if (recorded === undefined || !recorded.some((d) => d.name === constraintName)) {
308
+ absent.push(`${tableName}.${constraintName}`);
309
+ }
310
+ }
311
+ }
312
+ return absent;
313
+ }
154
314
  function allowFlagFor(kind) {
155
315
  switch (kind) {
156
316
  case "drop-column": return "drop-column";
@@ -204,8 +364,56 @@ wranglerRunner, fmt = "text") {
204
364
  emitStructuredError(`migrate: ${msg}`, "run `meta migrate --help` for usage", fmt);
205
365
  return 2;
206
366
  }
207
- const metaRoot = cwd;
367
+ // The project root is the directory whose `.metaobjects/config.json` governs
368
+ // this run — the same directory `resolveCollection` resolves the metadata
369
+ // from, found the same way (design §4.6.1: "Per-port generator config is then
370
+ // read from that same directory"). Everything below is relative to it: the
371
+ // `.metaobjects/config.json` operational block, `metaobjects.config.ts`'s
372
+ // `columnNamingStrategy`, the migrations `outDir`, `wrangler.toml` discovery.
373
+ //
374
+ // Read from ambient cwd instead, as this did, they DIVERGE the moment the two
375
+ // differ: run `meta migrate` from a subdirectory of a project whose root
376
+ // declares `columnNamingStrategy: "literal"` and the metadata resolves from
377
+ // the ancestor while the strategy silently defaults to snake_case — emitting a
378
+ // migration that renames every column. Newly reachable, too: before metadata
379
+ // sources were resolvable that invocation just failed with "no metaobjects/
380
+ // found".
381
+ //
382
+ // `resolveConfigDir` rather than `resolveCollection` deliberately: this must
383
+ // not require metadata to EXIST. `migrate apply-pending` and `--rollback`
384
+ // replay committed SQL and load no metadata at all, and making them fail on a
385
+ // project with no model would be a regression. It is the SAME walk
386
+ // `resolveCollection` runs (one exported definition in the sdk's
387
+ // `discovery.ts`, not two that agree by construction), so the directory this
388
+ // resolves and the directory the metadata comes from cannot diverge.
389
+ const metaRoot = await resolveConfigDir(cwd);
390
+ // `metaobjects.config.ts` is the one thing here NOT governed by that walk. It
391
+ // answers a different question from `.metaobjects/config.json` (design §4.6 —
392
+ // per-port "how is code generated here?" versus port-neutral "where does metadata
393
+ // come from?"), and in a Maven- or pip-rooted monorepo the two legitimately sit in
394
+ // different directories. Reading it from `metaRoot` there silently defaults
395
+ // `columnNamingStrategy` for a package that declares one — the exact
396
+ // rename-every-column failure the co-location was introduced to prevent, arrived
397
+ // at from the other side (#326). Nearest wins, so a subdirectory declaring nothing
398
+ // still walks up to the project root's config; identical to `metaRoot` in every
399
+ // `meta init` project, where the two files sit together.
400
+ const genRoot = resolveGenConfigDir(cwd, metaRoot);
208
401
  const config = await resolveMigrateConfig(flags, metaRoot);
402
+ // `resolveFormatOutDir`, not `resolvePath(metaRoot, config.outDir)`: under
403
+ // `--migration-format flyway` with a default outDir the run writes to
404
+ // Flyway's conventional location instead, so the unredirected path names a
405
+ // directory this invocation will never touch.
406
+ //
407
+ // Skipped for a plain `--dialect d1` run (format !== flyway): d1 resolves
408
+ // its OWN directory from wrangler.toml, unknowable until its binding
409
+ // resolves, so it issues this warning for itself from inside
410
+ // `runD1Migrate` instead — `resolveFormatOutDir` here would name the
411
+ // Kysely-path default, a directory that run never writes to. A d1 +
412
+ // `--migration-format flyway` run is refused just below before either
413
+ // directory is ever touched, so THAT combination still wants this one.
414
+ if (config.dialect !== "d1" || config.format === "flyway") {
415
+ warnIfLedgerRelocated(cwd, resolveFormatOutDir(config, metaRoot));
416
+ }
209
417
  try {
210
418
  // #192 — Flyway owns apply + history (flyway_schema_history). We generate the
211
419
  // migration; applying it is Flyway's job. Refuse at generation time rather than
@@ -254,11 +462,11 @@ wranglerRunner, fmt = "text") {
254
462
  emitStructuredError(`migrate: --rollback is not supported for dialect 'd1'`, "use 'wrangler d1 migrations' tooling to roll back d1", fmt);
255
463
  return 2;
256
464
  }
257
- return await runD1Migrate(config, metaRoot, wranglerRunner ?? defaultWranglerRunner, fmt);
465
+ return await runD1Migrate(config, metaRoot, cwd, wranglerRunner ?? defaultWranglerRunner, fmt);
258
466
  }
259
467
  // `migrate baseline` — seed the committed reference snapshot, emit no migration.
260
468
  if (config.baseline) {
261
- return await runBaseline(config, metaRoot, fmt);
469
+ return await runBaseline(config, metaRoot, fmt, genRoot);
262
470
  }
263
471
  // `migrate apply-pending` — replay committed migration files; no diff, no metadata load.
264
472
  if (config.applyPending) {
@@ -268,7 +476,7 @@ wranglerRunner, fmt = "text") {
268
476
  // when explicitly requested via --from-db, when --apply needs a connection, or
269
477
  // for --rollback (which runs hand-authored down.sql against the live DB).
270
478
  if (!config.fromDb && !config.apply && config.rollback === undefined) {
271
- return await runOfflineGenerate(config, metaRoot, fmt);
479
+ return await runOfflineGenerate(config, metaRoot, fmt, genRoot);
272
480
  }
273
481
  if (config.databaseUrl === undefined) {
274
482
  log.error(`migrate: --db <url> required (or set DATABASE_URL, or add migrate.databaseUrl to .metaobjects/config.json)`);
@@ -284,28 +492,35 @@ wranglerRunner, fmt = "text") {
284
492
  // Best-effort load of metaobjects.config.ts to pick up consumer-supplied
285
493
  // providers. migrate's postgres/sqlite path also reads the config later
286
494
  // for columnNamingStrategy; we load it once here and reuse below.
287
- let postgresConfigProviders;
495
+ let postgresLoadOptions = {};
288
496
  try {
289
- const forgeConfig = await loadMetaobjectsConfig(metaRoot);
290
- postgresConfigProviders = forgeConfig.providers;
497
+ postgresLoadOptions = loadMemoryOptionsFrom(await loadMetaobjectsConfig(genRoot));
291
498
  }
292
499
  catch {
293
- postgresConfigProviders = undefined;
500
+ postgresLoadOptions = {};
501
+ }
502
+ // Discovery and load are two separate failure modes, kept in separate try blocks
503
+ // (the `meta gen` pattern): a broad catch around both reports a genuine ParseError
504
+ // as "no metadata found", masking the real failure. `resolveCollection` raises
505
+ // ERR_COLLECTION_NOT_FOUND with its own message — the same exit 2 the hand-rolled
506
+ // ENOENT sniff used to produce.
507
+ let collection;
508
+ try {
509
+ collection = await resolveCollection(metaRoot);
510
+ }
511
+ catch (err) {
512
+ log.error(err.message);
513
+ return 2;
294
514
  }
295
515
  let metadata;
296
516
  try {
297
- metadata = await loadMemory(metaRoot, {
298
- ...(postgresConfigProviders !== undefined ? { providers: postgresConfigProviders } : {}),
517
+ metadata = await loadMemory(collection.configDir, {
518
+ files: collection.files,
519
+ ...postgresLoadOptions,
299
520
  });
300
521
  }
301
522
  catch (err) {
302
- const msg = err.message;
303
- if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
304
- log.error(`no metaobjects/ found in ${metaRoot}; run 'meta init' to scaffold`);
305
- }
306
- else {
307
- log.error(`failed to load metadata: ${msg}`);
308
- }
523
+ log.error(`failed to load metadata: ${err.message}`);
309
524
  return 2;
310
525
  }
311
526
  let kysely;
@@ -328,7 +543,7 @@ wranglerRunner, fmt = "text") {
328
543
  // and projection view DDL — derive it once, up front, so every view path agrees.
329
544
  let columnNamingStrategy = "snake_case";
330
545
  try {
331
- const cfg = await loadMetaobjectsConfig(metaRoot);
546
+ const cfg = await loadMetaobjectsConfig(genRoot);
332
547
  if (cfg.columnNamingStrategy)
333
548
  columnNamingStrategy = cfg.columnNamingStrategy;
334
549
  }
@@ -340,11 +555,21 @@ wranglerRunner, fmt = "text") {
340
555
  // view DDL (create/drop/replace + dependency-recreate) and emit() renders it —
341
556
  // there is no separate view-migration emitter.
342
557
  const expectedViews = buildProjectionViews(metadata, { dialect: kysely.dialect, columnNamingStrategy });
343
- const expected = buildExpectedSchema(metadata, {
558
+ const built = buildExpectedSchemaWithProvenance(metadata, {
344
559
  dialect: kysely.dialect,
345
560
  columnNamingStrategy,
346
561
  views: expectedViews,
347
562
  });
563
+ const scopeRc = refuseScopeMismatch(collection, () => built.provenance, fmt);
564
+ if (scopeRc !== undefined)
565
+ return scopeRc;
566
+ // Per-command scope: objects outside `migrate.scope` are another owner's. They
567
+ // leave the expected schema here and are suppressed on the actual side below —
568
+ // dropping them from `expected` ALONE would propose DROP TABLE for every one of
569
+ // them that exists in the database.
570
+ const scoped = scopeExpectedSchema(built, collection.inMigrateScope);
571
+ const expected = scoped.snapshot;
572
+ logOutOfScope(scoped.outOfScope, fmt);
348
573
  let actual;
349
574
  try {
350
575
  actual = await introspect(kysely.db, kysely.dialect);
@@ -359,7 +584,12 @@ wranglerRunner, fmt = "text") {
359
584
  let diffResult;
360
585
  try {
361
586
  diffResult = await diff({
362
- expected,
587
+ // The three scoped-diff obligations as one value (migrate-ts's scope.ts
588
+ // header has the mechanism): the narrowed expected side, `unmanagedNames`
589
+ // merging #208 §7's declared-@unmanaged set with the out-of-scope names so
590
+ // neither is created or dropped, and the schema scope pinned to the
591
+ // UNSCOPED model so narrowing can never widen the run.
592
+ ...scopedDiffInputs(scoped, collectUnmanagedNames(metadata)),
363
593
  actual,
364
594
  dialect: kysely.dialect,
365
595
  allow: tokensToAllowOptions(config.allow),
@@ -367,9 +597,6 @@ wranglerRunner, fmt = "text") {
367
597
  // has no expressible migration; refuse loudly instead of emitting SQL that drops
368
598
  // the constraint and breaks referencing FKs at apply.
369
599
  refusePrimaryKeyChange: true,
370
- // #208 §7 — declared-@unmanaged objects are external: exclude them from the
371
- // actual side so migrate proposes neither create nor drop for them.
372
- unmanagedNames: collectUnmanagedNames(metadata),
373
600
  onAmbiguous: async (a) => {
374
601
  collectedAmbiguous.push(a);
375
602
  return onAmbiguousResolution;
@@ -409,6 +636,34 @@ wranglerRunner, fmt = "text") {
409
636
  throw err;
410
637
  }
411
638
  changeCounts = summarizeChanges(diffResult.changes);
639
+ // #313 — refuse to AUTHOR a drop for an object the committed snapshot never
640
+ // contained. This path diffs metadata against introspection and never reads the
641
+ // snapshot, so an object another tool owns reads as "in the DB, not in the model"
642
+ // and is proposed for a drop; the migration that results cannot replay against a
643
+ // database where that object never existed, which is how a chain stays broken for
644
+ // months. `classify.ts` already states the doctrine — objects present in the DB
645
+ // but not the snapshot "must never be treated as actionable drift or
646
+ // auto-dropped" — and this is where it is finally enforced.
647
+ //
648
+ // It does not false-fire on the brownfield cases, because both of them ADD to the
649
+ // snapshot: a `baseline --from-db` snapshot contains the foreign table, and a
650
+ // scoped project carries its out-of-scope entries forward. The guard fires
651
+ // precisely when nothing ever claimed the object.
652
+ //
653
+ // Only on THIS path, and that is not an omission: the offline path diffs metadata
654
+ // against the committed snapshot, so it proposes a drop only for an object the
655
+ // snapshot HAS. A snapshot-absent drop is unreachable there by construction.
656
+ const unmanagedDrops = await snapshotAbsentDrops(diffResult.changes, snapshotPath(resolvePath(metaRoot, config.outDir), kysely.dialect));
657
+ if (unmanagedDrops.length > 0 && tokensToAllowOptions(config.allow).dropUnmanaged !== true) {
658
+ const named = unmanagedDrops.join(", ");
659
+ log.error(`migrate: refusing to drop ${named} — absent from the committed schema snapshot, so this ` +
660
+ `toolchain never managed ${unmanagedDrops.length === 1 ? "it" : "them"} and the migration ` +
661
+ `could not replay against a database where ${unmanagedDrops.length === 1 ? "it" : "they"} ` +
662
+ `never existed. Re-run with '--allow drop-unmanaged' if the drop is intended.`);
663
+ emitStructuredError(`migrate: refusing to drop ${named} — absent from the committed schema snapshot`, "re-run with '--allow drop-unmanaged' if the drop is intended", fmt);
664
+ await kysely.close();
665
+ return 2;
666
+ }
412
667
  // All changes — tables AND views — are emitted by the one schema-diff path.
413
668
  // View DDL (create/drop/replace) is produced by diff()'s view passes (2b body
414
669
  // comparison, 2c dependency-recreate) and rendered by every dialect's emitter;
@@ -507,7 +762,23 @@ wranglerRunner, fmt = "text") {
507
762
  // native ALTER vs recreate-and-copy on older SQLite.
508
763
  if (!config.dryRun && exitCode === 0 && !applyFailed && writtenPaths.length > 0) {
509
764
  try {
510
- await writeSnapshot(snapshotPath(resolvePath(metaRoot, config.outDir), kysely.dialect), actual.meta !== undefined ? { ...expected, meta: actual.meta } : expected);
765
+ // The COMMITTED snapshot keeps what `migrate.scope` excluded: writing the
766
+ // narrowed schema would delete every out-of-scope entry, so a later widening
767
+ // would propose CREATE TABLE for a table that exists and fail at apply. The
768
+ // out-of-scope entries come from `actual` — they are in the database, which
769
+ // is the same thing `baseline --from-db` records. Identical object, and so a
770
+ // byte-identical snapshot, for an unscoped run.
771
+ //
772
+ // The trade-off, stated so it is not rediscovered: those carried entries are
773
+ // INTROSPECTED descriptors, not metadata-built ones, so they can differ
774
+ // cosmetically from what this model would have emitted for the same table
775
+ // (column order, a default's rendered form). Removing the scope later can
776
+ // therefore produce one round of alter churn. That is strictly better than
777
+ // the alternative it replaced — a `CREATE TABLE` for a table that exists,
778
+ // which fails at apply — and it is the same mixed-provenance snapshot
779
+ // `baseline --from-db` writes for every table it adopts.
780
+ const committed = carryForwardOutOfScope(expected, actual, scoped.outOfScope);
781
+ await writeSnapshot(snapshotPath(resolvePath(metaRoot, config.outDir), kysely.dialect), actual.meta !== undefined ? { ...committed, meta: actual.meta } : committed);
511
782
  }
512
783
  catch (err) {
513
784
  // The migration itself is written (and possibly applied) — report the
@@ -568,7 +839,11 @@ wranglerRunner, fmt = "text") {
568
839
  * `--from-metadata` (default) derives it from metadata; `--from-db` introspects
569
840
  * an existing database once. Emits no migration.
570
841
  */
571
- export async function runBaseline(config, metaRoot, fmt = "text") {
842
+ export async function runBaseline(config, metaRoot, fmt = "text",
843
+ /** Directory whose `metaobjects.config.ts` governs — see `migrateCommand`'s
844
+ * `genRoot`. Defaults to `metaRoot`, the co-located case every `meta init`
845
+ * project is in and the only one the direct-call test suites construct. */
846
+ genRoot = metaRoot) {
572
847
  if (config.dialect === undefined) {
573
848
  log.error(`migrate baseline: --dialect required (or set migrate.dialect in .metaobjects/config.json)`);
574
849
  return 2;
@@ -621,23 +896,34 @@ export async function runBaseline(config, metaRoot, fmt = "text") {
621
896
  `baseline — this emits NO CREATE TABLE. Use it only if your database already matches ` +
622
897
  `your metadata; for a new/empty database run \`${greenfieldCreateCmd(config.dialect)}\` instead.`);
623
898
  let metadata;
624
- // Load metaobjects.config.ts ONCE, up front, for BOTH the consumer providers
899
+ // Load metaobjects.config.ts ONCE, up front, for BOTH the consumer providers/libraries
625
900
  // and the columnNamingStrategy — mirroring the DB path (and `meta gen`) so
626
901
  // offline baseline resolves config-registered custom subtypes too (#157).
627
- let baselineConfigProviders;
902
+ let baselineLoadOptions = {};
628
903
  let baselineStrategy = "snake_case";
629
904
  try {
630
- const cfg = await loadMetaobjectsConfig(metaRoot);
631
- baselineConfigProviders = cfg.providers;
905
+ const cfg = await loadMetaobjectsConfig(genRoot);
906
+ baselineLoadOptions = loadMemoryOptionsFrom(cfg);
632
907
  if (cfg.columnNamingStrategy)
633
908
  baselineStrategy = cfg.columnNamingStrategy;
634
909
  }
635
910
  catch {
636
- // config absent — no custom providers, default snake_case
911
+ // config absent — no custom providers, no libraries, default snake_case
637
912
  }
913
+ // `baseline` records a STARTING POINT, so it is deliberately NOT scoped: the
914
+ // `--from-db` arm captures whatever the database holds (there is no provenance
915
+ // for an introspected table), and an offline baseline that recorded less would
916
+ // disagree with it. An out-of-scope table sitting in the snapshot is harmless:
917
+ // every later run suppresses it on both sides of the diff, and an accepted
918
+ // scoped run carries it FORWARD (`carryForwardOutOfScope`) rather than dropping
919
+ // it — which is what makes that true. Committing the narrowed schema instead
920
+ // would delete the entry, and removing the scope later would then propose
921
+ // CREATE TABLE for a table that exists.
638
922
  try {
639
- metadata = await loadMemory(metaRoot, {
640
- ...(baselineConfigProviders !== undefined ? { providers: baselineConfigProviders } : {}),
923
+ const collection = await resolveCollection(metaRoot);
924
+ metadata = await loadMemory(collection.configDir, {
925
+ files: collection.files,
926
+ ...baselineLoadOptions,
641
927
  });
642
928
  }
643
929
  catch (err) {
@@ -735,35 +1021,50 @@ export async function runApplyPending(config, metaRoot, fmt = "text") {
735
1021
  * Scope: table/column/index/FK changes. Projection-view migrations stay on the
736
1022
  * introspection path (offline-view parity is a follow-up).
737
1023
  */
738
- export async function runOfflineGenerate(config, metaRoot, fmt = "text") {
1024
+ export async function runOfflineGenerate(config, metaRoot, fmt = "text",
1025
+ /** Directory whose `metaobjects.config.ts` governs — see `migrateCommand`'s
1026
+ * `genRoot`. Defaults to `metaRoot`, the co-located case. */
1027
+ genRoot = metaRoot) {
739
1028
  if (config.dialect === undefined) {
740
1029
  log.error(`migrate: --dialect required for offline generation (or use --from-db)`);
741
1030
  return 2;
742
1031
  }
743
- // Load metaobjects.config.ts ONCE, up front, for BOTH the consumer providers
1032
+ // Load metaobjects.config.ts ONCE, up front, for BOTH the consumer providers/libraries
744
1033
  // and the columnNamingStrategy — mirroring the DB path (and `meta gen`) so
745
1034
  // offline generate resolves config-registered custom subtypes too (#157).
746
- let offlineConfigProviders;
1035
+ let offlineLoadOptions = {};
747
1036
  let offlineStrategy = "snake_case";
748
1037
  try {
749
- const cfg = await loadMetaobjectsConfig(metaRoot);
750
- offlineConfigProviders = cfg.providers;
1038
+ const cfg = await loadMetaobjectsConfig(genRoot);
1039
+ offlineLoadOptions = loadMemoryOptionsFrom(cfg);
751
1040
  if (cfg.columnNamingStrategy)
752
1041
  offlineStrategy = cfg.columnNamingStrategy;
753
1042
  }
754
1043
  catch {
755
- // config absent — no custom providers, default snake_case
1044
+ // config absent — no custom providers, no libraries, default snake_case
756
1045
  }
757
1046
  let metadata;
1047
+ let collection;
758
1048
  try {
759
- metadata = await loadMemory(metaRoot, {
760
- ...(offlineConfigProviders !== undefined ? { providers: offlineConfigProviders } : {}),
1049
+ collection = await resolveCollection(metaRoot);
1050
+ metadata = await loadMemory(collection.configDir, {
1051
+ files: collection.files,
1052
+ ...offlineLoadOptions,
761
1053
  });
762
1054
  }
763
1055
  catch (err) {
764
1056
  log.error(`migrate: failed to load metadata: ${err.message}`);
765
1057
  return 2;
766
1058
  }
1059
+ const offlineDialect = config.dialect;
1060
+ const offlineViews = buildProjectionViews(metadata, { dialect: offlineDialect, columnNamingStrategy: offlineStrategy });
1061
+ const scopeRc = refuseScopeMismatch(collection, () => buildExpectedSchemaWithProvenance(metadata, {
1062
+ dialect: offlineDialect,
1063
+ columnNamingStrategy: offlineStrategy,
1064
+ views: offlineViews,
1065
+ }).provenance, fmt);
1066
+ if (scopeRc !== undefined)
1067
+ return scopeRc;
767
1068
  const outDir = resolvePath(metaRoot, config.outDir);
768
1069
  const path = snapshotPath(outDir, config.dialect);
769
1070
  let snapshot;
@@ -788,7 +1089,7 @@ export async function runOfflineGenerate(config, metaRoot, fmt = "text") {
788
1089
  }
789
1090
  const collectedAmbiguous = [];
790
1091
  const onAmbiguousResolution = mapOnAmbiguous(config.onAmbiguous);
791
- const offlineViews = buildProjectionViews(metadata, { dialect: config.dialect, columnNamingStrategy: offlineStrategy });
1092
+ const offlineScope = collection.inMigrateScope;
792
1093
  let plan;
793
1094
  try {
794
1095
  plan = await planOffline({
@@ -797,6 +1098,8 @@ export async function runOfflineGenerate(config, metaRoot, fmt = "text") {
797
1098
  snapshot,
798
1099
  columnNamingStrategy: offlineStrategy,
799
1100
  views: offlineViews,
1101
+ // Per-command scope — narrows BOTH sides of the offline diff (see planOffline).
1102
+ ...(offlineScope !== undefined ? { inScope: offlineScope } : {}),
800
1103
  allow: tokensToAllowOptions(config.allow),
801
1104
  onAmbiguous: async (a) => {
802
1105
  collectedAmbiguous.push(a);
@@ -817,7 +1120,11 @@ export async function runOfflineGenerate(config, metaRoot, fmt = "text") {
817
1120
  }
818
1121
  throw err;
819
1122
  }
820
- const { diff: diffResult, nextSnapshot } = plan;
1123
+ // `nextSnapshot` is what gets COMMITTED (it retains this run's out-of-scope
1124
+ // entries); `expected` is the governed side the emitter renders against. Equal
1125
+ // for an unscoped run.
1126
+ const { diff: diffResult, nextSnapshot, expected: governedExpected } = plan;
1127
+ logOutOfScope(plan.outOfScope, fmt);
821
1128
  if (diffResult.blocked.length > 0) {
822
1129
  log.error(`migrate: ${diffResult.blocked.length} destructive change(s) blocked; re-run with --allow <tokens>`);
823
1130
  return 1;
@@ -832,7 +1139,7 @@ export async function runOfflineGenerate(config, metaRoot, fmt = "text") {
832
1139
  }
833
1140
  const emitResult = emit(diffResult.changes, {
834
1141
  dialect: config.dialect,
835
- expectedSchema: nextSnapshot,
1142
+ expectedSchema: governedExpected,
836
1143
  actualSchema: snapshot,
837
1144
  ...(snapshot.meta ? { actualMeta: snapshot.meta } : {}),
838
1145
  });
@@ -907,7 +1214,12 @@ async function runRollback(config, metaRoot) {
907
1214
  }
908
1215
  }
909
1216
  }
910
- async function runD1Migrate(config, metaRoot, runner, fmt = "text") {
1217
+ async function runD1Migrate(config, metaRoot, cwd, runner, fmt = "text") {
1218
+ // Same split as `migrateCommand`: `.metaobjects/` state hangs off `metaRoot`,
1219
+ // `metaobjects.config.ts` off its own nearest-ancestor walk (#326). Recomputed
1220
+ // rather than threaded — it is a pure function of the two arguments this already
1221
+ // takes, so the two cannot disagree.
1222
+ const genRoot = resolveGenConfigDir(cwd, metaRoot);
911
1223
  // 1. Resolve wrangler.toml + binding.
912
1224
  const wranglerConfigPath = config.d1.wranglerConfigPath
913
1225
  ? resolvePath(metaRoot, config.d1.wranglerConfigPath)
@@ -931,6 +1243,11 @@ async function runD1Migrate(config, metaRoot, runner, fmt = "text") {
931
1243
  // No wrangler config but explicit binding — let wrangler discover the DB itself.
932
1244
  binding = { binding: config.d1.binding, database_name: "", database_id: "", migrations_dir: undefined };
933
1245
  }
1246
+ // The binding — and with it wrangler.toml's `migrations_dir` — is only now
1247
+ // known, so this is the earliest point d1 can honestly answer "where will
1248
+ // this run write?" (the caller skipped its own generic check for exactly
1249
+ // this reason; see the guard around that call).
1250
+ warnIfLedgerRelocated(cwd, resolveD1OutDir(config, metaRoot, binding.migrations_dir));
934
1251
  // 2. Build a D1Runner closure over the wrangler runner.
935
1252
  const d1Runner = async (sql) => {
936
1253
  const args = buildWranglerExecuteArgs({
@@ -944,34 +1261,39 @@ async function runD1Migrate(config, metaRoot, runner, fmt = "text") {
944
1261
  };
945
1262
  // 3. Load metadata. Best-effort config read for consumer providers; falls
946
1263
  // back to default core+forge bundle if metaobjects.config.ts is absent.
947
- let d1ConfigProviders;
1264
+ let d1LoadOptions = {};
948
1265
  try {
949
- const forgeConfig = await loadMetaobjectsConfig(metaRoot);
950
- d1ConfigProviders = forgeConfig.providers;
1266
+ d1LoadOptions = loadMemoryOptionsFrom(await loadMetaobjectsConfig(genRoot));
951
1267
  }
952
1268
  catch {
953
- d1ConfigProviders = undefined;
1269
+ d1LoadOptions = {};
1270
+ }
1271
+ // Discovery and load are separate failure modes (the `meta gen` pattern);
1272
+ // `resolveCollection`'s own ERR_COLLECTION_NOT_FOUND replaces the hand-rolled
1273
+ // ENOENT sniff, with the same exit 2.
1274
+ let collection;
1275
+ try {
1276
+ collection = await resolveCollection(metaRoot);
1277
+ }
1278
+ catch (err) {
1279
+ log.error(err.message);
1280
+ return 2;
954
1281
  }
955
1282
  let metadata;
956
1283
  try {
957
- metadata = await loadMemory(metaRoot, {
958
- ...(d1ConfigProviders !== undefined ? { providers: d1ConfigProviders } : {}),
1284
+ metadata = await loadMemory(collection.configDir, {
1285
+ files: collection.files,
1286
+ ...d1LoadOptions,
959
1287
  });
960
1288
  }
961
1289
  catch (err) {
962
- const msg = err.message;
963
- if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
964
- log.error(`no metaobjects/ found in ${metaRoot}; run 'meta init' to scaffold`);
965
- }
966
- else {
967
- log.error(`migrate: failed to load metadata: ${msg}`);
968
- }
1290
+ log.error(`migrate: failed to load metadata: ${err.message}`);
969
1291
  return 2;
970
1292
  }
971
1293
  // 4. Build expected schema + introspect actual.
972
1294
  let columnNamingStrategy = "snake_case";
973
1295
  try {
974
- const cfg = await loadMetaobjectsConfig(metaRoot);
1296
+ const cfg = await loadMetaobjectsConfig(genRoot);
975
1297
  if (cfg.columnNamingStrategy)
976
1298
  columnNamingStrategy = cfg.columnNamingStrategy;
977
1299
  }
@@ -979,7 +1301,14 @@ async function runD1Migrate(config, metaRoot, runner, fmt = "text") {
979
1301
  // metaobjects.config.ts absent or invalid — use default snake_case
980
1302
  }
981
1303
  const expectedViews = buildProjectionViews(metadata, { dialect: "d1", columnNamingStrategy });
982
- const expected = buildExpectedSchema(metadata, { dialect: "d1", columnNamingStrategy, views: expectedViews });
1304
+ const built = buildExpectedSchemaWithProvenance(metadata, { dialect: "d1", columnNamingStrategy, views: expectedViews });
1305
+ const scopeRc = refuseScopeMismatch(collection, () => built.provenance, fmt);
1306
+ if (scopeRc !== undefined)
1307
+ return scopeRc;
1308
+ // Per-command scope — both-sided, exactly as on the Kysely path above.
1309
+ const scoped = scopeExpectedSchema(built, collection.inMigrateScope);
1310
+ const expected = scoped.snapshot;
1311
+ logOutOfScope(scoped.outOfScope, fmt);
983
1312
  let actual;
984
1313
  try {
985
1314
  actual = await introspectD1({
@@ -999,7 +1328,8 @@ async function runD1Migrate(config, metaRoot, runner, fmt = "text") {
999
1328
  let diffResult;
1000
1329
  try {
1001
1330
  diffResult = await diff({
1002
- expected,
1331
+ // The three scoped-diff obligations, exactly as on the online path above.
1332
+ ...scopedDiffInputs(scoped, collectUnmanagedNames(metadata)),
1003
1333
  actual,
1004
1334
  // D1 is SQLite at the SQL level — the dialect activates the sqlite diff
1005
1335
  // semantics (structural FK matching: SQLite stores no FK names; CHECK
@@ -1011,8 +1341,6 @@ async function runD1Migrate(config, metaRoot, runner, fmt = "text") {
1011
1341
  // has no expressible migration; refuse loudly instead of emitting SQL that drops
1012
1342
  // the constraint and breaks referencing FKs at apply (same failure as the online path).
1013
1343
  refusePrimaryKeyChange: true,
1014
- // #208 §7 — declared-@unmanaged objects are external (see the online path above).
1015
- unmanagedNames: collectUnmanagedNames(metadata),
1016
1344
  onAmbiguous: async (a) => {
1017
1345
  collectedAmbiguous.push(a);
1018
1346
  return onAmbiguousResolution;
@@ -1067,11 +1395,9 @@ async function runD1Migrate(config, metaRoot, runner, fmt = "text") {
1067
1395
  }
1068
1396
  const combinedUp = emitResult.up;
1069
1397
  const combinedDown = emitResult.down;
1070
- // Migration dir resolution: --out-dir > wrangler.toml's migrations_dir > "migrations".
1071
- // The default outDir (./.metaobjects/migrations) is the Kysely-path default; for D1
1072
- // we fall back to wrangler conventions when the caller hasn't overridden it.
1073
- const isDefaultOutDir = config.outDir === MIGRATE_DEFAULT_OUT_DIR;
1074
- const migrationsDir = resolvePath(metaRoot, isDefaultOutDir ? (binding.migrations_dir ?? "migrations") : config.outDir);
1398
+ // Migration dir resolution same convention `warnIfLedgerRelocated` was
1399
+ // just given above, so the two cannot drift apart.
1400
+ const migrationsDir = resolveD1OutDir(config, metaRoot, binding.migrations_dir);
1075
1401
  if (config.dryRun) {
1076
1402
  log.info(`-- UP --\n${combinedUp}\n\n-- DOWN --\n${combinedDown}`);
1077
1403
  return 0;