Skip to content

Commit 0362a2c

Browse files
dmealingclaude
andcommitted
fix(cli,codegen-ts): first-run friction found by two cold agent sessions
Two fresh Claude sessions built a small app from `meta init` with no hints. Both hit the same walls, fixed here: - The scaffolded metaobjects.config.ts failed the scaffolded tsconfig.codegen.json check: `Property 'extStyle' is missing`. extStyle is optional on the user config now and normalizeConfig fills DEFAULT_EXT_STYLE ("js"), the value every generator already assumed. - `meta eject entity` alone made `meta gen` fail with `codegen config is missing dbImport`, in a project whose queries take db as a parameter. The runner installed dbImport as a throwing getter, and the reference entity/routes generators copy the render context with an object spread, which reads every property. The runner now hands generators a marker value and fails only when the marker reaches emitted output, so copies adopters already ejected are fixed too. A new test with the reference entity generator fails on the old runner and passes now. - A new database pointed at `meta migrate --apply` only got "--slug required". That error, and meta init's next steps, now name `meta migrate apply-pending`. - ERR_PARTIAL_UNRESOLVED names the file to create and the --prompts flag. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1mRRB1K2WdYy82kntMQdx
1 parent 71de7fb commit 0362a2c

8 files changed

Lines changed: 83 additions & 23 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ here.**
3131
- **SQLite: a function-call column default is wrapped in parentheses** when emitted, as SQLite
3232
requires (`DEFAULT (expr)`). Before, an authored expression default such as
3333
`datetime('now')` produced a `CREATE TABLE` that did not parse.
34+
- **`meta init`'s config passes its own typecheck.** `extStyle` was required by the config
35+
type but absent from the scaffolded `metaobjects.config.ts`, so
36+
`npx tsc -p tsconfig.codegen.json --noEmit` failed with `Property 'extStyle' is missing`
37+
on a fresh project. It is optional now and defaults to `"js"`, which every generator
38+
already assumed.
39+
- **`dbImport` is demanded only by a generator whose output imports `db`.** The reference
40+
`entity`, `routes` and `routes-hono` generators (the copies `meta eject` hands over) copy
41+
the render context with an object spread, and the demand fired on that read, so
42+
`meta eject entity` alone failed with `codegen config is missing dbImport` in a project
43+
whose queries take `db` as a parameter. The runner now checks the emitted output instead,
44+
which also fixes copies already ejected.
45+
- **A new database is pointed at `meta migrate apply-pending`.** `meta init`'s next steps
46+
name it, and `meta migrate --db <url> --apply` against an empty database with committed
47+
migrations now says to replay them rather than only asking for `--slug`.
48+
- **`meta verify` names the prompt file it could not find.** `ERR_PARTIAL_UNRESOLVED` now
49+
says which file to create (`prompts/<ref>.mustache` or `.txt`) and that `--prompts` moves
50+
the root, instead of only naming the directory it searched.
3451
- **Agent context: hand-built LLM prompts** (#389). The always-on context installed by
3552
`meta init` gains a rule: once a project declares any `template.prompt`, every LLM prompt is
3653
one, replies are read through a response model, and changing a hand-built prompt means

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ Next steps:
157157
4. meta gen # generate from exactly what you wired
158158
5. meta docs # neutral model + API docs (on by default)
159159
6. Create your tables: meta migrate --from-db --db file:dev.sqlite --dialect sqlite --slug init --apply
160+
A new database later (a fresh clone, CI, tests) replays the committed migrations:
161+
meta migrate apply-pending --db file:other.sqlite
160162
`;
161163

162164
export interface InitOptions {

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,14 @@ export async function migrateCommand(
895895
if (exitCode === 0 && emitted) {
896896
if (config.slug === undefined) {
897897
log.error(`migrate: --slug <name> required when there are changes (e.g., --slug add-user-shipping)`);
898+
// The common way to land here is a NEW database (a fresh clone, CI, another
899+
// environment) whose migrations are already committed: the diff against an empty
900+
// database is every table, so it asks to author a new migration. That database
901+
// needs the committed chain replayed, not a new migration.
902+
log.error(
903+
` New database, migrations already committed? Apply them instead: ` +
904+
`meta migrate apply-pending --db <url>`,
905+
);
898906
await kysely.close();
899907
return 2;
900908
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1039,7 +1039,8 @@ export async function verifyCommand(
10391039
const text = provider.resolve(ref);
10401040
if (text === undefined) {
10411041
log.error(
1042-
`[${tmpl.name}] (${label}) ${ERR_PARTIAL_UNRESOLVED}: ref "${ref}" did not resolve under ${promptsDir}`,
1042+
`[${tmpl.name}] (${label}) ${ERR_PARTIAL_UNRESOLVED}: ref "${ref}" did not resolve under ${promptsDir}` +
1043+
` — create ${join(promptsDir, `${ref}.mustache`)} (or .txt), or point --prompts at the directory that holds it`,
10431044
);
10441045
errorCount++;
10451046
anyErrorHere = true;

‎server/typescript/packages/codegen-ts/src/metaobjects-config.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export interface ResolvedGenConfig {
8484
* would generate DB code is required to set them explicitly (see `runGen`'s guard). */
8585
export const DEFAULT_DIALECT: Dialect = "sqlite";
8686
export const DEFAULT_DB_IMPORT = "./db";
87+
/** Import-specifier suffix when the config names none: `./x.js`, right for Node ESM + tsc. */
88+
export const DEFAULT_EXT_STYLE: ExtStyle = "js";
8789

8890
/**
8991
* The user-facing codegen config. `dbImport` / `dialect` are OPTIONAL here (unlike the
@@ -93,9 +95,13 @@ export const DEFAULT_DB_IMPORT = "./db";
9395
* fills inert defaults when they are absent AND the model emits no DB artifacts, and
9496
* throws a clear error when they are absent but the model DOES emit DB code (#194).
9597
*/
96-
export interface MetaobjectsGenConfig extends Omit<ResolvedGenConfig, "dbImport" | "dialect"> {
98+
export interface MetaobjectsGenConfig extends Omit<ResolvedGenConfig, "dbImport" | "dialect" | "extStyle"> {
9799
dbImport?: string;
98100
dialect?: Dialect;
101+
/** Import-specifier suffix in generated code. Defaults to `"js"` (`./x.js`), which is
102+
* right for Node ESM + tsc. Optional because `meta init` scaffolds a config without it,
103+
* and that config must pass the scaffolded `tsconfig.codegen.json`. */
104+
extStyle?: ExtStyle;
99105
/**
100106
* Generators to run. Each entry is either a typed generator factory result
101107
* (`entityFile()`) or a stable-name string (`"entity"`) resolved via the
@@ -205,10 +211,11 @@ export interface MetaobjectsGenConfig extends Omit<ResolvedGenConfig, "dbImport"
205211
* TargetConfig to the fully-resolved ResolvedTarget (incompatible under
206212
* exactOptionalPropertyTypes otherwise). */
207213
export interface NormalizedMetaobjectsGenConfig
208-
extends Omit<MetaobjectsGenConfig, "targets" | "generators" | "dbImport" | "dialect"> {
214+
extends Omit<MetaobjectsGenConfig, "targets" | "generators" | "dbImport" | "dialect" | "extStyle"> {
209215
/** Resolved to a concrete value (the user's, else the inert default). */
210216
dbImport: string;
211217
dialect: Dialect;
218+
extStyle: ExtStyle;
212219
/** Fully resolved — every string spec has been mapped to its factory result. */
213220
generators: Generator[];
214221
columnNamingStrategy: ColumnNamingStrategy;
@@ -393,6 +400,7 @@ export function normalizeConfig(config: MetaobjectsGenConfig): NormalizedMetaobj
393400
...config,
394401
dbImport: config.dbImport ?? DEFAULT_DB_IMPORT,
395402
dialect,
403+
extStyle: config.extStyle ?? DEFAULT_EXT_STYLE,
396404
generators: resolveGenerators(config.generators),
397405
columnNamingStrategy: config.columnNamingStrategy ?? DEFAULT_COLUMN_NAMING_STRATEGY,
398406
pluralizeCollections: config.pluralizeCollections ?? true,

‎server/typescript/packages/codegen-ts/src/runner.ts‎

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,10 @@ export function shouldNoteNamesArtifactAbsent(
263263
return versionBefore(was, since);
264264
}
265265

266+
/** What a generator reads as `dbImport` when the config declares none. Emitting it means the
267+
* generator needed a db module path; the runner then fails naming that generator. */
268+
const DB_IMPORT_UNSET = "__METAOBJECTS_DB_IMPORT_UNSET__";
269+
266270
export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
267271
const warnings: string[] = [];
268272
const strategy = opts.mergeStrategy ?? "overwrite";
@@ -725,23 +729,17 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
725729
warn: (msg) => warnings.push(`[${generator.name}] ${msg}`),
726730
};
727731

728-
// The point of USE for an undeclared dbImport (see the guard above). Reading
729-
// it is what proves this generator emits a db-singleton import, so reading is
730-
// what asks for it; a generator that never touches it never demands it. The
731-
// throw travels through the `[${generator.name}]` wrapper below, so the message
732-
// names the generator that wants the path. Both surfaces are covered because a
733-
// generator may read either.
734-
if (dbImportUndeclaredFor(selfTarget.name)) {
735-
const demand = (): never => {
736-
throw new Error(
737-
`codegen config is missing dbImport — this generator emits ` +
738-
`\`import { db } from …\` and needs the module to import it from. Set dbImport in ` +
739-
`metaobjects.config.ts (or on this generator's target). A project whose generated ` +
740-
`queries take \`db\` as a parameter never needs it.`,
741-
);
742-
};
743-
Object.defineProperty(renderContext, "dbImport", { get: demand, configurable: true });
744-
Object.defineProperty(ctx.config, "dbImport", { get: demand, configurable: true });
732+
// The point of USE for an undeclared dbImport (see the guard above). The value a
733+
// generator reads is a MARKER, and the demand fires only if the marker reaches the
734+
// OUTPUT — that is what proves this generator emits a db-singleton import. It used to
735+
// be a throwing getter, which fired on any read: the reference generators copy the
736+
// render context with an object spread, and a spread reads every property, so
737+
// `entityFile()` demanded a dbImport it never emits. A marker survives the spread
738+
// (and any path arithmetic, being a bare specifier) and is checked after generate.
739+
const dbImportUndeclared = dbImportUndeclaredFor(selfTarget.name);
740+
if (dbImportUndeclared) {
741+
Object.defineProperty(renderContext, "dbImport", { value: DB_IMPORT_UNSET, configurable: true, enumerable: true, writable: true });
742+
Object.defineProperty(ctx.config, "dbImport", { value: DB_IMPORT_UNSET, configurable: true, enumerable: true, writable: true });
745743
}
746744

747745
let files: EmittedFile[];
@@ -758,6 +756,14 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
758756
// not five. Emitting nothing is often CORRECT — a form generator on a model
759757
// with no forms — so this reports, it never fails.
760758
if (files.length === 0) emptyGenerators.add(generator.name);
759+
if (dbImportUndeclared && files.some((f) => f.content.includes(DB_IMPORT_UNSET) || f.path.includes(DB_IMPORT_UNSET))) {
760+
throw new Error(
761+
`codegen config is missing dbImport — this generator emits ` +
762+
`\`import { db } from …\` and needs the module to import it from. Set dbImport in ` +
763+
`metaobjects.config.ts (or on this generator's target). A project whose generated ` +
764+
`queries take \`db\` as a parameter never needs it.`,
765+
);
766+
}
761767
} catch (err) {
762768
const msg = err instanceof Error ? err.message : String(err);
763769
// `cause` preserves the original throw. Without it a `runGen` caller sees a

‎server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect, expectTypeOf } from "bun:test";
2-
import { defineConfig, normalizeConfig, resolveTargets, DEFAULT_TARGET_NAME, type MetaobjectsGenConfig, type ResolvedGenConfig, type Dialect } from "../src/metaobjects-config.js";
2+
import { defineConfig, normalizeConfig, resolveTargets, DEFAULT_TARGET_NAME, type MetaobjectsGenConfig, type ResolvedGenConfig, type Dialect, type ExtStyle } from "../src/metaobjects-config.js";
33
import type { Generator } from "../src/generator.js";
44

55
describe("resolveTargets", () => {
@@ -69,8 +69,11 @@ describe("defineConfig", () => {
6969

7070
test("type-level: MetaobjectsGenConfig embeds ResolvedGenConfig's non-DB fields exactly (#194 — dbImport/dialect are OPTIONAL here, filled by the runner)", () => {
7171
// Everything except dbImport/dialect matches ResolvedGenConfig field-for-field.
72-
expectTypeOf<Pick<MetaobjectsGenConfig, "outDir" | "extStyle" | "outputLayout" | "includeHonoRoutes" | "includeNames" | "includeUiTier" | "providedEnumModule">>()
73-
.toEqualTypeOf<Omit<ResolvedGenConfig, "dbImport" | "dialect">>();
72+
expectTypeOf<Pick<MetaobjectsGenConfig, "outDir" | "outputLayout" | "includeHonoRoutes" | "includeNames" | "includeUiTier" | "providedEnumModule">>()
73+
.toEqualTypeOf<Omit<ResolvedGenConfig, "dbImport" | "dialect" | "extStyle">>();
74+
// extStyle is OPTIONAL too: the config `meta init` scaffolds omits it and must still
75+
// typecheck; the runner fills DEFAULT_EXT_STYLE.
76+
expectTypeOf<MetaobjectsGenConfig["extStyle"]>().toEqualTypeOf<ExtStyle | undefined>();
7477
// dbImport/dialect are OPTIONAL on the user config (a value-object-only project omits
7578
// them); ResolvedGenConfig (what generators consume) keeps them required.
7679
expectTypeOf<MetaobjectsGenConfig["dbImport"]>().toEqualTypeOf<string | undefined>();

‎server/typescript/packages/codegen-ts/test/vo-only-config-optional.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
1818
import { runGen } from "../src/runner.js";
1919
import { entityFile } from "../src/generators/entity-file.js";
2020
import { routesFile } from "../src/generators/routes-file.js";
21+
import { entityFile as referenceEntityFile } from "../src/reference/entity.js";
2122
import type { MetaobjectsGenConfig } from "../src/metaobjects-config.js";
2223

2324
let tmp: string;
@@ -105,6 +106,20 @@ describe("#194 item 3 — dbImport/dialect optional for value-object-only and so
105106
expect(result.files.length).toBeGreaterThan(0);
106107
});
107108

109+
test("the REFERENCE entity generator (what `meta eject entity` copies) does not demand dbImport", async () => {
110+
// It copies the render context with an object spread, which reads every property.
111+
// When the demand was a throwing getter, that read alone convicted a generator whose
112+
// output never imports `db`.
113+
const root = await load(ENTITY);
114+
const cfg = {
115+
...configNoDb(),
116+
dialect: "sqlite" as const,
117+
generators: [referenceEntityFile()],
118+
} as MetaobjectsGenConfig;
119+
const result = await runGen({ config: cfg, metadata: root, projectRoot: tmp });
120+
expect(result.files.length).toBeGreaterThan(0);
121+
});
122+
108123
test("a generator that DOES emit `import { db }` still demands dbImport, and names itself", async () => {
109124
const root = await load(ENTITY);
110125
const cfg = {

0 commit comments

Comments
 (0)