Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { FileSource } from "@metaobjectsdev/metadata/core";
// Product lives in package "shop::commerce" → package-layout path "shop/commerce/Product".
const FIXTURE = resolve(import.meta.dir, "fixtures", "packaged-entity.json");

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

async function ctxFor(self: ResolvedTarget, em: ResolvedTarget) {
const { root } = await new MetaDataLoader().load([new FileSource(FIXTURE)]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { FileSource } from "@metaobjectsdev/metadata/core";
// Product lives in package "shop::commerce" → package-layout path "shop/commerce/Product".
const FIXTURE = resolve(import.meta.dir, "fixtures", "packaged-grid-entity.json");

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

async function ctxFor(self: ResolvedTarget, em: ResolvedTarget) {
const { root } = await new MetaDataLoader().load([new FileSource(FIXTURE)]);
Expand Down
13 changes: 13 additions & 0 deletions server/typescript/packages/codegen-ts/src/import-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,19 @@ export interface ResolvedTarget {
importBase: string | undefined;
outputLayout: OutputLayout;
dbImport: string;
/**
* Whether this target emits server runtime bindings. `true` (the default for
* the implicit "default" target) = a full server package: Drizzle tables/views,
* `runtime-ts` filter/sort allowlists, the whole DB layer. `false` = a
* contract-only target: Zod schemas + inferred TS types ONLY, with no
* `drizzle-orm` / `runtime-ts` import — for a shared wire-contract package
* consumed by a UI/web client that has no database. The axis is the target's
* AUDIENCE (server vs client), not any one artifact: in a contract target an
* entity drops its `pgTable`, a projection drops its `pgView`, every object
* renders as its plain shape. Allowlists are off here too (contract ⇒ no
* `runtime-ts`); the finer `allowlists` knob only applies within a runtime target.
*/
runtime: boolean;
}

/** importBase + (package path when package layout) + entity, extension-less. */
Expand Down
11 changes: 11 additions & 0 deletions server/typescript/packages/codegen-ts/src/metaobjects-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export interface TargetConfig {
importBase?: string;
outputLayout?: OutputLayout;
dbImport?: string;
/**
* Whether this target emits server runtime bindings. Defaults to `true` (a full
* server package: Drizzle tables/views + the DB layer). Set `false` for a
* contract-only target — Zod schemas + inferred TS types only, no `drizzle-orm`
* / `runtime-ts` import — e.g. a shared wire-contract package consumed by a web
* client with no database. See {@link ResolvedTarget.runtime}.
*/
runtime?: boolean;
}

/** Subset of MetaobjectsGenConfig surfaced to generators via GenContext. */
Expand Down Expand Up @@ -195,6 +203,8 @@ export function resolveTargets(config: MetaobjectsGenConfig): Record<string, Res
importBase: config.importBase,
outputLayout: layout,
dbImport: config.dbImport,
// The default target is the server package — runtime bindings on.
runtime: true,
},
};
for (const [name, t] of Object.entries(config.targets ?? {})) {
Expand All @@ -204,6 +214,7 @@ export function resolveTargets(config: MetaobjectsGenConfig): Record<string, Res
importBase: t.importBase,
outputLayout: t.outputLayout ?? layout,
dbImport: t.dbImport ?? config.dbImport,
runtime: t.runtime ?? true,
};
}
return out;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext {
importBase: undefined,
outputLayout,
dbImport: opts.dbImport,
runtime: true,
};
const collectionNameOpts = {
pluralize: opts.pluralizeCollections ?? true,
Expand Down
28 changes: 22 additions & 6 deletions server/typescript/packages/codegen-ts/src/templates/entity-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ import { isAbstract } from "../instance-artifacts.js";
* drop `@metaobjectsdev/runtime-ts` from their deps entirely. The client-side
* `<Entity>Filter` type is always emitted — consumers still want it for typed
* client calls regardless of how the server is wired.
*
* Whether the file emits ANY server runtime binding at all (Drizzle table/view,
* the allowlists) is governed by the TARGET, not this option: `ctx.selfTarget.runtime`.
* A contract-only target (`runtime: false`) renders every object as its plain
* shape (interface + Zod) and every projection as its read schema — no
* `drizzle-orm`, no `runtime-ts`. `allowlists` is a finer Fastify-vs-Hono opt-out
* that only matters within a runtime target.
*/
export interface RenderEntityFileOpts {
readonly allowlists?: boolean;
Expand All @@ -43,7 +50,10 @@ export function renderEntityFile(
ctx: RenderContext,
opts?: RenderEntityFileOpts,
): string {
const allowlists = opts?.allowlists ?? true;
// Contract-only target ⇒ no server runtime: no Drizzle pgView/pgTable, no
// runtime-ts allowlists. The read schema + inferred types still emit.
const runtime = ctx.selfTarget.runtime;
const allowlists = runtime ? (opts?.allowlists ?? true) : false;

// --- Abstract path (shape only) ---
// An abstract entity contributes shape via inheritance only — it must NEVER
Expand All @@ -65,14 +75,20 @@ export function renderEntityFile(
columnNamingStrategy: ctx.columnNamingStrategy,
dialect: ctx.dialect,
apiPrefix: ctx.apiPrefix,
timestampMode: ctx.timestampMode,
allowlists,
ctx,
// Contract target drops the Drizzle .existing() view decl + drizzle-orm import.
includeViewDecl: runtime,
});
}

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

Expand Down
101 changes: 91 additions & 10 deletions server/typescript/packages/codegen-ts/src/templates/projection-decl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@
// - Insert/Update types

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

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -64,7 +91,20 @@ export function renderProjectionDecl(
root: MetaRoot,
opts: ProjectionDeclOpts,
): string {
const { dialect, columnNamingStrategy, apiPrefix = "" } = opts;
const { dialect, columnNamingStrategy, apiPrefix = "", timestampMode = "string", allowlists = true, ctx, includeViewDecl = true } = opts;

// Resolve a value-object name → its import module. Layout/package/extStyle-aware
// when a render context is present (so the projection's VO imports match the
// entity's), else a flat same-dir import — identical to zodFieldExpr's fallback.
const voModule = (refBase: string): string =>
ctx
? valueObjectModuleSpecifier(refBase, ctx.packageOf, projection.package, ctx.outputLayout, ctx.extStyle)
: `./${refBase}.js`;
const objectRefOf = (f: MetaField): string | undefined => {
if (f.subType !== FIELD_SUBTYPE_OBJECT) return undefined;
const ref = f.attr(FIELD_ATTR_OBJECT_REF);
return typeof ref === "string" && ref.length > 0 ? stripPackage(ref) : undefined;
};

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

const zodLines: Code[] = allFields.map(
(f) => code` ${f.name}: ${z}.${zodTypeFor(f).replace(/^z\./, "")}`,
);
// A field.object passthrough carries the value-object's Zod schema (so the
// view's read schema + inferred type expose the VO shape, not z.unknown()).
const zodLines: Code[] = allFields.map((f) => {
const refBase = objectRefOf(f);
if (refBase) {
const schemaSym = imp(`${refBase}InsertSchema@${voModule(refBase)}`);
return f.isArray
? code` ${f.name}: ${z}.array(${schemaSym})`
: code` ${f.name}: ${schemaSym}`;
}
return code` ${f.name}: ${z}.${zodTypeFor(f).replace(/^z\./, "")}`;
});

// Typed view column map for the Drizzle `.existing()` declaration — keyed by
// projection field name, valued by the column builder for the (renamed)
// physical view column, so `db.select().from(<view>)` is typed. Honors
// `@dbColumnType` (e.g. a passthrough of a jsonb column). `.existing()` views
// carry type + physical name only — no PK/default/notNull modifiers.
const viewColumnLines: Code[] = allFields.map((f) => {
const spec = mapColumnType(f, dialect, columnNamingStrategy, timestampMode);
const colSym = imp(`${spec.fnName}@${spec.importModule}`);
const optsArg =
spec.fnOptions && Object.keys(spec.fnOptions).length > 0
? `, ${JSON.stringify(spec.fnOptions)}`
: "";
// Of the table modifiers, only `.notNull()` carries to an existing view (it
// shapes the SELECT type); `.primaryKey()`/`.default()`/`.references()` are
// table-DDL concerns and invalid on a `.existing()` view declaration.
const notNull = spec.modifiers.includes(".notNull()") ? ".notNull()" : "";
// Narrow a jsonb passthrough to its value-object type — `.$type<VO>()` —
// mirroring the entity column, so the read row is typed (not `unknown`).
let dollarType: Code | string = "";
const dtr = spec.dollarTypeRef;
if (dtr?.kind === "objectRef") {
const voTypeSym = imp(`${dtr.name}@${voModule(dtr.name)}`);
dollarType = dtr.array ? code`.$type<${voTypeSym}[]>()` : code`.$type<${voTypeSym}>()`;
}
return code` ${f.name}: ${colSym}(${JSON.stringify(spec.dbName)}${optsArg})${dollarType}${notNull}`;
});

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

const sections: Code[] = [
code`
...(includeViewDecl
? [code`
// View declaration — Drizzle uses this for typed SELECT queries.
// The SQL view is created/managed by migrate-ts; .existing() tells Drizzle
// not to attempt DDL for this declaration.
export const ${camelName}View = ${viewSym}(${JSON.stringify(viewName)}, {}).existing();
`,
export const ${camelName}View = ${viewSym}(${JSON.stringify(viewName)}, {
${joinCode(viewColumnLines, { on: ",\n" })}
}).existing();
`]
: []),
code`
export const ${projName}Schema = ${z}.object({
${joinCode(zodLines, { on: ",\n" })}
Expand All @@ -137,8 +217,9 @@ export const ${projName} = {
${constFieldLines.join("\n")}
} as const;
`,
renderFilterAllowlist(projection),
renderSortAllowlist(projection),
...(allowlists
? [renderFilterAllowlist(projection), renderSortAllowlist(projection)]
: []),
renderFilterType(projection),
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ import {

const model = (over: Partial<ResolvedTarget> = {}): ResolvedTarget => ({
name: "default", outDir: "db/gen", importBase: "@mf/db/generated",
outputLayout: "package", dbImport: "../index", ...over,
outputLayout: "package", dbImport: "../index", runtime: true, ...over,
});
const web = (over: Partial<ResolvedTarget> = {}): ResolvedTarget => ({
name: "web", outDir: "web/gen", importBase: undefined,
outputLayout: "package", dbImport: "../index", ...over,
outputLayout: "package", dbImport: "../index", runtime: true, ...over,
});

describe("entityModuleSpecifier", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe("resolveTargets", () => {
const t = resolveTargets({ ...base, importBase: "@mf/db/generated", outputLayout: "package" });
expect(t[DEFAULT_TARGET_NAME]).toEqual({
name: "default", outDir: "db/gen", importBase: "@mf/db/generated",
outputLayout: "package", dbImport: "../index",
outputLayout: "package", dbImport: "../index", runtime: true,
});
});

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

test("named target may override outputLayout + importBase", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,4 +244,58 @@ describe("renderEntityFile — source-aware dispatch", () => {
expect(out).not.toContain("sqliteView");
});
});

// A contract-only target (selfTarget.runtime === false) is the UI/wire-package
// axis: no server runtime bindings at all. A projection keeps its Zod read
// schema + inferred type but drops the Drizzle view; a write-through entity
// renders as its plain shape (interface + Zod) instead of a Drizzle table.
// Neither imports drizzle-orm. This replaces the old per-call includeViewDecl
// flag — the decision now lives on the target's audience, not on each artifact.
describe("contract-only target (runtime: false)", () => {
function contractCtx(root: Parameters<typeof buildPkMap>[0]) {
return makeRenderContext({
dialect: "postgres",
loadedRoot: root,
outDir: "/x",
dbImport: "~/db",
pkMap: buildPkMap(root),
relationMap: buildRelationMap(root),
selfTarget: {
name: "shared", outDir: "/x", importBase: "@pkg/shared/generated",
outputLayout: "flat", dbImport: "~/db", runtime: false,
},
});
}

test("projection: keeps Zod read schema + type, drops pgView + drizzle-orm", async () => {
const { root, projection } = await loadProjectionFixture();
const out = renderEntityFile(projection, contractCtx(root));
// contract kept:
expect(out).toContain("ProgramSummarySchema");
expect(out).toContain("export type ProgramSummary");
// runtime stripped:
expect(out).not.toContain("pgView");
expect(out).not.toContain(".existing()");
expect(out).not.toContain("drizzle-orm");
});

test("write-through entity: renders plain shape, no pgTable / drizzle-orm", async () => {
const { root, entity } = await loadVanillaFixture();
const out = renderEntityFile(entity, contractCtx(root));
// shape kept (Zod schema + a type for the entity):
expect(out).toContain("z.object");
expect(out).toContain("Post");
// runtime stripped:
expect(out).not.toContain("pgTable");
expect(out).not.toContain("drizzle-orm");
expect(out).not.toContain("InferSelectModel");
});

test("no runtime-ts allowlists in a contract target", async () => {
const { root, projection } = await loadProjectionFixture();
const out = renderEntityFile(projection, contractCtx(root));
expect(out).not.toContain("@metaobjectsdev/runtime-ts");
expect(out).not.toContain("FilterAllowlist");
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ describe("makeRenderContext — targets", () => {
const ctx = makeRenderContext({ ...baseInput, outputLayout: "package" });
expect(ctx.selfTarget).toEqual({
name: "default", outDir: "db/gen", importBase: undefined,
outputLayout: "package", dbImport: "../index",
outputLayout: "package", dbImport: "../index", runtime: true,
});
expect(ctx.entityModuleTarget).toEqual(ctx.selfTarget);
});

it("passes through explicit selfTarget + entityModuleTarget", () => {
const em: ResolvedTarget = { name: "default", outDir: "db/gen", importBase: "@mf/db/generated", outputLayout: "package", dbImport: "../index" };
const web: ResolvedTarget = { name: "web", outDir: "web/gen", importBase: undefined, outputLayout: "package", dbImport: "../index" };
const em: ResolvedTarget = { name: "default", outDir: "db/gen", importBase: "@mf/db/generated", outputLayout: "package", dbImport: "../index", runtime: true };
const web: ResolvedTarget = { name: "web", outDir: "web/gen", importBase: undefined, outputLayout: "package", dbImport: "../index", runtime: true };
const ctx = makeRenderContext({ ...baseInput, outputLayout: "package", selfTarget: web, entityModuleTarget: em });
expect(ctx.selfTarget.name).toBe("web");
expect(ctx.entityModuleTarget.importBase).toBe("@mf/db/generated");
Expand Down
Loading
Loading