Skip to content

Commit 17d7f87

Browse files
dmealingclaude
andauthored
Typed projection views + target runtime flag (0.11.6) (#72)
* feat(codegen-ts): typed projection view columns + honor allowlists:false Projection (view) codegen had two gaps that made a generated `.existing()` pgView unusable for a typed read: - It emitted `pgView(name, {})` with no column map, so `db.select().from(view)` yielded `{}` rows. Now emits the typed column map from the projection fields (honoring @dbColumnType — e.g. a passthrough of a jsonb column → `jsonb(...)`, timestamps → `timestamp(..., { mode })`), carrying `.notNull()` (the only table modifier valid on an existing view). - It always imported `@metaobjectsdev/runtime-ts` for the filter/sort allowlists, ignoring the `allowlists` flag that entity-file already honors. renderEntityFile now threads `allowlists` (+ `timestampMode`) to renderProjectionDecl, which omits the allowlists (and the runtime-ts import) when false — so a dependency-free consumer gets compilable output. Adds projection-decl regression tests. Verified end-to-end against a real consumer (typed view-backed reads compile + run). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(codegen-ts): projection passthroughs resolve value-object refs A field.object passthrough in a projection collapsed to z.unknown() (Zod) and a bare jsonb column (no $type), losing the value-object shape. Now the projection declaration resolves @objectref the same way the entity codegen does: - the read Zod schema references the VO's <Ref>InsertSchema (so z.infer carries the VO shape, not unknown), - the .existing() view column narrows to .$type<VO>() so a typed db.select() returns the VO type. Import modules resolve layout/package/extStyle-aware via the shared valueObjectModuleSpecifier when a RenderContext is threaded (entity-file now passes ctx), with a flat same-dir fallback for bare unit-test calls. Adds a projection-decl regression test. Verified against a real consumer (view-backed reads now carry the precise VO types). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(codegen-ts): contract-only projection output (includeViewDecl flag) Add an includeViewDecl option (default true) on the entityFile generator, threaded to renderProjectionDecl. When false, the projection emits only the read Zod schema + inferred type + constants — NO Drizzle .existing() view declaration and no drizzle-orm import. This lets a projection's contract type be generated into a runtime-free types package (e.g. a shared package consumed by a web client) while the Drizzle view stays in the DB-layer target. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * refactor(codegen-ts): replace includeViewDecl with a target-level runtime flag The `includeViewDecl` option (added on this branch) was scoped to the wrong concept — it singled out the projection's view declaration, when the real axis is the TARGET's audience: a server package wants the full DB layer, a contract package consumed by a UI client wants types only. Per-artifact "drop this one binding" booleans don't generalize: the next consumer needs `includeTableDecl`, then `includeQueryHelpers`, and each target has to remember to set them all. Replace it with one flag on the target: `runtime` (default true). - `runtime: false` = a contract-only target. Every object renders as its plain shape (interface + Zod), every projection as its read schema + inferred type. No drizzle-orm (no pgTable / pgView), no runtime-ts allowlists. The flag lives on ResolvedTarget, so it reaches every template as `ctx.selfTarget.runtime` with no new plumbing. - `allowlists` stays as the finer Fastify-vs-Hono opt-out WITHIN a runtime target — it's a different axis (runtime-ts dependency, not drizzle). Views stop being special: a contract projection drops its pgView the same way a contract entity drops its pgTable. EntityFileOpts loses `includeViewDecl`; the low-level renderProjectionDecl keeps its opt (driven from the target now). Tests: add a contract-target (runtime:false) block asserting a projection keeps its Zod schema + type but emits no pgView/drizzle-orm, and a write-through entity renders its plain shape with no pgTable. 818 pass. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(codegen-ts-react,tanstack): add runtime to cross-target test ResolvedTarget literals The new required `runtime` field on ResolvedTarget broke the workspace typecheck in the two client codegen packages, whose cross-target tests construct target literals directly. codegen-ts's own tests were already updated; these two siblings typecheck against codegen-ts's built dist and were missed. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9a226fe commit 17d7f87

15 files changed

Lines changed: 325 additions & 31 deletions

server/typescript/packages/codegen-ts-react/test/cross-target.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { FileSource } from "@metaobjectsdev/metadata/core";
88
// Product lives in package "shop::commerce" → package-layout path "shop/commerce/Product".
99
const FIXTURE = resolve(import.meta.dir, "fixtures", "packaged-entity.json");
1010

11-
const model: ResolvedTarget = { name: "default", outDir: "db/gen", importBase: "@mf/db/generated", outputLayout: "package", dbImport: "../index" };
12-
const web: ResolvedTarget = { name: "web", outDir: "web/gen", importBase: undefined, outputLayout: "package", dbImport: "../index" };
11+
const model: ResolvedTarget = { name: "default", outDir: "db/gen", importBase: "@mf/db/generated", outputLayout: "package", dbImport: "../index", runtime: true };
12+
const web: ResolvedTarget = { name: "web", outDir: "web/gen", importBase: undefined, outputLayout: "package", dbImport: "../index", runtime: true };
1313

1414
async function ctxFor(self: ResolvedTarget, em: ResolvedTarget) {
1515
const { root } = await new MetaDataLoader().load([new FileSource(FIXTURE)]);

server/typescript/packages/codegen-ts-tanstack/test/cross-target.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { FileSource } from "@metaobjectsdev/metadata/core";
99
// Product lives in package "shop::commerce" → package-layout path "shop/commerce/Product".
1010
const FIXTURE = resolve(import.meta.dir, "fixtures", "packaged-grid-entity.json");
1111

12-
const model: ResolvedTarget = { name: "default", outDir: "db/gen", importBase: "@mf/db/generated", outputLayout: "package", dbImport: "../index" };
13-
const web: ResolvedTarget = { name: "web", outDir: "web/gen", importBase: undefined, outputLayout: "package", dbImport: "../index" };
12+
const model: ResolvedTarget = { name: "default", outDir: "db/gen", importBase: "@mf/db/generated", outputLayout: "package", dbImport: "../index", runtime: true };
13+
const web: ResolvedTarget = { name: "web", outDir: "web/gen", importBase: undefined, outputLayout: "package", dbImport: "../index", runtime: true };
1414

1515
async function ctxFor(self: ResolvedTarget, em: ResolvedTarget) {
1616
const { root } = await new MetaDataLoader().load([new FileSource(FIXTURE)]);

server/typescript/packages/codegen-ts/src/import-path.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,19 @@ export interface ResolvedTarget {
118118
importBase: string | undefined;
119119
outputLayout: OutputLayout;
120120
dbImport: string;
121+
/**
122+
* Whether this target emits server runtime bindings. `true` (the default for
123+
* the implicit "default" target) = a full server package: Drizzle tables/views,
124+
* `runtime-ts` filter/sort allowlists, the whole DB layer. `false` = a
125+
* contract-only target: Zod schemas + inferred TS types ONLY, with no
126+
* `drizzle-orm` / `runtime-ts` import — for a shared wire-contract package
127+
* consumed by a UI/web client that has no database. The axis is the target's
128+
* AUDIENCE (server vs client), not any one artifact: in a contract target an
129+
* entity drops its `pgTable`, a projection drops its `pgView`, every object
130+
* renders as its plain shape. Allowlists are off here too (contract ⇒ no
131+
* `runtime-ts`); the finer `allowlists` knob only applies within a runtime target.
132+
*/
133+
runtime: boolean;
121134
}
122135

123136
/** importBase + (package path when package layout) + entity, extension-less. */

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ export interface TargetConfig {
3232
importBase?: string;
3333
outputLayout?: OutputLayout;
3434
dbImport?: string;
35+
/**
36+
* Whether this target emits server runtime bindings. Defaults to `true` (a full
37+
* server package: Drizzle tables/views + the DB layer). Set `false` for a
38+
* contract-only target — Zod schemas + inferred TS types only, no `drizzle-orm`
39+
* / `runtime-ts` import — e.g. a shared wire-contract package consumed by a web
40+
* client with no database. See {@link ResolvedTarget.runtime}.
41+
*/
42+
runtime?: boolean;
3543
}
3644

3745
/** Subset of MetaobjectsGenConfig surfaced to generators via GenContext. */
@@ -195,6 +203,8 @@ export function resolveTargets(config: MetaobjectsGenConfig): Record<string, Res
195203
importBase: config.importBase,
196204
outputLayout: layout,
197205
dbImport: config.dbImport,
206+
// The default target is the server package — runtime bindings on.
207+
runtime: true,
198208
},
199209
};
200210
for (const [name, t] of Object.entries(config.targets ?? {})) {
@@ -204,6 +214,7 @@ export function resolveTargets(config: MetaobjectsGenConfig): Record<string, Res
204214
importBase: t.importBase,
205215
outputLayout: t.outputLayout ?? layout,
206216
dbImport: t.dbImport ?? config.dbImport,
217+
runtime: t.runtime ?? true,
207218
};
208219
}
209220
return out;

server/typescript/packages/codegen-ts/src/render-context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext {
105105
importBase: undefined,
106106
outputLayout,
107107
dbImport: opts.dbImport,
108+
runtime: true,
108109
};
109110
const collectionNameOpts = {
110111
pluralize: opts.pluralizeCollections ?? true,

server/typescript/packages/codegen-ts/src/templates/entity-file.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ import { isAbstract } from "../instance-artifacts.js";
3333
* drop `@metaobjectsdev/runtime-ts` from their deps entirely. The client-side
3434
* `<Entity>Filter` type is always emitted — consumers still want it for typed
3535
* client calls regardless of how the server is wired.
36+
*
37+
* Whether the file emits ANY server runtime binding at all (Drizzle table/view,
38+
* the allowlists) is governed by the TARGET, not this option: `ctx.selfTarget.runtime`.
39+
* A contract-only target (`runtime: false`) renders every object as its plain
40+
* shape (interface + Zod) and every projection as its read schema — no
41+
* `drizzle-orm`, no `runtime-ts`. `allowlists` is a finer Fastify-vs-Hono opt-out
42+
* that only matters within a runtime target.
3643
*/
3744
export interface RenderEntityFileOpts {
3845
readonly allowlists?: boolean;
@@ -43,7 +50,10 @@ export function renderEntityFile(
4350
ctx: RenderContext,
4451
opts?: RenderEntityFileOpts,
4552
): string {
46-
const allowlists = opts?.allowlists ?? true;
53+
// Contract-only target ⇒ no server runtime: no Drizzle pgView/pgTable, no
54+
// runtime-ts allowlists. The read schema + inferred types still emit.
55+
const runtime = ctx.selfTarget.runtime;
56+
const allowlists = runtime ? (opts?.allowlists ?? true) : false;
4757

4858
// --- Abstract path (shape only) ---
4959
// An abstract entity contributes shape via inheritance only — it must NEVER
@@ -65,14 +75,20 @@ export function renderEntityFile(
6575
columnNamingStrategy: ctx.columnNamingStrategy,
6676
dialect: ctx.dialect,
6777
apiPrefix: ctx.apiPrefix,
78+
timestampMode: ctx.timestampMode,
79+
allowlists,
80+
ctx,
81+
// Contract target drops the Drizzle .existing() view decl + drizzle-orm import.
82+
includeViewDecl: runtime,
6883
});
6984
}
7085

71-
// --- Value-only path (no writable source.rdb: in-memory / transit shape) ---
72-
// No Drizzle table, no migration footprint. Consumers that need to validate
73-
// the shape (LLM tool_use input_schema, REST body parsing) use the Zod
74-
// schema; consumers that need the type use the interface.
75-
if (!hasWritableRdbSource(entity)) {
86+
// --- Value-only / contract path (no Drizzle table) ---
87+
// Reached when the entity has no writable source.rdb (in-memory / transit
88+
// shape) OR the target is contract-only (a UI/wire package gets the read shape,
89+
// not a DB table). Either way: interface + Zod, no migration footprint, no
90+
// drizzle-orm. Consumers validate via the Zod schema and type via the interface.
91+
if (!runtime || !hasWritableRdbSource(entity)) {
7692
return renderValueObjectFile(entity, ctx.apiPrefix, ctx);
7793
}
7894

server/typescript/packages/codegen-ts/src/templates/projection-decl.ts

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,17 @@
88
// - Insert/Update types
99

1010
import { code, imp, joinCode, type Code } from "ts-poet";
11-
import { MetaField, MetaObject, type MetaRoot } from "@metaobjectsdev/metadata";
11+
import {
12+
MetaField, MetaObject, type MetaRoot,
13+
FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, stripPackage,
14+
} from "@metaobjectsdev/metadata";
1215
import { projectionViewName } from "../projection/extract-view-spec.js";
1316
import { columnNameFromField, toSnakeCase, pluralize } from "../naming.js";
1417
import { GENERATED_HEADER } from "../constants.js";
1518
import type { ColumnNamingStrategy } from "../metaobjects-config.js";
19+
import type { RenderContext } from "../render-context.js";
20+
import { mapColumnType } from "../column-mapper.js";
21+
import { valueObjectModuleSpecifier } from "../import-path.js";
1622
import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js";
1723
import { renderFilterType } from "./filter-type.js";
1824
import { inferViewKind, zodTypeFor, currencyMetaFor, labelFor } from "./field-meta.js";
@@ -25,6 +31,27 @@ export interface ProjectionDeclOpts {
2531
readonly columnNamingStrategy: ColumnNamingStrategy;
2632
readonly dialect: "postgres" | "sqlite";
2733
readonly apiPrefix?: string;
34+
/** Drives the timestamp column TS type (Date vs string) in the view declaration. */
35+
readonly timestampMode?: "date" | "string";
36+
/**
37+
* When false, omit the Fastify-flavored filter/sort allowlists (and their
38+
* `runtime-ts` import) — mirrors entity-file's `allowlists` option so a
39+
* dependency-free consumer (no runtime-ts) gets compilable output.
40+
*/
41+
readonly allowlists?: boolean;
42+
/**
43+
* Render context, when available — used to resolve value-object import MODULES
44+
* (`.$type<VO>()` + the VO Zod schema) in a layout/package/extStyle-aware way.
45+
* Absent in bare unit-test calls, which fall back to a flat same-dir import.
46+
*/
47+
readonly ctx?: RenderContext;
48+
/**
49+
* Whether to emit the Drizzle `.existing()` view declaration (and its
50+
* drizzle-orm import). Default true. Set false for a contract-only output
51+
* (Zod read schema + inferred type + constants, no runtime DB dependency) —
52+
* e.g. a shared types package consumed by a web client that has no Drizzle.
53+
*/
54+
readonly includeViewDecl?: boolean;
2855
}
2956

3057
// ---------------------------------------------------------------------------
@@ -64,7 +91,20 @@ export function renderProjectionDecl(
6491
root: MetaRoot,
6592
opts: ProjectionDeclOpts,
6693
): string {
67-
const { dialect, columnNamingStrategy, apiPrefix = "" } = opts;
94+
const { dialect, columnNamingStrategy, apiPrefix = "", timestampMode = "string", allowlists = true, ctx, includeViewDecl = true } = opts;
95+
96+
// Resolve a value-object name → its import module. Layout/package/extStyle-aware
97+
// when a render context is present (so the projection's VO imports match the
98+
// entity's), else a flat same-dir import — identical to zodFieldExpr's fallback.
99+
const voModule = (refBase: string): string =>
100+
ctx
101+
? valueObjectModuleSpecifier(refBase, ctx.packageOf, projection.package, ctx.outputLayout, ctx.extStyle)
102+
: `./${refBase}.js`;
103+
const objectRefOf = (f: MetaField): string | undefined => {
104+
if (f.subType !== FIELD_SUBTYPE_OBJECT) return undefined;
105+
const ref = f.attr(FIELD_ATTR_OBJECT_REF);
106+
return typeof ref === "string" && ref.length > 0 ? stripPackage(ref) : undefined;
107+
};
68108

69109
const viewFn = dialect === "postgres" ? "pgView" : "sqliteView";
70110
const viewModule =
@@ -93,9 +133,45 @@ export function renderProjectionDecl(
93133
}
94134
for (const f of projection.ownFields()) allFields.push(f);
95135

96-
const zodLines: Code[] = allFields.map(
97-
(f) => code` ${f.name}: ${z}.${zodTypeFor(f).replace(/^z\./, "")}`,
98-
);
136+
// A field.object passthrough carries the value-object's Zod schema (so the
137+
// view's read schema + inferred type expose the VO shape, not z.unknown()).
138+
const zodLines: Code[] = allFields.map((f) => {
139+
const refBase = objectRefOf(f);
140+
if (refBase) {
141+
const schemaSym = imp(`${refBase}InsertSchema@${voModule(refBase)}`);
142+
return f.isArray
143+
? code` ${f.name}: ${z}.array(${schemaSym})`
144+
: code` ${f.name}: ${schemaSym}`;
145+
}
146+
return code` ${f.name}: ${z}.${zodTypeFor(f).replace(/^z\./, "")}`;
147+
});
148+
149+
// Typed view column map for the Drizzle `.existing()` declaration — keyed by
150+
// projection field name, valued by the column builder for the (renamed)
151+
// physical view column, so `db.select().from(<view>)` is typed. Honors
152+
// `@dbColumnType` (e.g. a passthrough of a jsonb column). `.existing()` views
153+
// carry type + physical name only — no PK/default/notNull modifiers.
154+
const viewColumnLines: Code[] = allFields.map((f) => {
155+
const spec = mapColumnType(f, dialect, columnNamingStrategy, timestampMode);
156+
const colSym = imp(`${spec.fnName}@${spec.importModule}`);
157+
const optsArg =
158+
spec.fnOptions && Object.keys(spec.fnOptions).length > 0
159+
? `, ${JSON.stringify(spec.fnOptions)}`
160+
: "";
161+
// Of the table modifiers, only `.notNull()` carries to an existing view (it
162+
// shapes the SELECT type); `.primaryKey()`/`.default()`/`.references()` are
163+
// table-DDL concerns and invalid on a `.existing()` view declaration.
164+
const notNull = spec.modifiers.includes(".notNull()") ? ".notNull()" : "";
165+
// Narrow a jsonb passthrough to its value-object type — `.$type<VO>()` —
166+
// mirroring the entity column, so the read row is typed (not `unknown`).
167+
let dollarType: Code | string = "";
168+
const dtr = spec.dollarTypeRef;
169+
if (dtr?.kind === "objectRef") {
170+
const voTypeSym = imp(`${dtr.name}@${voModule(dtr.name)}`);
171+
dollarType = dtr.array ? code`.$type<${voTypeSym}[]>()` : code`.$type<${voTypeSym}>()`;
172+
}
173+
return code` ${f.name}: ${colSym}(${JSON.stringify(spec.dbName)}${optsArg})${dollarType}${notNull}`;
174+
});
99175

100176
const constFieldLines: string[] = allFields.map((f) => {
101177
const dbCol = columnNameFromField(f.name, columnNamingStrategy);
@@ -114,12 +190,16 @@ export function renderProjectionDecl(
114190
const path = pathFromProjectionName(projName);
115191

116192
const sections: Code[] = [
117-
code`
193+
...(includeViewDecl
194+
? [code`
118195
// View declaration — Drizzle uses this for typed SELECT queries.
119196
// The SQL view is created/managed by migrate-ts; .existing() tells Drizzle
120197
// not to attempt DDL for this declaration.
121-
export const ${camelName}View = ${viewSym}(${JSON.stringify(viewName)}, {}).existing();
122-
`,
198+
export const ${camelName}View = ${viewSym}(${JSON.stringify(viewName)}, {
199+
${joinCode(viewColumnLines, { on: ",\n" })}
200+
}).existing();
201+
`]
202+
: []),
123203
code`
124204
export const ${projName}Schema = ${z}.object({
125205
${joinCode(zodLines, { on: ",\n" })}
@@ -137,8 +217,9 @@ export const ${projName} = {
137217
${constFieldLines.join("\n")}
138218
} as const;
139219
`,
140-
renderFilterAllowlist(projection),
141-
renderSortAllowlist(projection),
220+
...(allowlists
221+
? [renderFilterAllowlist(projection), renderSortAllowlist(projection)]
222+
: []),
142223
renderFilterType(projection),
143224
];
144225

server/typescript/packages/codegen-ts/test/import-path.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ import {
1414

1515
const model = (over: Partial<ResolvedTarget> = {}): ResolvedTarget => ({
1616
name: "default", outDir: "db/gen", importBase: "@mf/db/generated",
17-
outputLayout: "package", dbImport: "../index", ...over,
17+
outputLayout: "package", dbImport: "../index", runtime: true, ...over,
1818
});
1919
const web = (over: Partial<ResolvedTarget> = {}): ResolvedTarget => ({
2020
name: "web", outDir: "web/gen", importBase: undefined,
21-
outputLayout: "package", dbImport: "../index", ...over,
21+
outputLayout: "package", dbImport: "../index", runtime: true, ...over,
2222
});
2323

2424
describe("entityModuleSpecifier", () => {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ describe("resolveTargets", () => {
99
const t = resolveTargets({ ...base, importBase: "@mf/db/generated", outputLayout: "package" });
1010
expect(t[DEFAULT_TARGET_NAME]).toEqual({
1111
name: "default", outDir: "db/gen", importBase: "@mf/db/generated",
12-
outputLayout: "package", dbImport: "../index",
12+
outputLayout: "package", dbImport: "../index", runtime: true,
1313
});
1414
});
1515

@@ -27,8 +27,8 @@ describe("resolveTargets", () => {
2727
web: { outDir: "web/gen" },
2828
},
2929
});
30-
expect(t.api).toEqual({ name: "api", outDir: "api/gen", importBase: undefined, outputLayout: "package", dbImport: "@mf/database" });
31-
expect(t.web).toEqual({ name: "web", outDir: "web/gen", importBase: undefined, outputLayout: "package", dbImport: "../index" });
30+
expect(t.api).toEqual({ name: "api", outDir: "api/gen", importBase: undefined, outputLayout: "package", dbImport: "@mf/database", runtime: true });
31+
expect(t.web).toEqual({ name: "web", outDir: "web/gen", importBase: undefined, outputLayout: "package", dbImport: "../index", runtime: true });
3232
});
3333

3434
test("named target may override outputLayout + importBase", () => {

server/typescript/packages/codegen-ts/test/projection/entity-file.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,4 +244,58 @@ describe("renderEntityFile — source-aware dispatch", () => {
244244
expect(out).not.toContain("sqliteView");
245245
});
246246
});
247+
248+
// A contract-only target (selfTarget.runtime === false) is the UI/wire-package
249+
// axis: no server runtime bindings at all. A projection keeps its Zod read
250+
// schema + inferred type but drops the Drizzle view; a write-through entity
251+
// renders as its plain shape (interface + Zod) instead of a Drizzle table.
252+
// Neither imports drizzle-orm. This replaces the old per-call includeViewDecl
253+
// flag — the decision now lives on the target's audience, not on each artifact.
254+
describe("contract-only target (runtime: false)", () => {
255+
function contractCtx(root: Parameters<typeof buildPkMap>[0]) {
256+
return makeRenderContext({
257+
dialect: "postgres",
258+
loadedRoot: root,
259+
outDir: "/x",
260+
dbImport: "~/db",
261+
pkMap: buildPkMap(root),
262+
relationMap: buildRelationMap(root),
263+
selfTarget: {
264+
name: "shared", outDir: "/x", importBase: "@pkg/shared/generated",
265+
outputLayout: "flat", dbImport: "~/db", runtime: false,
266+
},
267+
});
268+
}
269+
270+
test("projection: keeps Zod read schema + type, drops pgView + drizzle-orm", async () => {
271+
const { root, projection } = await loadProjectionFixture();
272+
const out = renderEntityFile(projection, contractCtx(root));
273+
// contract kept:
274+
expect(out).toContain("ProgramSummarySchema");
275+
expect(out).toContain("export type ProgramSummary");
276+
// runtime stripped:
277+
expect(out).not.toContain("pgView");
278+
expect(out).not.toContain(".existing()");
279+
expect(out).not.toContain("drizzle-orm");
280+
});
281+
282+
test("write-through entity: renders plain shape, no pgTable / drizzle-orm", async () => {
283+
const { root, entity } = await loadVanillaFixture();
284+
const out = renderEntityFile(entity, contractCtx(root));
285+
// shape kept (Zod schema + a type for the entity):
286+
expect(out).toContain("z.object");
287+
expect(out).toContain("Post");
288+
// runtime stripped:
289+
expect(out).not.toContain("pgTable");
290+
expect(out).not.toContain("drizzle-orm");
291+
expect(out).not.toContain("InferSelectModel");
292+
});
293+
294+
test("no runtime-ts allowlists in a contract target", async () => {
295+
const { root, projection } = await loadProjectionFixture();
296+
const out = renderEntityFile(projection, contractCtx(root));
297+
expect(out).not.toContain("@metaobjectsdev/runtime-ts");
298+
expect(out).not.toContain("FilterAllowlist");
299+
});
300+
});
247301
});

0 commit comments

Comments
 (0)