Skip to content

Commit 37c2a50

Browse files
dmealingclaude
andcommitted
fix(cli): eject and gen around the owned HTTP adapter, found upgrading adopters
Two adopter upgrades to 1.0.10 hit the 1.0.9 adapter hand-over: - `meta eject entity` over an owned copy from 1.0.0 reported "preserved" but still copied the Drizzle adapter into codegen/runtime/ and asked for its dependencies, in a project with no database. A copy with no `runtimeImport` option never imports that tree. The runtime copy is now skipped for a kept copy that predates it; a kept copy that supports it still gets a missing copy filled in. - In a pnpm monorepo, re-ejected routes imported codegen/runtime/ by a relative path that leaves the workspace package, and the package's tsc failed with TS6059. `meta gen` now warns when a generated file imports the owned adapter from outside its nearest package.json, naming the package and the two fixes (runtimeImport to the package, or move the copy inside the package). Warning only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1mRRB1K2WdYy82kntMQdx
1 parent 0cb915f commit 37c2a50

6 files changed

Lines changed: 193 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ here.**
1717
`--server` replaces the stack recorded in `.metaobjects/`, so on a project that is also
1818
TypeScript (or Kotlin, or has a React client) following the hint dropped those references.
1919
The hint is now `npx meta agent-docs`, which keeps the recorded stack.
20+
- **`meta eject` no longer copies the HTTP adapter beside an owned generator that predates it.**
21+
Since 1.0.9, ejecting `entity` / `routes` / `routes-hono` also copies the adapter source into
22+
`codegen/runtime/`, and re-running it fills in a missing copy. Over an owned copy from before
23+
1.0.9, which has no `runtimeImport` option and whose output imports the published package,
24+
that copy was dead code, and could be a Drizzle adapter in a project with no database. It is
25+
now skipped for such a copy.
26+
- **`meta gen` warns when generated code reaches the owned adapter from outside its workspace
27+
package.** In a monorepo whose output sits in `apps/api/…`, the relative import of
28+
`codegen/runtime/` leaves that package and its build fails (`TS6059 … is not under
29+
'rootDir'`). The warning names the package and the two fixes: pass
30+
`runtimeImport: "@metaobjectsdev/runtime-ts"`, or move the copy inside the package.
2031
- **`meta migrate --slug` reports the `down.sql` it writes.** It printed only the `up.sql` path.
2132
- **TanStack hooks and grid hooks import `<Entity>.meta` from beside themselves.** The UI
2233
generators write the DB-free `<Entity>.meta.ts` into their own target, but the generated

‎server/typescript/packages/cli/src/commands/eject.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,12 @@ export async function ejectGenerator(opts: EjectOptions): Promise<EjectResult> {
256256
// The adapter source the OUTPUT imports is part of what is handed over — copied whether
257257
// or not the generator file itself was already here, and under the same never-clobber
258258
// rule, so re-running eject fills in a missing copy without touching a kept one.
259-
const runtime = ejectsRuntime(opts.name)
259+
// Except when the KEPT copy predates that hand-over (1.0.9): with no `runtimeImport`
260+
// option its output imports the published package, so the copy would be dead code —
261+
// a Drizzle adapter, and its dependencies, in a project that may have no database.
262+
const keptCopyImportsPackage =
263+
existing !== undefined && !opts.force && !existing.includes("runtimeImport");
264+
const runtime = ejectsRuntime(opts.name) && !keptCopyImportsPackage
260265
? await ejectRuntime(opts.cwd, [opts.name], opts.force === true)
261266
: undefined;
262267

‎server/typescript/packages/cli/src/commands/gen.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import { emitStructured } from "../lib/format.js";
3131
import { composeCatalog } from "../lib/catalog.js";
3232
import { describeError } from "../lib/error-text.js";
33+
import { findRuntimeBoundaryCrossings, runtimeBoundaryWarnings } from "../lib/runtime-boundary-advisory.js";
3334

3435
/**
3536
* Print a load failure with everything the loader's ADR-0009 envelope carried — the stable
@@ -235,6 +236,14 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
235236
info: "",
236237
}));
237238

239+
// Advisory: generated code importing the owned adapter copy from outside its own
240+
// workspace package (a monorepo) will not build. Warning only.
241+
if (!cliConfig.dryRun) {
242+
for (const w of runtimeBoundaryWarnings(
243+
findRuntimeBoundaryCrossings(projectRoot, result.files.map((f) => f.path)),
244+
)) log.warn(`warning: ${w}`);
245+
}
246+
238247
const targetDirs = Array.from(new Set(
239248
(forgeConfig.targets ? Object.values(forgeConfig.targets).map((t) => t.outDir) : [])
240249
.concat([forgeConfig.outDir]),
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { dirname, join, relative, resolve, sep } from "node:path";
3+
import { OWNED_RUNTIME_DIR, HTTP_RUNTIME_PACKAGE } from "@metaobjectsdev/codegen-ts";
4+
5+
/**
6+
* Generated code that imports the owned HTTP-adapter copy from OUTSIDE its own
7+
* workspace package.
8+
*
9+
* Since 1.0.9 an ejected `entity` / `routes` / `routes-hono` generator points its output
10+
* at `codegen/runtime/` (the adapter source `meta eject` copied) by a path relative to
11+
* the output directory. In a single-package project that is exactly right. In a monorepo
12+
* whose output sits inside a workspace package (`apps/api/src/generated`), the path climbs
13+
* out of that package: `tsc` rejects the file (`TS6059 … is not under 'rootDir'`), and the
14+
* copy's own bare imports (`qs`, `fastify`) do not resolve from the repository root. The
15+
* `meta gen` run itself is green; the adopter's build is the first thing that disagrees.
16+
*
17+
* Found upgrading a pnpm monorepo. This names the package and the two ways out. It is a
18+
* warning and never changes an exit code.
19+
*/
20+
export interface RuntimeBoundaryFinding {
21+
/** Project-relative directory of the workspace package the generated file sits in. */
22+
packageDir: string;
23+
/** One generated file (project-relative) that crosses the boundary. */
24+
example: string;
25+
}
26+
27+
const IMPORT_SPEC = /\bfrom\s+["'](\.{1,2}\/[^"']+)["']/g;
28+
29+
function nearestPackageDir(fileDir: string, projectRoot: string): string | undefined {
30+
for (let d = fileDir; ; d = dirname(d)) {
31+
if (d === projectRoot || !d.startsWith(projectRoot + sep)) return undefined;
32+
if (existsSync(join(d, "package.json"))) return d;
33+
}
34+
}
35+
36+
function isInside(child: string, parent: string): boolean {
37+
return child === parent || child.startsWith(parent + sep);
38+
}
39+
40+
export function findRuntimeBoundaryCrossings(
41+
projectRoot: string,
42+
generatedFiles: readonly string[],
43+
): RuntimeBoundaryFinding[] {
44+
const root = resolve(projectRoot);
45+
const runtimeRoot = join(root, OWNED_RUNTIME_DIR);
46+
const byPackage = new Map<string, RuntimeBoundaryFinding>();
47+
for (const file of generatedFiles) {
48+
if (!/\.(ts|tsx|mts)$/.test(file) || !existsSync(file)) continue;
49+
const abs = resolve(file);
50+
const pkgDir = nearestPackageDir(dirname(abs), root);
51+
if (pkgDir === undefined || isInside(runtimeRoot, pkgDir) || byPackage.has(pkgDir)) continue;
52+
const text = readFileSync(abs, "utf8");
53+
for (const m of text.matchAll(IMPORT_SPEC)) {
54+
if (isInside(resolve(dirname(abs), m[1]!), runtimeRoot)) {
55+
byPackage.set(pkgDir, {
56+
packageDir: relative(root, pkgDir).split(sep).join("/"),
57+
example: relative(root, abs).split(sep).join("/"),
58+
});
59+
break;
60+
}
61+
}
62+
}
63+
return [...byPackage.values()];
64+
}
65+
66+
export function runtimeBoundaryWarnings(findings: readonly RuntimeBoundaryFinding[]): string[] {
67+
return findings.map((f) =>
68+
`generated code in ${f.packageDir}/ imports the owned HTTP adapter from ${OWNED_RUNTIME_DIR}/, ` +
69+
`outside that workspace package (e.g. ${f.example}); its build will not resolve it. ` +
70+
`Either keep importing the published adapter — pass \`runtimeImport: "${HTTP_RUNTIME_PACKAGE}"\` ` +
71+
`to the ejected entity/routes generators — or move ${OWNED_RUNTIME_DIR}/ inside ${f.packageDir}/ ` +
72+
`and point \`runtimeImport\` at it.`,
73+
);
74+
}

‎server/typescript/packages/cli/test/eject.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,28 @@ describe("meta eject reports how the owned copy compares to the reference", () =
268268
expect(r.comparison).toBeUndefined();
269269
});
270270
});
271+
272+
describe("meta eject over an owned copy that predates the runtime hand-over", () => {
273+
// Since 1.0.9 an ejected entity/routes generator points its output at an owned copy of
274+
// the HTTP adapter in codegen/runtime/, and eject copies that source. An owned copy
275+
// from before then has no `runtimeImport` option — its output imports the published
276+
// @metaobjectsdev/runtime-ts — so copying the adapter beside it (and asking for its
277+
// dependencies) handed a project with no database a Drizzle adapter it never imports.
278+
test("a kept pre-1.0.9 copy gets no codegen/runtime/ files", async () => {
279+
await mkdir(join(cwd, "codegen/generators"), { recursive: true });
280+
await writeFile(join(cwd, "codegen/generators/entity.ts"), "// my 1.0.0 entity generator\n", "utf8");
281+
const r = await ejectGenerator({ cwd, name: "entity" });
282+
expect(r.status).toBe("preserved");
283+
expect(r.runtime?.files ?? []).toEqual([]);
284+
const { existsSync } = await import("node:fs");
285+
expect(existsSync(join(cwd, "codegen/runtime"))).toBe(false);
286+
});
287+
288+
test("a kept copy that already supports runtimeImport still gets a missing runtime copy", async () => {
289+
await mkdir(join(cwd, "codegen/generators"), { recursive: true });
290+
await writeFile(join(cwd, "codegen/generators/entity.ts"), "// mine\nconst runtimeImport = undefined;\n", "utf8");
291+
const r = await ejectGenerator({ cwd, name: "entity" });
292+
expect(r.status).toBe("preserved");
293+
expect((r.runtime?.files ?? []).length).toBeGreaterThan(0);
294+
});
295+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// An ejected entity/routes generator imports the owned adapter copy in codegen/runtime/
2+
// by a relative path. In a monorepo whose output sits in a workspace package that path
3+
// leaves the package and the package's build rejects it (found upgrading a pnpm monorepo:
4+
// `TS6059 … is not under 'rootDir'`). meta gen warns, naming the package.
5+
import { describe, test, expect, afterEach } from "bun:test";
6+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
7+
import { tmpdir } from "node:os";
8+
import { join } from "node:path";
9+
import { findRuntimeBoundaryCrossings, runtimeBoundaryWarnings } from "../../src/lib/runtime-boundary-advisory.js";
10+
11+
const dirs: string[] = [];
12+
afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); });
13+
14+
function project(): string {
15+
const d = mkdtempSync(join(tmpdir(), "mo-rt-boundary-"));
16+
dirs.push(d);
17+
writeFileSync(join(d, "package.json"), "{}");
18+
mkdirSync(join(d, "codegen/runtime/drizzle-fastify"), { recursive: true });
19+
writeFileSync(join(d, "codegen/runtime/drizzle-fastify/index.ts"), "export {};\n");
20+
return d;
21+
}
22+
23+
function gen(root: string, rel: string, content: string): string {
24+
const abs = join(root, rel);
25+
mkdirSync(join(abs, ".."), { recursive: true });
26+
writeFileSync(abs, content);
27+
return abs;
28+
}
29+
30+
describe("runtime boundary advisory", () => {
31+
test("a file in a workspace package importing ../codegen/runtime is named", () => {
32+
const root = project();
33+
mkdirSync(join(root, "apps/api"), { recursive: true });
34+
writeFileSync(join(root, "apps/api/package.json"), "{}");
35+
const f = gen(root, "apps/api/src/generated/User.routes.ts",
36+
`import { mountCrudRoutes } from "../../../../codegen/runtime/drizzle-fastify/index";\n`);
37+
const found = findRuntimeBoundaryCrossings(root, [f]);
38+
expect(found).toEqual([{ packageDir: "apps/api", example: "apps/api/src/generated/User.routes.ts" }]);
39+
const [w] = runtimeBoundaryWarnings(found);
40+
expect(w).toContain(`runtimeImport: "@metaobjectsdev/runtime-ts"`);
41+
expect(w).toContain("apps/api/");
42+
});
43+
44+
test("a single-package project is not flagged", () => {
45+
const root = project();
46+
const f = gen(root, "src/generated/User.routes.ts",
47+
`import { mountCrudRoutes } from "../../codegen/runtime/drizzle-fastify/index";\n`);
48+
expect(findRuntimeBoundaryCrossings(root, [f])).toEqual([]);
49+
});
50+
51+
test("output that imports the published package is not flagged", () => {
52+
const root = project();
53+
mkdirSync(join(root, "apps/api"), { recursive: true });
54+
writeFileSync(join(root, "apps/api/package.json"), "{}");
55+
const f = gen(root, "apps/api/src/generated/User.routes.ts",
56+
`import { mountCrudRoutes } from "@metaobjectsdev/runtime-ts/drizzle-fastify";\n`);
57+
expect(findRuntimeBoundaryCrossings(root, [f])).toEqual([]);
58+
});
59+
60+
test("a runtime copy moved inside the package is not flagged", () => {
61+
const root = project();
62+
mkdirSync(join(root, "apps/api/codegen/runtime/drizzle-fastify"), { recursive: true });
63+
writeFileSync(join(root, "apps/api/package.json"), "{}");
64+
const f = gen(root, "apps/api/src/generated/User.routes.ts",
65+
`import { mountCrudRoutes } from "../../codegen/runtime/drizzle-fastify/index";\n`);
66+
expect(findRuntimeBoundaryCrossings(root, [f])).toEqual([]);
67+
});
68+
});

0 commit comments

Comments
 (0)