Skip to content

Commit 4e78eb4

Browse files
dmealingclaude
andcommitted
fix(cli): thread config providers into offline migrate paths (#157)
runBaseline + runOfflineGenerate called loadMemory() without the consumer providers from metaobjects.config.ts, so a project registering a custom subtype via a config provider hit 'Unknown type' on offline meta migrate — even though meta gen and the DB migrate paths load the same metadata fine. Load the config once up front in both functions and pass providers to loadMemory (folding the later columnNamingStrategy read into that same load), mirroring the DB path. Closes #157 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SKaSE8U5cwN4ZGFwhJwxSY
1 parent 4390030 commit 4e78eb4

2 files changed

Lines changed: 155 additions & 15 deletions

File tree

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

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -509,18 +509,27 @@ export async function runBaseline(
509509
}
510510
} else {
511511
let metadata;
512-
try {
513-
metadata = await loadMemory(metaRoot);
514-
} catch (err) {
515-
log.error(`migrate baseline: failed to load metadata: ${(err as Error).message}`);
516-
return 2;
517-
}
512+
// Load metaobjects.config.ts ONCE, up front, for BOTH the consumer providers
513+
// and the columnNamingStrategy — mirroring the DB path (and `meta gen`) so
514+
// offline baseline resolves config-registered custom subtypes too (#157).
515+
let baselineConfigProviders:
516+
| readonly import("@metaobjectsdev/codegen-ts").MetaDataTypeProvider[]
517+
| undefined;
518518
let baselineStrategy: "snake_case" | "literal" | "kebab-case" = "snake_case";
519519
try {
520520
const cfg = await loadMetaobjectsConfig(metaRoot);
521+
baselineConfigProviders = cfg.providers;
521522
if (cfg.columnNamingStrategy) baselineStrategy = cfg.columnNamingStrategy;
522523
} catch {
523-
// config absent — default snake_case
524+
// config absent — no custom providers, default snake_case
525+
}
526+
try {
527+
metadata = await loadMemory(metaRoot, {
528+
...(baselineConfigProviders !== undefined ? { providers: baselineConfigProviders } : {}),
529+
});
530+
} catch (err) {
531+
log.error(`migrate baseline: failed to load metadata: ${(err as Error).message}`);
532+
return 2;
524533
}
525534
const baselineViews = buildProjectionViews(metadata, { dialect: config.dialect, columnNamingStrategy: baselineStrategy });
526535
snapshot = baselineFromMetadata(metadata, config.dialect, baselineStrategy, baselineViews);
@@ -553,9 +562,26 @@ export async function runOfflineGenerate(
553562
log.error(`migrate: --dialect required for offline generation (or use --from-db)`);
554563
return 2;
555564
}
565+
// Load metaobjects.config.ts ONCE, up front, for BOTH the consumer providers
566+
// and the columnNamingStrategy — mirroring the DB path (and `meta gen`) so
567+
// offline generate resolves config-registered custom subtypes too (#157).
568+
let offlineConfigProviders:
569+
| readonly import("@metaobjectsdev/codegen-ts").MetaDataTypeProvider[]
570+
| undefined;
571+
let offlineStrategy: "snake_case" | "literal" | "kebab-case" = "snake_case";
572+
try {
573+
const cfg = await loadMetaobjectsConfig(metaRoot);
574+
offlineConfigProviders = cfg.providers;
575+
if (cfg.columnNamingStrategy) offlineStrategy = cfg.columnNamingStrategy;
576+
} catch {
577+
// config absent — no custom providers, default snake_case
578+
}
579+
556580
let metadata;
557581
try {
558-
metadata = await loadMemory(metaRoot);
582+
metadata = await loadMemory(metaRoot, {
583+
...(offlineConfigProviders !== undefined ? { providers: offlineConfigProviders } : {}),
584+
});
559585
} catch (err) {
560586
log.error(`migrate: failed to load metadata: ${(err as Error).message}`);
561587
return 2;
@@ -584,13 +610,6 @@ export async function runOfflineGenerate(
584610
const collectedAmbiguous: AmbiguousChange[] = [];
585611
const onAmbiguousResolution = mapOnAmbiguous(config.onAmbiguous);
586612

587-
let offlineStrategy: "snake_case" | "literal" | "kebab-case" = "snake_case";
588-
try {
589-
const cfg = await loadMetaobjectsConfig(metaRoot);
590-
if (cfg.columnNamingStrategy) offlineStrategy = cfg.columnNamingStrategy;
591-
} catch {
592-
// config absent — default snake_case
593-
}
594613
const offlineViews = buildProjectionViews(metadata, { dialect: config.dialect, columnNamingStrategy: offlineStrategy });
595614

596615
let plan;
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, test, expect, afterAll } from "bun:test";
2+
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { runBaseline, runOfflineGenerate } from "../src/commands/migrate.js";
6+
7+
// #157 — the offline migrate paths (baseline + offline generate) must thread the
8+
// consumer `providers` from metaobjects.config.ts into loadMemory, exactly like
9+
// `meta gen` and the DB migrate paths do. Otherwise a project that registers a
10+
// custom subtype via a config provider hits `Unknown type <subtype>` on offline
11+
// `meta migrate`, forcing a "strip the custom nodes" workaround.
12+
13+
const dirs: string[] = [];
14+
afterAll(async () => {
15+
for (const d of dirs) await rm(d, { recursive: true, force: true });
16+
});
17+
18+
// A config that registers a custom `validator.geocheck` subtype via a provider —
19+
// mirrors how an adopter ships a custom subtype to gen/migrate. A validator is
20+
// used (not a field) so it exercises provider-threading without also depending on
21+
// how the migrate column-mapper handles a novel physical type.
22+
const CUSTOM_CONFIG = [
23+
`import { defineConfig } from "@metaobjectsdev/codegen-ts";`,
24+
`import { entityFile } from "@metaobjectsdev/codegen-ts/generators";`,
25+
`import { TypeId, TYPE_VALIDATOR, MetaValidator } from "@metaobjectsdev/metadata";`,
26+
`const geoProvider = {`,
27+
` id: "test-geocheck",`,
28+
` dependencies: ["metaobjects-core-types"],`,
29+
` registerTypes(registry) {`,
30+
` registry.register({`,
31+
` typeId: new TypeId(TYPE_VALIDATOR, "geocheck"),`,
32+
` description: "A custom validator",`,
33+
` factory: (typeId, name) => new MetaValidator(typeId, name),`,
34+
` childRules: [],`,
35+
` attributes: [],`,
36+
` });`,
37+
` },`,
38+
`};`,
39+
`export default defineConfig({`,
40+
` outDir: "out",`,
41+
` dialect: "postgres",`,
42+
` generators: [entityFile()],`,
43+
` providers: [geoProvider],`,
44+
`});`,
45+
].join("\n");
46+
47+
// An entity (writable table) whose `name` field carries the custom validator.
48+
// The `validator.geocheck` child resolves ONLY when the config provider loads —
49+
// so an offline migrate that doesn't thread providers fails at metadata load.
50+
const CUSTOM_META = {
51+
"metadata.root": {
52+
package: "acme::geo",
53+
children: [
54+
{
55+
"object.entity": {
56+
name: "Place",
57+
children: [
58+
{ "field.long": { name: "id" } },
59+
{
60+
"field.string": {
61+
name: "name",
62+
children: [{ "validator.geocheck": { name: "chk" } }],
63+
},
64+
},
65+
{ "source.rdb": { name: "src", "@table": "places" } },
66+
{
67+
"identity.primary": {
68+
name: "pk",
69+
"@fields": ["id"],
70+
"@generation": "increment",
71+
},
72+
},
73+
],
74+
},
75+
},
76+
],
77+
},
78+
};
79+
80+
async function customProject(): Promise<string> {
81+
const root = await mkdtemp(join(tmpdir(), "mts-migprov-"));
82+
dirs.push(root);
83+
await mkdir(join(root, "metaobjects"), { recursive: true });
84+
await writeFile(
85+
join(root, "metaobjects", "meta.json"),
86+
JSON.stringify(CUSTOM_META),
87+
"utf8",
88+
);
89+
await writeFile(join(root, "metaobjects.config.ts"), CUSTOM_CONFIG, "utf8");
90+
return root;
91+
}
92+
93+
describe("offline migrate threads config providers (#157)", () => {
94+
test("runBaseline loads a config-registered custom subtype (no strip workaround)", async () => {
95+
const root = await customProject();
96+
const code = await runBaseline(
97+
{ dialect: "postgres", outDir: "./.metaobjects/migrations", fromDb: false } as any,
98+
root,
99+
);
100+
expect(code).toBe(0);
101+
});
102+
103+
test("runOfflineGenerate loads a config-registered custom subtype", async () => {
104+
const root = await customProject();
105+
// baseline first (also needs the provider) so there is a snapshot to diff.
106+
await runBaseline(
107+
{ dialect: "postgres", outDir: "./.metaobjects/migrations", fromDb: false } as any,
108+
root,
109+
);
110+
const code = await runOfflineGenerate(
111+
{
112+
dialect: "postgres",
113+
outDir: "./.metaobjects/migrations",
114+
allow: [],
115+
onAmbiguous: "error",
116+
} as any,
117+
root,
118+
);
119+
expect(code).toBe(0);
120+
});
121+
});

0 commit comments

Comments
 (0)