Skip to content

Commit d5ecbe8

Browse files
authored
Merge pull request #289 from metaobjectsdev/fix/decouple-ui-and-raw-exec
fix: raw-exec 500 on Postgres, UI/storage decoupling, and Drizzle out of browser bundles
2 parents edfe739 + 735eb44 commit d5ecbe8

17 files changed

Lines changed: 483 additions & 31 deletions

File tree

server/typescript/packages/codegen-ts-react/src/form-file.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { MetaObject } from "@metaobjectsdev/metadata";
22
import { OBJECT_ATTR_DISCRIMINATOR } from "@metaobjectsdev/metadata";
3-
import { perEntity, type Generator, type GeneratorFactory, entityOutputPath, emitsWriteArtifacts, isTphSubtype, CODEGEN_ATTR_EMIT_FORM } from "@metaobjectsdev/codegen-ts";
3+
import { perEntity, type Generator, type GeneratorFactory, entityOutputPath, servesWriteApi, isProjection, isTphSubtype, CODEGEN_ATTR_EMIT_FORM } from "@metaobjectsdev/codegen-ts";
44
import { renderFormFile } from "./templates/form-file.js";
55

66
export interface FormFileOpts {
@@ -33,7 +33,11 @@ export const formFile = function formFile(opts?: FormFileOpts): Generator {
3333
// ADR-0039: own — @discriminator identifies a TPH base level (read own so a
3434
// subtype isn't mistaken for a base); e is already known not to be a subtype.
3535
if (typeof e.ownAttr(OBJECT_ATTR_DISCRIMINATOR) === "string") return false;
36-
return emitsWriteArtifacts(e);
36+
// A form is a client of a WRITE endpoint. Ask whether one exists, not where the
37+
// data is stored — see api-surface.ts. `!isProjection` stays explicit here: a
38+
// read-only view is a UI-tier exclusion (nothing to submit), distinct from
39+
// whether write endpoints exist at all.
40+
return servesWriteApi(e) && !isProjection(e);
3741
},
3842
generate: perEntity((entity, ctx) => {
3943
if (!ctx.renderContext) {

server/typescript/packages/codegen-ts-tanstack/src/tanstack-grid-hook.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { MetaObject } from "@metaobjectsdev/metadata";
2-
import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, emitsInstanceArtifacts, isTphSubtype, CODEGEN_ATTR_EMIT_TANSTACK, CODEGEN_ATTR_EMIT_GRID } from "@metaobjectsdev/codegen-ts";
2+
import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, entityMetaFileName, renderEntityMetaFile, servesReadApi, isTphSubtype, CODEGEN_ATTR_EMIT_TANSTACK, CODEGEN_ATTR_EMIT_GRID } from "@metaobjectsdev/codegen-ts";
33
import { hasDataGridLayout, warnMissingDataGridLayout } from "./data-grid-gate.js";
44
import { renderGridHookFile } from "./templates/grid-hook-file.js";
55

@@ -31,7 +31,7 @@ export const tanstackGridHook = function tanstackGridHook(opts?: TanstackGridHoo
3131
// TS2307 when the inherited layout carries an `@filter` preset (the hook then imports
3232
// `<sub>DefaultFilter` from the missing columns module).
3333
const passesOtherGates = (e: MetaObject): boolean =>
34-
emitsInstanceArtifacts(e)
34+
servesReadApi(e)
3535
// ADR-0039: resolving — a concrete entity may inherit its @emit* opt-out flag via extends.
3636
&& e.attr(CODEGEN_ATTR_EMIT_TANSTACK) !== false
3737
&& userFilter(e)
@@ -40,14 +40,23 @@ export const tanstackGridHook = function tanstackGridHook(opts?: TanstackGridHoo
4040
if (!ctx.renderContext) {
4141
throw new Error("tanstack-grid-hook: renderContext is required (provided by runGen)");
4242
}
43-
return {
43+
// Also emit the DB-free descriptor module this file imports from. Each UI
44+
// generator emits it rather than relying on the consumer wiring an extra one:
45+
// the entity generator is scaffold-and-own (ADR-0034), so it cannot be changed
46+
// from the package. Emissions are byte-identical between generators, which the
47+
// runner collapses (#266).
48+
return [{
49+
path: entityOutputPath(ctx.renderContext.outputLayout, entity.package,
50+
entityMetaFileName(entity.name)),
51+
content: await formatTs(renderEntityMetaFile(entity, ctx.renderContext.apiPrefix)),
52+
}, {
4453
path: entityOutputPath(
4554
ctx.renderContext.outputLayout,
4655
entity.package,
4756
`${entity.name}.grid.ts`,
4857
),
4958
content: await formatTs(renderGridHookFile(entity, ctx.renderContext)),
50-
};
59+
}];
5160
});
5261
const generator: Generator = {
5362
name: "tanstack-grid-hook",

server/typescript/packages/codegen-ts-tanstack/src/tanstack-grid.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { MetaObject } from "@metaobjectsdev/metadata";
2-
import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, emitsInstanceArtifacts, isTphSubtype, CODEGEN_ATTR_EMIT_TANSTACK, CODEGEN_ATTR_EMIT_GRID } from "@metaobjectsdev/codegen-ts";
2+
import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, servesReadApi, isTphSubtype, CODEGEN_ATTR_EMIT_TANSTACK, CODEGEN_ATTR_EMIT_GRID } from "@metaobjectsdev/codegen-ts";
33
import { hasDataGridLayout, warnMissingDataGridLayout } from "./data-grid-gate.js";
44
import { renderColumnsFile } from "./templates/columns-file.js";
55

@@ -24,7 +24,7 @@ export const tanstackGrid = function tanstackGrid(opts?: TanstackGridOpts): Gene
2424
// Split out so the discoverability note can name exactly the entities the LAYOUT
2525
// gate alone held back (#287) — an opted-out or abstract type is not a surprise.
2626
const passesOtherGates = (e: MetaObject): boolean =>
27-
emitsInstanceArtifacts(e)
27+
servesReadApi(e)
2828
// ADR-0039: resolving — a concrete entity may inherit its @emit* opt-out flag via extends.
2929
&& e.attr(CODEGEN_ATTR_EMIT_TANSTACK) !== false
3030
&& userFilter(e)

server/typescript/packages/codegen-ts-tanstack/src/tanstack-query.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { MetaObject } from "@metaobjectsdev/metadata";
2-
import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, emitsInstanceArtifacts, isTphSubtype, CODEGEN_ATTR_EMIT_TANSTACK } from "@metaobjectsdev/codegen-ts";
2+
import { perEntity, type Generator, type GeneratorFactory, formatTs, entityOutputPath, entityMetaFileName, renderEntityMetaFile, servesReadApi, isTphSubtype, CODEGEN_ATTR_EMIT_TANSTACK } from "@metaobjectsdev/codegen-ts";
33
import { renderHooksFile } from "./templates/hooks-file.js";
44

55
export interface TanstackQueryOpts {
@@ -25,17 +25,26 @@ export const tanstackQuery = function tanstackQuery(opts?: TanstackQueryOpts): G
2525
// FR-017 Tier 3: TPH subtypes get no standalone hooks file — their per-subtype
2626
// hooks live in the discriminator base's hooks file (polymorphic + per-subtype).
2727
// ADR-0039: resolving — a concrete entity may inherit @emitTanstack via extends.
28-
filter: (e: MetaObject) => emitsInstanceArtifacts(e) && e.attr(CODEGEN_ATTR_EMIT_TANSTACK) !== false && !isTphSubtype(e) && userFilter(e),
28+
filter: (e: MetaObject) => servesReadApi(e) && e.attr(CODEGEN_ATTR_EMIT_TANSTACK) !== false && !isTphSubtype(e) && userFilter(e),
2929
generate: perEntity(async (entity, ctx) => {
3030
if (!ctx.renderContext) {
3131
throw new Error(
3232
"tanstack-query: renderContext is required (provided by runGen)",
3333
);
34-
}
35-
return {
34+
} // Also emit the DB-free descriptor module this file imports from. Each UI
35+
// generator emits it rather than relying on the consumer wiring an extra
36+
// generator: the entity generator is scaffold-and-own (ADR-0034), so it cannot
37+
// be changed from the package. Emissions are byte-identical between generators,
38+
// which the runner collapses (#266).
39+
const metaFile = {
40+
path: entityOutputPath(ctx.renderContext.outputLayout, entity.package,
41+
entityMetaFileName(entity.name)),
42+
content: await formatTs(renderEntityMetaFile(entity, ctx.renderContext.apiPrefix)),
43+
};
44+
return [metaFile, {
3645
path: entityOutputPath(ctx.renderContext.outputLayout, entity.package, `${entity.name}.hooks.ts`),
3746
content: await formatTs(renderHooksFile(entity, ctx.renderContext)),
38-
};
47+
}];
3948
}),
4049
};
4150
if (opts?.target) {

server/typescript/packages/codegen-ts-tanstack/src/templates/grid-hook-file.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ import { code, imp, joinCode, type Code } from "ts-poet";
1212
import type { MetaObject, MetaLayout } from "@metaobjectsdev/metadata";
1313
import { LAYOUT_SUBTYPE_DATA_GRID } from "@metaobjectsdev/metadata";
1414
import type { RenderContext } from "@metaobjectsdev/codegen-ts";
15+
16+
/** The DB-free descriptor sibling of an entity module: `./Author` → `./Author.meta`,
17+
* `./Author.js` → `./Author.meta.js`. The UI files take the `<Entity>` descriptor
18+
* from there so a browser bundle never pulls the Drizzle table in — it is the ONLY
19+
* value they import from the entity module; everything else is `import type`. */
20+
function metaModuleOf(entityModule: string): string {
21+
return entityModule.endsWith(".js")
22+
? `${entityModule.slice(0, -3)}.meta.js`
23+
: `${entityModule}.meta`;
24+
}
25+
1526
import { GENERATED_HEADER, entityModuleSpecifier, siblingSpecifier } from "@metaobjectsdev/codegen-ts";
1627

1728
interface GridSpec {
@@ -69,7 +80,7 @@ export function renderGridHookFile(entity: MetaObject, ctx: RenderContext): stri
6980
const buildFilterQsSym = imp("buildFilterQs@@metaobjectsdev/runtime-web");
7081

7182
const entityImports: Code = code`
72-
import { ${entityName} } from ${JSON.stringify(entityModule)};
83+
import { ${entityName} } from ${JSON.stringify(metaModuleOf(entityModule))};
7384
import type { ${entityName} as ${entityName}Row } from ${JSON.stringify(entityModule)};
7485
`;
7586

server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
import { code, imp, joinCode, Import, type Code } from "ts-poet";
22
import type { MetaObject } from "@metaobjectsdev/metadata";
33
import type { RenderContext, RelationEntry } from "@metaobjectsdev/codegen-ts";
4+
5+
/** The DB-free descriptor sibling of an entity module: `./Author` → `./Author.meta`,
6+
* `./Author.js` → `./Author.meta.js`. The UI files take the `<Entity>` descriptor
7+
* from there so a browser bundle never pulls the Drizzle table in — it is the ONLY
8+
* value they import from the entity module; everything else is `import type`. */
9+
function metaModuleOf(entityModule: string): string {
10+
return entityModule.endsWith(".js")
11+
? `${entityModule.slice(0, -3)}.meta.js`
12+
: `${entityModule}.meta`;
13+
}
14+
415
import {
516
GENERATED_HEADER,
617
isProjection,
@@ -170,8 +181,8 @@ function renderReadOnlyHooksFile(entity: MetaObject, entityModule: string, ctx:
170181
const buildFilterQsSym = imp("buildFilterQs@@metaobjectsdev/runtime-web");
171182

172183
const entityImports: Code = code`
184+
import { ${entityName} } from ${JSON.stringify(metaModuleOf(entityModule))};
173185
import {
174-
${entityName},
175186
type ${entityName} as ${entityName}Row,
176187
type ${entityName}Filter,
177188
} from ${JSON.stringify(entityModule)};
@@ -249,8 +260,8 @@ function renderFullHooksFile(entity: MetaObject, entityModule: string, ctx: Rend
249260
const buildFilterQsSym = imp("buildFilterQs@@metaobjectsdev/runtime-web");
250261

251262
const entityImports: Code = code`
263+
import { ${entityName} } from ${JSON.stringify(metaModuleOf(entityModule))};
252264
import {
253-
${entityName},
254265
type ${entityName} as ${entityName}Row,
255266
type ${entityName}Insert,
256267
type ${entityName}Update,

server/typescript/packages/codegen-ts-tanstack/test/golden/__snapshots__/grid-filter/Subscriber.grid.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @generated by @metaobjectsdev/codegen-ts-tanstack — DO NOT EDIT.
22
// Source metadata: Subscriber (Subscriber)
3-
import { Subscriber } from "./Subscriber";
43
import type { Subscriber as SubscriberRow } from "./Subscriber";
4+
import { Subscriber } from "./Subscriber.meta";
55
import { subscriberActiveFilter } from "./Subscriber.columns";
66
import { buildFilterQs } from "@metaobjectsdev/runtime-web";
77
import { useEntityFetcher } from "@metaobjectsdev/tanstack";

server/typescript/packages/codegen-ts-tanstack/test/golden/__snapshots__/multi-grid/Program.grid.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @generated by @metaobjectsdev/codegen-ts-tanstack — DO NOT EDIT.
22
// Source metadata: Program (Program)
3-
import { Program } from "./Program";
43
import type { Program as ProgramRow } from "./Program";
4+
import { Program } from "./Program.meta";
55
import { buildFilterQs } from "@metaobjectsdev/runtime-web";
66
import { useEntityFetcher } from "@metaobjectsdev/tanstack";
77
import { useQuery } from "@tanstack/react-query";

server/typescript/packages/codegen-ts-tanstack/test/golden/__snapshots__/single-entity/Subscriber.grid.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @generated by @metaobjectsdev/codegen-ts-tanstack — DO NOT EDIT.
22
// Source metadata: Subscriber (Subscriber)
3-
import { Subscriber } from "./Subscriber";
43
import type { Subscriber as SubscriberRow } from "./Subscriber";
4+
import { Subscriber } from "./Subscriber.meta";
55
import { buildFilterQs } from "@metaobjectsdev/runtime-web";
66
import { useEntityFetcher } from "@metaobjectsdev/tanstack";
77
import { useQuery } from "@tanstack/react-query";

server/typescript/packages/codegen-ts-tanstack/test/tanstack-query.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ async function buildCtx(genFilter?: (e: { name: string }) => boolean): Promise<G
2828
};
2929
}
3030

31+
const pick = (files: { path: string; content: string }[], suffix: string) => {
32+
const f = files.find((x) => x.path.endsWith(suffix));
33+
if (!f) throw new Error(`no emitted file ending in ${suffix} — got ${files.map((x) => x.path).join(", ")}`);
34+
return f;
35+
};
36+
3137
describe("tanstackQuery() factory", () => {
3238
test("returns a Generator named 'tanstack-query'", () => {
3339
const gen = tanstackQuery();
@@ -38,8 +44,7 @@ describe("tanstackQuery() factory", () => {
3844
test("emits Subscriber.hooks.ts with the query-key factory and 5 hooks", async () => {
3945
const ctx = await buildCtx();
4046
const files = await tanstackQuery().generate(ctx);
41-
expect(files.length).toBe(1);
42-
const file = files[0]!;
47+
const file = pick(files, ".hooks.ts");
4348
expect(file.path).toBe("Subscriber.hooks.ts");
4449
expect(file.content).toContain("@generated by @metaobjectsdev/codegen-ts");
4550
// Query-key factory:
@@ -57,28 +62,28 @@ describe("tanstackQuery() factory", () => {
5762

5863
test("mutation hooks invalidate via subscriberKeys.all()", async () => {
5964
const ctx = await buildCtx();
60-
const file = (await tanstackQuery().generate(ctx))[0]!;
65+
const file = pick(await tanstackQuery().generate(ctx), ".hooks.ts");
6166
// Each mutation should call invalidateQueries with subscriberKeys.all().
6267
const matches = file.content.match(/invalidateQueries\(\{\s*queryKey:\s*subscriberKeys\.all\(\)/g);
6368
expect(matches?.length).toBe(3); // create, update, delete
6469
});
6570

6671
test("each hook accepts an opts override parameter", async () => {
6772
const ctx = await buildCtx();
68-
const file = (await tanstackQuery().generate(ctx))[0]!;
73+
const file = pick(await tanstackQuery().generate(ctx), ".hooks.ts");
6974
expect(file.content).toContain("opts?: Omit<UseQueryOptions");
7075
expect(file.content).toContain("opts?: Omit<UseMutationOptions");
7176
});
7277

7378
test("uses Subscriber.$path from entity constants for URLs", async () => {
7479
const ctx = await buildCtx();
75-
const file = (await tanstackQuery().generate(ctx))[0]!;
80+
const file = pick(await tanstackQuery().generate(ctx), ".hooks.ts");
7681
expect(file.content).toContain("Subscriber.$path");
7782
});
7883

7984
test("prefixes fetch URLs with Subscriber.$apiPrefix", async () => {
8085
const ctx = await buildCtx();
81-
const file = (await tanstackQuery().generate(ctx))[0]!;
86+
const file = pick(await tanstackQuery().generate(ctx), ".hooks.ts");
8287
// Every fetch URL should have $apiPrefix before $path so users can set apiPrefix once.
8388
expect(file.content).toContain("Subscriber.$apiPrefix");
8489
// Specifically the get-one pattern:

0 commit comments

Comments
 (0)