@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";
@@ -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
- buildExpectedSchema,
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
- provisions a fresh/CI database. postgres/sqlite only.
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
- const metaRoot = cwd;
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 postgresConfigProviders: readonly import("@metaobjectsdev/codegen-ts").MetaDataTypeProvider[] | undefined;
604
+ let postgresLoadOptions: ReturnType<typeof loadMemoryOptionsFrom> = {};
385
605
  try {
386
- const forgeConfig = await loadMetaobjectsConfig(metaRoot);
387
- postgresConfigProviders = forgeConfig.providers;
606
+ postgresLoadOptions = loadMemoryOptionsFrom(await loadMetaobjectsConfig(genRoot));
388
607
  } catch {
389
- postgresConfigProviders = undefined;
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(metaRoot, {
395
- ...(postgresConfigProviders !== undefined ? { providers: postgresConfigProviders } : {}),
626
+ metadata = await loadMemory(collection.configDir, {
627
+ files: collection.files,
628
+ ...postgresLoadOptions,
396
629
  });
397
630
  } catch (err) {
398
- const msg = (err as Error).message;
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(metaRoot);
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 expected = buildExpectedSchema(metadata, {
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
- expected,
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 ? { ...expected, meta: actual.meta } : expected,
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 baselineConfigProviders:
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(metaRoot);
743
- baselineConfigProviders = cfg.providers;
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
- metadata = await loadMemory(metaRoot, {
750
- ...(baselineConfigProviders !== undefined ? { providers: baselineConfigProviders } : {}),
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 offlineConfigProviders:
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(metaRoot);
876
- offlineConfigProviders = cfg.providers;
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
- metadata = await loadMemory(metaRoot, {
885
- ...(offlineConfigProviders !== undefined ? { providers: offlineConfigProviders } : {}),
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 offlineViews = buildProjectionViews(metadata, { dialect: config.dialect, columnNamingStrategy: offlineStrategy });
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
- const { diff: diffResult, nextSnapshot } = plan;
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: nextSnapshot,
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 d1ConfigProviders: readonly import("@metaobjectsdev/codegen-ts").MetaDataTypeProvider[] | undefined;
1438
+ let d1LoadOptions: ReturnType<typeof loadMemoryOptionsFrom> = {};
1097
1439
  try {
1098
- const forgeConfig = await loadMetaobjectsConfig(metaRoot);
1099
- d1ConfigProviders = forgeConfig.providers;
1440
+ d1LoadOptions = loadMemoryOptionsFrom(await loadMetaobjectsConfig(genRoot));
1100
1441
  } catch {
1101
- d1ConfigProviders = undefined;
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(metaRoot, {
1107
- ...(d1ConfigProviders !== undefined ? { providers: d1ConfigProviders } : {}),
1458
+ metadata = await loadMemory(collection.configDir, {
1459
+ files: collection.files,
1460
+ ...d1LoadOptions,
1108
1461
  });
1109
1462
  } catch (err) {
1110
- const msg = (err as Error).message;
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(metaRoot);
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 expected = buildExpectedSchema(metadata, { dialect: "d1", columnNamingStrategy, views: expectedViews });
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
- expected,
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: --out-dir > wrangler.toml's migrations_dir > "migrations".
1221
- // The default outDir (./.metaobjects/migrations) is the Kysely-path default; for D1
1222
- // we fall back to wrangler conventions when the caller hasn't overridden it.
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}`);