@metaobjectsdev/cli 1.0.5 → 1.0.7-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metaobjectsdev/cli",
3
- "version": "1.0.5",
3
+ "version": "1.0.7-rc.1",
4
4
  "description": "CLI for MetaObjects: scaffold, codegen, migrate, and drift-detection commands.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -51,15 +51,15 @@
51
51
  "@biomejs/js-api": "^0.7.0",
52
52
  "@biomejs/wasm-nodejs": "^1.9.4",
53
53
  "@libsql/kysely-libsql": "^0.4.0",
54
- "@metaobjectsdev/codegen-ts": "1.0.5",
55
- "@metaobjectsdev/codegen-ts-react": "1.0.5",
56
- "@metaobjectsdev/codegen-ts-tanstack": "1.0.5",
57
- "@metaobjectsdev/docs-site": "1.0.5",
58
- "@metaobjectsdev/metadata": "1.0.5",
59
- "@metaobjectsdev/migrate-ts": "1.0.5",
60
- "@metaobjectsdev/render": "1.0.5",
61
- "@metaobjectsdev/runtime-ts": "1.0.5",
62
- "@metaobjectsdev/sdk": "1.0.5",
54
+ "@metaobjectsdev/codegen-ts": "1.0.7-rc.1",
55
+ "@metaobjectsdev/codegen-ts-react": "1.0.7-rc.1",
56
+ "@metaobjectsdev/codegen-ts-tanstack": "1.0.7-rc.1",
57
+ "@metaobjectsdev/docs-site": "1.0.7-rc.1",
58
+ "@metaobjectsdev/metadata": "1.0.7-rc.1",
59
+ "@metaobjectsdev/migrate-ts": "1.0.7-rc.1",
60
+ "@metaobjectsdev/render": "1.0.7-rc.1",
61
+ "@metaobjectsdev/runtime-ts": "1.0.7-rc.1",
62
+ "@metaobjectsdev/sdk": "1.0.7-rc.1",
63
63
  "@toon-format/toon": "^2.3.0",
64
64
  "jiti": "^2.4.0"
65
65
  },
@@ -17,6 +17,9 @@ import { collectionLoadOptions } from "../lib/collection-load-options.js";
17
17
  import { exclusionNotes, importedOption, migrateScopeMismatch } from "../lib/migrate-scope.js";
18
18
  import {
19
19
  allowOptionFor,
20
+ isBlockedChangesError,
21
+ isPrimaryKeyChangeError,
22
+ isDeclaredRenameError,
20
23
  buildExpectedSchemaWithProvenance,
21
24
  scopeExpectedSchema,
22
25
  scopedDiffInputs,
@@ -32,8 +35,7 @@ import {
32
35
  readSnapshot,
33
36
  writeSnapshot,
34
37
  qualifiedDbName,
35
- BlockedChangesError,
36
- PrimaryKeyChangeError,
38
+ type BlockedChangesError,
37
39
  renderD1,
38
40
  writeMigrationD1,
39
41
  writeMigrationFlyway,
@@ -46,6 +48,7 @@ import {
46
48
  type AmbiguousChange,
47
49
  type AmbiguousResolution,
48
50
  type Change,
51
+ type DataHazard,
49
52
  type D1Binding,
50
53
  type EmitResult,
51
54
  type D1Runner,
@@ -112,6 +115,15 @@ MIGRATE FLAGS:
112
115
  never existed.
113
116
  --on-ambiguous abort|rename|drop-add
114
117
  How to handle ambiguous renames (default: abort)
118
+ --rename-table [schema.]old=new
119
+ Declare a table rename (repeatable). Resolves the drop+create
120
+ as ALTER TABLE ... RENAME TO whatever the rename heuristic
121
+ makes of the names; refused if no such pair is pending.
122
+ --rename-column [schema.]table.old=new
123
+ Declare a column rename (repeatable); keeps the column's data.
124
+ 'table' is the name in the metadata (the NEW name when the
125
+ table is renamed in the same run). The column's type,
126
+ nullability and default must not change in the same run.
115
127
  --from-db Introspect live DB instead of using the committed snapshot
116
128
  --apply Run pending migration files against the DB after writing
117
129
  --rollback <target> Roll back applied migrations newer than <target>
@@ -269,6 +281,47 @@ function mapOnAmbiguous(v: "abort" | "rename" | "drop-add"): AmbiguousResolution
269
281
  return v === "drop-add" ? "drop+add" : v;
270
282
  }
271
283
 
284
+ /**
285
+ * The report-and-exit for an engine refusal whose message the engine already made
286
+ * actionable — the sibling of `refuseScopeMismatch` above, which is the same decision
287
+ * for scope. The engine's guards (`isPrimaryKeyChangeError` et al.) narrow at the catch
288
+ * site; only the hint varies by refusal, so each hint is written once.
289
+ */
290
+ function refuseEngineError(err: Error, hint: string, fmt: OutputFormat): number {
291
+ log.error(`migrate: ${err.message}`);
292
+ emitStructuredError(`migrate: ${err.message}`, hint, fmt);
293
+ return 1;
294
+ }
295
+
296
+ const PK_CHANGE_HINT =
297
+ "align the primary key manually, or reconcile the metadata identity to match the live table";
298
+ const DECLARED_RENAME_HINT =
299
+ "fix or remove the --rename-table / --rename-column flag; a table's name is its name in the metadata (the new name when it is renamed too)";
300
+
301
+ /**
302
+ * Warn about changes that apply to an empty table and fail on a populated one. Warn, not
303
+ * refuse: the diff cannot see rows, and a new required column on an empty table is fine.
304
+ * The Postgres migration file carries the same warning, with the preparation step, above
305
+ * the statement.
306
+ */
307
+ function warnDataHazards(hazards: readonly DataHazard[]): string[] {
308
+ const warnings = hazards.map((h) => {
309
+ const t = h.schema !== undefined ? `${h.schema}.${h.table}` : h.table;
310
+ switch (h.kind) {
311
+ case "add-required-column":
312
+ return `${t}.${h.column} is added NOT NULL with no default — fails if ${t} has rows. `
313
+ + `Give the field a @default, or add it optional, backfill, then make it required.`;
314
+ case "set-not-null":
315
+ return `${t}.${h.column} becomes NOT NULL — fails if any row holds NULL. Backfill it first.`;
316
+ case "add-check":
317
+ return `CHECK ${h.check} is added to ${t} — fails if any existing row violates `
318
+ + `(${h.expression}). Fix those rows first.`;
319
+ }
320
+ });
321
+ for (const w of warnings) log.warn(`migrate: ${w}`);
322
+ return warnings;
323
+ }
324
+
272
325
  /**
273
326
  * The command that actually creates tables in a fresh/empty database. This is the
274
327
  * correct first step for a brand-new project — it introspects the (empty) DB, diffs
@@ -657,6 +710,7 @@ export async function migrateCommand(
657
710
  let applyFailed = false;
658
711
  let blocked: BlockedEntry[] = [];
659
712
  let ambiguous: AmbiguousEntry[] = [];
713
+ let hazardWarnings: string[] = [];
660
714
  let changeCounts: Record<string, number> = {};
661
715
 
662
716
  try {
@@ -712,6 +766,7 @@ export async function migrateCommand(
712
766
  actual,
713
767
  dialect: kysely.dialect,
714
768
  allow: tokensToAllowOptions(config.allow),
769
+ renames: config.renames,
715
770
  // #258 — adopting a live DB whose PRIMARY KEY differs from the metadata identity
716
771
  // has no expressible migration; refuse loudly instead of emitting SQL that drops
717
772
  // the constraint and breaks referencing FKs at apply.
@@ -723,11 +778,14 @@ export async function migrateCommand(
723
778
  });
724
779
  } catch (err) {
725
780
  // #258 — a primary-key move has no expressible migration; refuse loudly.
726
- if (err instanceof PrimaryKeyChangeError) {
727
- log.error(`migrate: ${err.message}`);
728
- emitStructuredError(`migrate: ${err.message}`, "align the primary key manually, or reconcile the metadata identity to match the live table", fmt);
781
+ if (isPrimaryKeyChangeError(err)) {
782
+ const rc = refuseEngineError(err, PK_CHANGE_HINT, fmt);
729
783
  await kysely.close();
730
- return 1;
784
+ return rc;
785
+ }
786
+ if (isDeclaredRenameError(err)) {
787
+ await kysely.close();
788
+ return refuseEngineError(err, DECLARED_RENAME_HINT, fmt);
731
789
  }
732
790
  // diff() throws when onAmbiguous returns "abort" — surface as exit 1
733
791
  // with the collected ambiguity list.
@@ -756,6 +814,7 @@ export async function migrateCommand(
756
814
  }
757
815
 
758
816
  changeCounts = summarizeChanges(diffResult.changes);
817
+ hazardWarnings = warnDataHazards(diffResult.hazards);
759
818
 
760
819
  // #313 — refuse to AUTHOR a drop for an object the committed snapshot never
761
820
  // contained. This path diffs metadata against introspection and never reads the
@@ -813,7 +872,7 @@ export async function migrateCommand(
813
872
  ...(actual.meta !== undefined ? { actualMeta: actual.meta } : {}),
814
873
  });
815
874
  } catch (err) {
816
- if (err instanceof BlockedChangesError) {
875
+ if (isBlockedChangesError(err)) {
817
876
  blocked = blockedToEntries(err);
818
877
  exitCode = 1;
819
878
  } else {
@@ -944,6 +1003,7 @@ export async function migrateCommand(
944
1003
  format: config.format,
945
1004
  applied: appliedNames,
946
1005
  applyFailed,
1006
+ warnings: hazardWarnings,
947
1007
  };
948
1008
  const output =
949
1009
  fmt === "toon" ? formatMigrateResultToon(migrateResult)
@@ -1267,6 +1327,7 @@ export async function runOfflineGenerate(
1267
1327
  // the default `meta migrate` — performing no import exclusion at all.
1268
1328
  ...importedOption(collection),
1269
1329
  allow: tokensToAllowOptions(config.allow),
1330
+ renames: config.renames,
1270
1331
  onAmbiguous: async (a) => {
1271
1332
  collectedAmbiguous.push(a);
1272
1333
  return onAmbiguousResolution;
@@ -1274,11 +1335,8 @@ export async function runOfflineGenerate(
1274
1335
  });
1275
1336
  } catch (err) {
1276
1337
  // #258 — a primary-key move has no expressible migration; refuse loudly.
1277
- if (err instanceof PrimaryKeyChangeError) {
1278
- log.error(`migrate: ${err.message}`);
1279
- emitStructuredError(`migrate: ${err.message}`, "align the primary key manually, or reconcile the metadata identity to match the live table", fmt);
1280
- return 1;
1281
- }
1338
+ if (isPrimaryKeyChangeError(err)) return refuseEngineError(err, PK_CHANGE_HINT, fmt);
1339
+ if (isDeclaredRenameError(err)) return refuseEngineError(err, DECLARED_RENAME_HINT, fmt);
1282
1340
  if ((err as Error).message.includes("aborted by onAmbiguous")) {
1283
1341
  log.error(`migrate: ambiguous rename/drop detected; re-run with --on-ambiguous rename|drop-add`);
1284
1342
  return 1;
@@ -1304,6 +1362,7 @@ export async function runOfflineGenerate(
1304
1362
  log.error(`migrate: --slug <name> required when there are changes (e.g., --slug add-user-shipping)`);
1305
1363
  return 2;
1306
1364
  }
1365
+ warnDataHazards(diffResult.hazards);
1307
1366
 
1308
1367
  const emitResult = emit(diffResult.changes, {
1309
1368
  dialect: config.dialect,
@@ -1523,6 +1582,7 @@ async function runD1Migrate(
1523
1582
  // @constraintName models churning and enum @values changes silent on D1.
1524
1583
  dialect: "d1",
1525
1584
  allow: tokensToAllowOptions(config.allow),
1585
+ renames: config.renames,
1526
1586
  // #258 — adopting a live D1 DB whose PRIMARY KEY differs from the metadata identity
1527
1587
  // has no expressible migration; refuse loudly instead of emitting SQL that drops
1528
1588
  // the constraint and breaks referencing FKs at apply (same failure as the online path).
@@ -1534,11 +1594,8 @@ async function runD1Migrate(
1534
1594
  });
1535
1595
  } catch (err) {
1536
1596
  // #258 — a primary-key move has no expressible migration; refuse loudly.
1537
- if (err instanceof PrimaryKeyChangeError) {
1538
- log.error(`migrate: ${err.message}`);
1539
- emitStructuredError(`migrate: ${err.message}`, "align the primary key manually, or reconcile the metadata identity to match the live table", fmt);
1540
- return 1;
1541
- }
1597
+ if (isPrimaryKeyChangeError(err)) return refuseEngineError(err, PK_CHANGE_HINT, fmt);
1598
+ if (isDeclaredRenameError(err)) return refuseEngineError(err, DECLARED_RENAME_HINT, fmt);
1542
1599
  if ((err as Error).message.includes("aborted by onAmbiguous")) {
1543
1600
  const entries = ambiguousToEntries(collectedAmbiguous);
1544
1601
  for (const e of entries) {
@@ -1551,6 +1608,7 @@ async function runD1Migrate(
1551
1608
  }
1552
1609
 
1553
1610
  const changeCounts = summarizeChanges(diffResult.changes);
1611
+ warnDataHazards(diffResult.hazards);
1554
1612
 
1555
1613
  // Views are emitted by the one schema-diff path: renderD1 = renderSqlite (which
1556
1614
  // renders view DDL) + the D1 safety pass (applied inside renderD1, stripping the
@@ -1572,7 +1630,7 @@ async function runD1Migrate(
1572
1630
  try {
1573
1631
  emitResult = renderD1(diffResult.changes, expected, actual.meta, actual);
1574
1632
  } catch (err) {
1575
- if (err instanceof BlockedChangesError) {
1633
+ if (isBlockedChangesError(err)) {
1576
1634
  const entries = blockedToEntries(err);
1577
1635
  for (const e of entries) {
1578
1636
  log.error(`migrate: blocked '${e.kind}' on ${e.description} (allow with --allow ${e.allowFlag})`);
@@ -122,7 +122,7 @@ const ERR_UNKNOWN_ATTR = "ERR_UNKNOWN_ATTR";
122
122
  */
123
123
  const EMPTY_MIGRATE_FLAGS = {
124
124
  db: undefined, dialect: undefined, format: undefined, outDir: undefined, slug: undefined,
125
- allow: [], onAmbiguous: undefined, dryRun: false, d1Binding: undefined, remote: false,
125
+ allow: [], onAmbiguous: undefined, renames: [], dryRun: false, d1Binding: undefined, remote: false,
126
126
  apply: false, rollback: undefined, yes: false, fromDb: false, baseline: false,
127
127
  applyPending: false,
128
128
  } as const satisfies MigrateFlags;
package/src/index.ts CHANGED
@@ -143,6 +143,8 @@ MIGRATE FLAGS:
143
143
  drop-check,drop-view,drop-view-cascade,
144
144
  adopt-view,nullable-to-not-null,drop-identity-default
145
145
  --on-ambiguous abort|rename|drop-add Default abort
146
+ --rename-table [schema.]old=new Declare a table rename (repeatable)
147
+ --rename-column [schema.]table.old=new Declare a column rename; keeps its data (repeatable)
146
148
  --d1 <binding> D1 binding name from wrangler.toml (only with --dialect d1)
147
149
  --remote Target remote D1 instead of local (only with --dialect d1)
148
150
  --apply Run 'wrangler d1 migrations apply' after writing files
package/src/lib/args.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { parseArgs } from "node:util";
2
+ import type { DeclaredRename } from "@metaobjectsdev/migrate-ts";
2
3
  import { parseAdvisoryLimit } from "./advisory.js";
3
4
 
4
5
  // ---------------------------------------------------------------------------
@@ -510,6 +511,8 @@ export interface MigrateFlags {
510
511
  slug: string | undefined;
511
512
  allow: AllowToken[];
512
513
  onAmbiguous: OnAmbiguous | undefined;
514
+ /** `--rename-table` / `--rename-column`: renames the author declares (see `DeclaredRename`). */
515
+ renames: DeclaredRename[];
513
516
  dryRun: boolean;
514
517
  // D1-specific:
515
518
  d1Binding: string | undefined;
@@ -539,6 +542,8 @@ export const MIGRATE_OPTIONS = {
539
542
  "slug": { type: "string" },
540
543
  "allow": { type: "string", multiple: true },
541
544
  "on-ambiguous": { type: "string" },
545
+ "rename-table": { type: "string", multiple: true },
546
+ "rename-column": { type: "string", multiple: true },
542
547
  "dry-run": { type: "boolean", default: false },
543
548
  "from-db": { type: "boolean", default: false },
544
549
  "d1": { type: "string" },
@@ -593,6 +598,10 @@ export function parseMigrateArgs(argv: string[]): MigrateFlags {
593
598
  slug: values.slug as string | undefined,
594
599
  allow: allowTokens as AllowToken[],
595
600
  onAmbiguous: onAmb as OnAmbiguous | undefined,
601
+ renames: [
602
+ ...((values["rename-table"] as string[] | undefined) ?? []).map(parseTableRename),
603
+ ...((values["rename-column"] as string[] | undefined) ?? []).map(parseColumnRename),
604
+ ],
596
605
  dryRun: !!values["dry-run"],
597
606
  d1Binding: values.d1 as string | undefined,
598
607
  remote: !!values.remote,
@@ -605,6 +614,40 @@ export function parseMigrateArgs(argv: string[]): MigrateFlags {
605
614
  };
606
615
  }
607
616
 
617
+ /** `--rename-table [schema.]old=new` — the new name is bare: a rename never moves schemas. */
618
+ function parseTableRename(v: string): DeclaredRename {
619
+ const { path, to } = splitRename(v, "--rename-table", "[schema.]old=new", 1, 2);
620
+ const from = path[path.length - 1]!;
621
+ return path.length === 2 ? { kind: "table", schema: path[0]!, from, to } : { kind: "table", from, to };
622
+ }
623
+
624
+ /** `--rename-column [schema.]table.old=new` — `table` is the table's name in the metadata. */
625
+ function parseColumnRename(v: string): DeclaredRename {
626
+ const { path, to } = splitRename(v, "--rename-column", "[schema.]table.old=new", 2, 3);
627
+ const [from, table] = [path[path.length - 1]!, path[path.length - 2]!];
628
+ return path.length === 3
629
+ ? { kind: "column", schema: path[0]!, table, from, to }
630
+ : { kind: "column", table, from, to };
631
+ }
632
+
633
+ /**
634
+ * Split `<path>=<new>` into the dotted path and the new name, refusing anything the
635
+ * flag's grammar cannot hold — including a path of [minPath, maxPath] segments, so the
636
+ * `invalid <flag>` message has the one construction site its callers also use.
637
+ */
638
+ function splitRename(
639
+ v: string, flag: string, shape: string, minPath: number, maxPath: number,
640
+ ): { path: string[]; to: string } {
641
+ const eq = v.indexOf("=");
642
+ const path = eq < 0 ? [] : v.slice(0, eq).split(".");
643
+ const to = eq < 0 ? "" : v.slice(eq + 1);
644
+ if (to === "" || to.includes(".") || to.includes("=") || path.some((p) => p === "")
645
+ || path.length < minPath || path.length > maxPath) {
646
+ throw new Error(`invalid ${flag} '${v}'; expected ${shape}`);
647
+ }
648
+ return { path, to };
649
+ }
650
+
608
651
  // ---------------------------------------------------------------------------
609
652
  // eject flags — FR-040 §4.2(a)
610
653
  // ---------------------------------------------------------------------------
package/src/lib/config.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { ConfigSchema, type Config, DEFAULT_METAOBJECTS_DIR } from "@metaobjectsdev/sdk";
4
4
  import type { GenFlags, MigrateFlags, MigrateFormat } from "./args.js";
5
5
  import type { Dialect } from "./kysely.js";
6
+ import type { DeclaredRename } from "@metaobjectsdev/migrate-ts";
6
7
 
7
8
  // ---------------------------------------------------------------------------
8
9
  // Built-in defaults
@@ -42,6 +43,8 @@ export interface ResolvedMigrateConfig {
42
43
  /** Output-format adapter (#192): "default" homegrown layout, or "flyway" V__/U__. */
43
44
  format: MigrateFormat;
44
45
  onAmbiguous: "abort" | "rename" | "drop-add";
46
+ /** Declared renames — CLI-only, never config: a rename is a one-time migration event. */
47
+ renames: DeclaredRename[];
45
48
  allow: string[];
46
49
  slug: string | undefined;
47
50
  dryRun: boolean;
@@ -121,6 +124,7 @@ export async function resolveMigrateConfig(
121
124
  dialect: flags.dialect ?? cfgBlock.dialect ?? MIGRATE_DEFAULTS.dialect,
122
125
  format: flags.format ?? cfgBlock.format ?? MIGRATE_DEFAULTS.format,
123
126
  onAmbiguous: flags.onAmbiguous ?? cfgBlock.onAmbiguous ?? MIGRATE_DEFAULTS.onAmbiguous,
127
+ renames: flags.renames,
124
128
  allow: flags.allow.length > 0
125
129
  ? flags.allow
126
130
  : (cfgBlock.allow ?? MIGRATE_DEFAULTS.allow),
package/src/lib/output.ts CHANGED
@@ -181,6 +181,12 @@ export interface MigrateResultShape {
181
181
  * that cannot work.
182
182
  */
183
183
  format?: "default" | "flyway";
184
+ /**
185
+ * Data-hazard warnings: changes that apply to an empty table and fail on a populated one.
186
+ * Text output already carries them on stderr; JSON/toon carry them here so a programmatic
187
+ * caller sees the risk before applying.
188
+ */
189
+ warnings?: string[];
184
190
  }
185
191
 
186
192
  export function formatMigrateResult(result: MigrateResultShape, _opts: FormatOptions): string {
@@ -298,6 +304,7 @@ export function migrateResultToData(result: MigrateResultShape): {
298
304
  written: string[];
299
305
  summary: string;
300
306
  help: string[];
307
+ warnings?: string[];
301
308
  } {
302
309
  const changeEntries = Object.entries(result.changeCounts).filter(([, v]) => v > 0);
303
310
  const changes = changeEntries.map(([kind, count]) => ({ kind, count }));
@@ -352,7 +359,12 @@ export function migrateResultToData(result: MigrateResultShape): {
352
359
  help = ["re-run with --slug <name> to write the migration"];
353
360
  }
354
361
 
355
- return { changes, written: result.writtenPaths, summary, help };
362
+ const warnings = result.warnings ?? [];
363
+ return {
364
+ changes, written: result.writtenPaths, summary, help,
365
+ // Only when present, so a run with no hazard keeps its existing shape.
366
+ ...(warnings.length > 0 ? { warnings } : {}),
367
+ };
356
368
  }
357
369
 
358
370
  export function formatMigrateResultToon(result: MigrateResultShape): string {