Skip to content

Commit 6bbe307

Browse files
dmealingclaude
andcommitted
fix(cli,codegen-ts): unblock the TS newcomer quickstart (migrate trap + nodenext imports)
Two pre-launch first-touch fixes so a newcomer following the docs reaches a working app under a stock toolchain. 1. `meta migrate baseline` greenfield trap (cli). An offline baseline (no --from-db) recorded the metadata's DESIRED schema as already-applied, so on an empty/new database no CREATE TABLE was ever emitted and the generated server 500'd "no such table" -- while the CLI reported success at every step. - runBaseline now introspects the target --db and REFUSES (exit 2, writes no snapshot) when it proves the DB is empty; every other offline baseline still writes the snapshot but WARNs that it emits no DDL. - The no-snapshot hint, the `meta gen` success hint, and `meta migrate --help` now route to the working `meta migrate --from-db --db <url> --dialect <d> --slug init --apply` path instead of the `baseline` trap. 2. nodenext-safe generated imports (codegen-ts). Generated relative imports were un-extensioned (`./Author`, `../db`), failing TS2835 under a stock `tsc --init` (nodenext) config; the repo's own bundler tsconfig masked it. - The `extStyle` default flips to "js" and `meta init` scaffolds `extStyle: "js"` + `.js`-extensioned owned-generator imports. `.js` specifiers resolve under both nodenext and bundler, so this is strictly more compatible. - relativeModuleSpecifier now extension-styles a relative dbImport too (`../db` -> `../db.js`), guarded against double-extension and bare packages. Gated by a new nodenext compile gate (real generated output, zero TS2835), a greenfield baseline-guard suite, and a jiti config-load regression for the scaffolded `.js` imports. Docs (ports/typescript.md) updated to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeGSV3StPCcJGZNNJ4ZfAb
1 parent 66ba667 commit 6bbe307

21 files changed

Lines changed: 539 additions & 117 deletions

docs/ports/typescript.md

Lines changed: 39 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,15 @@ imports those local copies; `meta gen` runs from them, not from the package.
4646
// metaobjects.config.ts
4747
import { defineConfig } from "@metaobjectsdev/cli";
4848
// Owned generators scaffolded by `meta init` — yours to edit (ADR-0034).
49-
import { entityFile } from "./codegen/generators/entity";
50-
import { queriesFile } from "./codegen/generators/queries";
51-
import { routesFile } from "./codegen/generators/routes";
52-
import { barrel } from "./codegen/generators/barrel";
49+
import { entityFile } from "./codegen/generators/entity.js";
50+
import { queriesFile } from "./codegen/generators/queries.js";
51+
import { routesFile } from "./codegen/generators/routes.js";
52+
import { barrel } from "./codegen/generators/barrel.js";
5353

5454
export default defineConfig({
5555
outDir: "src/generated",
5656
dialect: "postgres", // "postgres" | "sqlite" | "d1"
57+
extStyle: "js", // ".js"-extensioned imports — nodenext- AND bundler-safe ("none" to opt out)
5758
apiPrefix: "/api",
5859
columnNamingStrategy: "snake_case", // "snake_case" | "literal" | "kebab-case"
5960
generators: [entityFile(), queriesFile(), routesFile(), barrel()],
@@ -135,20 +136,20 @@ brand-new project there's no snapshot yet:
135136

136137
```bash
137138
meta migrate --dialect sqlite --slug init
138-
# meta: migrate: no schema snapshot at .../.schema.sqlite.json;
139-
# run `meta migrate baseline --dialect sqlite` first
139+
# meta: migrate: no schema snapshot at .../.schema.sqlite.json. For a new project
140+
# run `meta migrate --from-db --db <url> --dialect sqlite --slug init --apply`
141+
# to create your tables, or `meta migrate baseline --from-db --db <url>` to
142+
# adopt an existing database.
140143
```
141144

142-
**Do not follow that hint on a database that doesn't exist yet.** `meta
143-
migrate baseline` (without `--from-db`) derives the "existing" snapshot
144-
**from your metadata** — it records your entities' target shape as already
145-
applied, even against an empty/nonexistent database. Every subsequent `meta
146-
migrate` then reports `no changes` (exit 0) forever, and no table is ever
147-
created — a silent failure that only surfaces later, at the API layer, as
148-
`SQLITE_ERROR: no such table`.
149-
150-
For a brand-new database, introspect the (empty) live DB instead and apply
151-
immediately — this is the correct one-time bootstrap command:
145+
The CLI points you at the right command (above). Avoid the `meta migrate
146+
baseline` subcommand **without** `--from-db` on a database that doesn't exist
147+
yet: an offline baseline derives the "existing" snapshot **from your metadata**,
148+
recording your entities' target shape as already applied. No table is ever
149+
created, and every subsequent `meta migrate` reports `no changes` (exit 0) — a
150+
silent failure that only surfaces later, at the API layer, as `SQLITE_ERROR: no
151+
such table`. (`meta migrate baseline` now refuses when it can see the target
152+
`--db` is empty, but the safe path for a new project is simply:)
152153

153154
```bash
154155
npx meta migrate --from-db --db file:dev.sqlite --dialect sqlite --slug init --apply
@@ -170,28 +171,20 @@ meta migrate --dialect d1 # Cloudflare D1
170171

171172
### Typecheck the generated code
172173

173-
`npx tsc` (the hint every `meta gen`/`meta migrate` prints) needs a
174-
`tsconfig.json` that matches how the generated code is written: relative
175-
imports with **no file extensions**, resolved bundler-style — the same
176-
convention this repo's own `server/typescript/tsconfig.base.json` uses. A
177-
fresh `tsc --init` on some TypeScript versions instead defaults to Node's
178-
native ESM resolution (`"module": "nodenext"` / `"node16"`), which *requires*
179-
extensioned relative imports (`./Author.js`) and fails on every generated file
180-
with `TS2835`. Set:
181-
182-
```jsonc
183-
// tsconfig.json
184-
{
185-
"compilerOptions": {
186-
"module": "esnext",
187-
"moduleResolution": "bundler"
188-
// ...your other options
189-
}
190-
}
191-
```
192-
193-
and make sure `package.json` has `"type": "module"` — MetaObjects generates
194-
ESM only, no CommonJS.
174+
`npx tsc` (the hint every `meta gen`/`meta migrate` prints) works out of the
175+
box with a stock `tsc --init` tsconfig: the generated code emits
176+
`.js`-extensioned relative imports (`import { Author } from "./Author.js"`),
177+
which resolve correctly under **both** Node's native ESM resolution
178+
(`"module": "nodenext"`, the `tsc --init` default) **and** bundler-style
179+
resolution (`"moduleResolution": "bundler"`, Vite/esbuild/tsx). The one project
180+
setting to check: `package.json` must have `"type": "module"` — MetaObjects
181+
generates ESM only, no CommonJS. (The `.js` extension names the compiled output;
182+
`tsc`/your bundler resolves it back to the `.ts` source — this is the standard
183+
Node-ESM TypeScript convention.)
184+
185+
If you prefer un-extensioned imports (some legacy bundler setups), set
186+
`extStyle: "none"` in `metaobjects.config.ts` and use `"moduleResolution":
187+
"bundler"`.
195188

196189
## Use
197190

@@ -218,7 +211,7 @@ export const db = drizzle(createClient({ url: "file:dev.sqlite" }));
218211
```ts
219212
// src/server.ts
220213
import Fastify from "fastify";
221-
import { authorRoutes } from "./generated/Author.routes";
214+
import { authorRoutes } from "./generated/Author.routes.js";
222215

223216
const app = Fastify();
224217
await app.register(authorRoutes); // mounts GET/POST/PATCH/PUT/DELETE for Author
@@ -252,15 +245,16 @@ difference is the framework adapter the emitted code talks to.
252245
// metaobjects.config.ts
253246
import { defineConfig } from "@metaobjectsdev/cli";
254247
// Owned generators scaffolded by `meta init` (ADR-0034 scaffold-and-own).
255-
import { entityFile } from "./codegen/generators/entity";
256-
import { queriesFile } from "./codegen/generators/queries";
257-
import { barrel } from "./codegen/generators/barrel";
248+
import { entityFile } from "./codegen/generators/entity.js";
249+
import { queriesFile } from "./codegen/generators/queries.js";
250+
import { barrel } from "./codegen/generators/barrel.js";
258251
// Hono routes have no reference template yet — still imported from the package.
259252
import { routesFileHono } from "@metaobjectsdev/codegen-ts/generators";
260253

261254
export default defineConfig({
262255
outDir: "src/generated",
263256
dialect: "sqlite", // or "postgres" / "d1"
257+
extStyle: "js",
264258
apiPrefix: "/api",
265259
generators: [entityFile(), queriesFile(), routesFileHono(), barrel()],
266260
});
@@ -277,7 +271,7 @@ import {
277271
AuthorUpdateSchema,
278272
AuthorFilterAllowlist,
279273
AuthorSortAllowlist,
280-
} from "./Author";
274+
} from "./Author.js";
281275
import { mountCrudRoutes } from "@metaobjectsdev/runtime-ts/hono";
282276
import type { Hono } from "hono";
283277

@@ -304,7 +298,7 @@ Consumer wiring (Workers example):
304298
```ts
305299
import { Hono } from "hono";
306300
import { drizzle } from "drizzle-orm/d1";
307-
import { registerAuthorRoutes } from "./generated/Author.routes.hono";
301+
import { registerAuthorRoutes } from "./generated/Author.routes.hono.js";
308302

309303
interface Env { DB: D1Database }
310304

@@ -371,7 +365,7 @@ export function safeParseNpcResponse(text: string):
371365
Consumer wiring:
372366

373367
```ts
374-
import { parseNpcResponse, safeParseNpcResponse } from "./generated/NpcResponse.output";
368+
import { parseNpcResponse, safeParseNpcResponse } from "./generated/NpcResponse.output.js";
375369

376370
const llmResponse = await myLlmProvider.call(promptText);
377371

server/typescript/packages/cli/src/commands/init.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,14 @@ function buildMetaobjectsConfigBody(dialect: "sqlite" | "postgres" | "d1" = "sql
5555
// reference templates into ./codegen/generators/ — they are YOURS to edit, and
5656
// \`meta gen\` runs from these local copies, not from the package. Read each file's
5757
// header doc-block for what it emits and how to customize it.
58-
import { entityFile } from "./codegen/generators/entity";
59-
import { queriesFile } from "./codegen/generators/queries";
60-
import { routesFile } from "./codegen/generators/routes";
61-
import { barrel } from "./codegen/generators/barrel";
58+
import { entityFile } from "./codegen/generators/entity.js";
59+
import { queriesFile } from "./codegen/generators/queries.js";
60+
import { routesFile } from "./codegen/generators/routes.js";
61+
import { barrel } from "./codegen/generators/barrel.js";
6262
6363
export default defineConfig({
6464
outDir: "src/generated",
65-
extStyle: "none",
65+
extStyle: "js", // ".js"-extensioned relative imports — safe under Node ESM / tsc nodenext AND bundlers
6666
dbImport: "../db",
6767
dialect: "${dialect}",
6868
apiPrefix: "", // set to "/api" if your routes mount under /api

server/typescript/packages/cli/src/commands/migrate.ts

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { formatMigrateResult, formatMigrateResultToon, type BlockedEntry, type A
88
import { formatMigrateResultJson } from "../lib/output-json.js";
99
import type { OutputFormat } from "../lib/format.js";
1010
import { toonEncode } from "../lib/format.js";
11-
import { buildKyselyFromUrl } from "../lib/kysely.js";
11+
import { buildKyselyFromUrl, redactUrl } from "../lib/kysely.js";
1212
import { log } from "../lib/log.js";
1313
import { loadMemory } from "@metaobjectsdev/sdk";
1414
import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js";
@@ -54,8 +54,10 @@ USAGE:
5454
meta migrate [baseline] [flags]
5555
5656
SUBCOMMANDS:
57-
baseline Seed the committed reference snapshot (no migration emitted).
58-
Required before the first offline generate.
57+
baseline Snapshot an EXISTING database's schema as the reference point
58+
(use with --from-db). NOTE: for a brand-new/empty database use
59+
the greenfield example below, NOT baseline — an offline baseline
60+
records your metadata as already-applied and emits no CREATE TABLE.
5961
6062
MIGRATE FLAGS:
6163
--db <url> DB connection URL (required for live-introspect / --apply / --rollback)
@@ -80,10 +82,13 @@ MIGRATE FLAGS:
8082
--help, -h Print this help
8183
8284
EXAMPLES:
83-
meta migrate baseline --dialect sqlite
84-
meta migrate --dialect sqlite --slug add-users
85-
meta migrate --db file:local.db --slug add-orders
85+
# New project — create tables in a fresh database (introspect → diff → CREATE TABLE → apply):
86+
meta migrate --from-db --db file:dev.sqlite --dialect sqlite --slug init --apply
87+
# Later — add a schema change and apply it:
88+
meta migrate --db file:dev.sqlite --dialect sqlite --slug add-users --apply
8689
meta migrate --db postgresql://localhost/mydb --slug add-index --apply
90+
# Adopt an existing database — snapshot its current schema first:
91+
meta migrate baseline --from-db --db postgresql://localhost/mydb
8792
`;
8893

8994
/** Emit a structured error on stdout (not stderr) in the active format, per axi. */
@@ -112,6 +117,47 @@ function mapOnAmbiguous(v: "abort" | "rename" | "drop-add"): AmbiguousResolution
112117
return v === "drop-add" ? "drop+add" : v;
113118
}
114119

120+
/**
121+
* The command that actually creates tables in a fresh/empty database. This is the
122+
* correct first step for a brand-new project — it introspects the (empty) DB, diffs
123+
* metadata against it to produce every CREATE TABLE, and applies them. Offline
124+
* `baseline` is NOT this: it records the desired schema as already-applied and emits
125+
* no DDL (launch-blocker B1).
126+
*/
127+
function greenfieldCreateCmd(dialect: string | undefined): string {
128+
const d = dialect ?? "sqlite";
129+
return `meta migrate --from-db --db <url> --dialect ${d} --slug init --apply`;
130+
}
131+
132+
/** The command to adopt a database that already exists (snapshot its live schema). */
133+
function adoptExistingDbCmd(): string {
134+
return "meta migrate baseline --from-db --db <url>";
135+
}
136+
137+
/** Shared `emitStructuredError` detail — points at the greenfield-create and
138+
* adopt-existing commands. Used by the empty-DB refusal and the no-snapshot hint. */
139+
function nextStepsDetail(dialect: string | undefined): string {
140+
return `run \`${greenfieldCreateCmd(dialect)}\` to create tables, or \`${adoptExistingDbCmd()}\` to adopt an existing database`;
141+
}
142+
143+
/** Table count of the target DB, or undefined when it can't be reached/introspected
144+
* (any failure ⇒ unknown ⇒ never block; the caller warns instead of refusing). */
145+
async function countLiveTables(
146+
url: string,
147+
dialect: ResolvedMigrateConfig["dialect"],
148+
): Promise<number | undefined> {
149+
try {
150+
const kysely = await buildKyselyFromUrl(url, dialect);
151+
try {
152+
return (await introspect(kysely.db, kysely.dialect)).tables.length;
153+
} finally {
154+
await kysely.close();
155+
}
156+
} catch {
157+
return undefined;
158+
}
159+
}
160+
115161
function summarizeChanges(changes: Change[]): Record<string, number> {
116162
const counts: Record<string, number> = {};
117163
for (const c of changes) {
@@ -486,7 +532,7 @@ export async function migrateCommand(
486532
export async function runBaseline(
487533
config: ResolvedMigrateConfig,
488534
metaRoot: string,
489-
_fmt: OutputFormat = "text",
535+
fmt: OutputFormat = "text",
490536
): Promise<number> {
491537
if (config.dialect === undefined) {
492538
log.error(`migrate baseline: --dialect required (or set migrate.dialect in .metaobjects/config.json)`);
@@ -514,6 +560,33 @@ export async function runBaseline(
514560
await kysely.close();
515561
}
516562
} else {
563+
// Greenfield-trap guard (launch-blocker B1). An offline baseline records the
564+
// metadata's DESIRED schema as the already-applied baseline; on an empty/new
565+
// database that silently suppresses every CREATE TABLE forever. When we can see
566+
// the target --db and PROVE it has no tables, refuse and point at the working
567+
// greenfield path. In every OTHER case — no --db, a non-empty --db, or a --db we
568+
// couldn't reach — we still write the snapshot but WARN: an offline baseline is
569+
// only correct when the database already matches the metadata, and emits no DDL.
570+
const liveTableCount = config.databaseUrl !== undefined
571+
? await countLiveTables(config.databaseUrl, config.dialect)
572+
: undefined;
573+
if (liveTableCount === 0) {
574+
// `liveTableCount === 0` ⇒ databaseUrl was defined (countLiveTables only
575+
// returns a number when it introspected a real connection).
576+
log.error(
577+
`migrate baseline: the database at ${redactUrl(config.databaseUrl!)} has no tables — ` +
578+
`baselining now would record your metadata as already-applied and no CREATE TABLE ` +
579+
`would ever be emitted. For a new/empty database run \`${greenfieldCreateCmd(config.dialect)}\` ` +
580+
`to create your tables (or \`${adoptExistingDbCmd()}\` to adopt an existing database).`,
581+
);
582+
emitStructuredError("migrate baseline: target database is empty", nextStepsDetail(config.dialect), fmt);
583+
return 2;
584+
}
585+
log.warn(
586+
`migrate baseline (offline): recording your metadata's schema as the already-applied ` +
587+
`baseline — this emits NO CREATE TABLE. Use it only if your database already matches ` +
588+
`your metadata; for a new/empty database run \`${greenfieldCreateCmd(config.dialect)}\` instead.`,
589+
);
517590
let metadata;
518591
// Load metaobjects.config.ts ONCE, up front, for BOTH the consumer providers
519592
// and the columnNamingStrategy — mirroring the DB path (and `meta gen`) so
@@ -603,13 +676,17 @@ export async function runOfflineGenerate(
603676
return 2;
604677
}
605678
if (snapshot === null) {
606-
log.error(`migrate: no schema snapshot at ${path}; run \`meta migrate baseline --dialect ${config.dialect}\` first`);
607-
// Structured next-step on stdout so callers / agents can parse it, in the active format.
608-
emitStructuredError(
609-
"no schema snapshot",
610-
`first run \`meta migrate baseline --dialect ${config.dialect}\``,
611-
fmt,
679+
// For a brand-new project the working first step is the greenfield --from-db …
680+
// --apply path (introspect the empty DB, diff, CREATE TABLE, apply), NOT the
681+
// offline `baseline` subcommand — offline baseline records the desired schema as
682+
// already-applied and would suppress every CREATE TABLE (launch-blocker B1).
683+
log.error(
684+
`migrate: no schema snapshot at ${path}. For a new project run ` +
685+
`\`${greenfieldCreateCmd(config.dialect)}\` to create your tables, or ` +
686+
`\`${adoptExistingDbCmd()}\` to adopt an existing database.`,
612687
);
688+
// Structured next-step on stdout so callers / agents can parse it, in the active format.
689+
emitStructuredError("no schema snapshot", nextStepsDetail(config.dialect), fmt);
613690
return 2;
614691
}
615692

server/typescript/packages/cli/src/lib/output.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export function genResultToData(result: GenResultShape): {
184184
: parts.join(", ");
185185
const help = result.files.length === 0
186186
? ["author entities under metaobjects/ then re-run `meta gen`"]
187-
: ["typecheck the generated code with `npx tsc`", "run schema with `meta migrate --db <url> --slug <name>`"];
187+
: ["typecheck the generated code with `npx tsc`", "create your database tables with `meta migrate --from-db --db <url> --dialect <sqlite|postgres> --slug init --apply`"];
188188
return { gen: result.files.map((f) => ({ file: f.path, status: f.status })), summary, help };
189189
}
190190

server/typescript/packages/cli/test/load-metaobjects-config.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect } from "bun:test";
2-
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
2+
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { loadMetaobjectsConfig } from "../src/lib/load-metaobjects-config.js";
@@ -32,4 +32,41 @@ describe("loadMetaobjectsConfig", () => {
3232
rmSync(dir, { recursive: true, force: true });
3333
}
3434
});
35+
36+
// Regression for the launch-B3 nodenext fix: `meta init` now scaffolds the
37+
// ADR-0034 owned-generator imports with a `.js` extension (nodenext-safe). The
38+
// jiti-based loader must still resolve those relative `.js` specifiers to the
39+
// on-disk `.ts` files when it loads the config — otherwise `meta gen` breaks on
40+
// a freshly-scaffolded project.
41+
test("resolves scaffolded owned-generator imports written with a `.js` extension", async () => {
42+
const dir = mkdtempSync(join(tmpdir(), "mo-cfg-jsext-"));
43+
try {
44+
mkdirSync(join(dir, "codegen", "generators"), { recursive: true });
45+
writeFileSync(
46+
join(dir, "codegen", "generators", "entity.ts"),
47+
// A minimal Generator factory — the loader only needs the module to
48+
// resolve and the factory to produce a generator-shaped object.
49+
`export function entityFile() { return { name: "entity-file", generate: () => [] }; }\n`,
50+
);
51+
writeFileSync(
52+
join(dir, "metaobjects.config.ts"),
53+
[
54+
`import { defineConfig } from "@metaobjectsdev/codegen-ts";`,
55+
`import { entityFile } from "./codegen/generators/entity.js";`,
56+
`export default defineConfig({`,
57+
` outDir: "src/generated",`,
58+
` extStyle: "js",`,
59+
` dbImport: "../db",`,
60+
` dialect: "sqlite",`,
61+
` generators: [entityFile()],`,
62+
`});`,
63+
].join("\n"),
64+
);
65+
const cfg = await loadMetaobjectsConfig(dir);
66+
expect(cfg.generators.length).toBe(1);
67+
expect(cfg.extStyle).toBe("js");
68+
} finally {
69+
rmSync(dir, { recursive: true, force: true });
70+
}
71+
});
3572
});

0 commit comments

Comments
 (0)