Skip to content

Latest commit

 

History

History
683 lines (565 loc) · 26.3 KB

File metadata and controls

683 lines (565 loc) · 26.3 KB

TypeScript client (web)

The browser-side reference implementation: three runtime packages under client/web/packages/ consumed in the browser, plus two server-side codegen packages under server/typescript/packages/ that emit code targeting those runtimes. Together they wire React + TanStack Query + TanStack Table to any backend that speaks the MetaObjects REST contract.

Throughout this doc the worked example is an Author entity in the acme::blog package (the same Author shape used across docs/features/).

What this covers

This document covers the TypeScript client tier: the three browser packages (@metaobjectsdev/runtime-web, @metaobjectsdev/react, @metaobjectsdev/tanstack) plus the two server-side codegen packages that target them (@metaobjectsdev/codegen-ts-react, @metaobjectsdev/codegen-ts-tanstack). The client is universal: it can consume any backend (TypeScript / Java / Kotlin / C# / Python) that implements the REST URL grammar and JSON wire format defined in features/api-contract.md.

Architecture: two-package pattern

Each browser-facing framework integration ships as a pair of packages — one server-side (codegen, runs at meta gen time) and one browser-side (runtime, runs in the user's app). This mirrors Prisma (prisma + @prisma/client), Apollo (@apollo/codegen-cli + @apollo/client), and Drizzle (drizzle-kit + drizzle-orm).

Runtime side (browser):                Codegen side (server):

  @metaobjectsdev/runtime-web  ←──┐      @metaobjectsdev/codegen-ts   ←──┐
        ↑                          \            ↑                         \
        ├── @metaobjectsdev/react   │           ├── @metaobjectsdev/codegen-ts-react
        │       ↑                   │           │
        └── @metaobjectsdev/tanstack┘           └── @metaobjectsdev/codegen-ts-tanstack
                                                       (depends on codegen-ts-react)

Two disjoint dependency trees. The codegen packages live under server/typescript/packages/ because they execute server-side (Node, during meta gen), even though their output targets the browser. The runtime packages live under client/web/packages/ and have zero Node-only deps.

Angular follows the same two-package pattern and exists in-repo, source-only by decision — see "Angular 18" below. Future framework integrations (Svelte, React Native) will follow the same pattern.

Browser runtime packages

Package Purpose Key exports
@metaobjectsdev/runtime-web Pure framework-agnostic browser core. Zero React, zero TanStack, zero Node-only deps. formatCurrency, parseCurrency, minorUnitsFor, buildFilterQs, type EntityFetcher, type GridConfig
@metaobjectsdev/react React-specific runtime (peer-deps on react, react-hook-form, @hookform/resolvers, zod). useEntityForm, <CurrencyInput>, types EntityMeta, EntityFieldMeta, BoundInputProps
@metaobjectsdev/tanstack TanStack runtime (peer-deps on @tanstack/react-query, @tanstack/react-table). <EntityFetcherProvider>, useEntityFetcher, <EntityGrid>, <CellRendererProvider>, defaultCellRenderers

Codegen packages

Package Generators What it emits
@metaobjectsdev/codegen-ts-react formFile() <Entity>.form.tsx — a per-entity React form using useEntityForm + <CurrencyInput>
@metaobjectsdev/codegen-ts-tanstack tanstackQuery(), tanstackGrid(), tanstackGridHook() <Entity>.hooks.ts (5 React Query hooks) for every entity; <Entity>.columns.tsx (TanStack Table column defs) and <Entity>.grid.ts (the controlled grid state hook) for entities declaring a layout.dataGrid

Both codegen packages emit imports that target their matching runtime package. The framework-neutral @metaobjectsdev/codegen-ts engine remains the substrate (entity files, query helpers, server routes, barrel).

metaobjects.config.ts wiring

The minimal client-aware config registers entity + queries + routes + forms + tanstack hooks + grids + barrel. The apiPrefix value flows into both the route registration (server-side) and the generated hooks' fetch URLs (browser-side):

// metaobjects.config.ts
import { defineConfig } from "@metaobjectsdev/cli";
import {
  entityFile,
  queriesFile,
  routesFile,
  barrel,
} from "@metaobjectsdev/codegen-ts/generators";
import { formFile } from "@metaobjectsdev/codegen-ts-react";
import { tanstackQuery, tanstackGrid, tanstackGridHook } from "@metaobjectsdev/codegen-ts-tanstack";

export default defineConfig({
  outDir: "packages/database/src/generated",
  dialect: "postgres",
  apiPrefix: "/api",
  columnNamingStrategy: "snake_case",
  generators: [
    entityFile(),
    queriesFile(),
    routesFile(),
    formFile(),
    tanstackQuery(),
    tanstackGrid(),
    tanstackGridHook(),   // pairs with tanstackGrid — generates the controlled grid state
    barrel(),
  ],
});

For projects that want entities/routes/hooks emitted into different packages, use the targets registry (see "Per-target output directories" below).

The <EntityFetcherProvider> contract

Every generated hook (React Query) calls useEntityFetcher(), which reads a single EntityFetcher function from React context. The consumer's app supplies the concrete implementation — auth headers, base URL, error handling — and the same fetcher serves every entity.

// from @metaobjectsdev/runtime-web
export type EntityFetcher = <T>(path: string, init?: RequestInit) => Promise<T>;
// In the consumer's app root:
import { EntityFetcherProvider } from "@metaobjectsdev/tanstack";

const fetcher = async <T,>(path: string, init?: RequestInit): Promise<T> => {
  const res = await fetch(path, {
    ...init,
    credentials: "include",
    headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status} on ${path}`);
  return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
};

export function App() {
  return (
    <EntityFetcherProvider value={fetcher}>
      {/* generated hooks now have a fetcher */}
      <AuthorList />
    </EntityFetcherProvider>
  );
}

The URL grammar this fetcher must speak is defined in features/api-contract.mdGET /api/author?..., POST /api/author, etc.

Generated React forms

formFile() emits a <Entity>.form.tsx per entity. The form imports useEntityForm (React Hook Form bound to a generated Zod insert schema) and exposes a .input.<field> accessor for each field. Spread it onto an <input> element — every metadata-derived attribute (placeholder, type, aria-label, RHF rules) rides along automatically.

Metadata:

// metaobjects/meta.blog.json
{ "metadata.root": {
    "package": "acme::blog",
    "children": [
      { "object.entity": {
        "name": "Author",
        "children": [
          { "source.rdb":   { "@table": "authors" } },
          { "field.long":   { "name": "id" } },
          { "field.string": { "name": "name", "@required": true, "@maxLength": 200,
                              "children": [ { "view.text": {} } ] } },
          { "field.string": { "name": "bio",  "@maxLength": 2000,
                              "children": [ { "view.textarea": {} } ] } },
          { "identity.primary": { "@fields": "id", "@generation": "increment" } }
        ]
      }}
    ]
}}

Generated Author.form.tsx (consumer's view):

// generated/acme/blog/Author.form.tsx (excerpt)
import { useEntityForm } from "@metaobjectsdev/react";
import { Author, AuthorInsertSchema } from "./Author";

export function AuthorForm({ onSubmit }: { onSubmit: (v: AuthorInsert) => void }) {
  const form = useEntityForm(Author, AuthorInsertSchema);
  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <input {...form.input.name} />
      <textarea {...form.input.bio} />
      <button type="submit">Save</button>
    </form>
  );
}

useEntityForm returns the full React Hook Form UseFormReturn<T> surface plus .input — so handleSubmit, formState, setValue, etc. are all available. Validation runs through zodResolver against the generated AuthorInsertSchema.

Generated TanStack hooks + grids

tanstackQuery() emits <Entity>.hooks.ts per entity — 2 query hooks for projections (view-backed, read-only) and 5 hooks for full writable entities:

Hook Verb / Path
useAuthor(id) GET /api/author/:id
useAuthors(filter?) GET /api/author?filter[...]=...&sort=...&limit=N&offset=N
useCreateAuthor() POST /api/author
useUpdateAuthor() PATCH /api/author/:id
useDeleteAuthor() DELETE /api/author/:id

Each hook is React Query–native: query hooks return UseQueryResult, mutation hooks return UseMutationResult. Mutations aggressively invalidate authorKeys.all() so lists re-fetch after writes.

Grids are opt-in per entity

Hooks are emitted for every entity; grid artifacts are not. tanstackGrid() emits <Entity>.columns.tsx only for an entity that declares a layout.dataGrid child — declaring one is how you say "this entity is displayed in a grid". Wiring the grid generators and getting no grid files means exactly that, and meta gen reports it in its warnings.

Each layout.dataGrid yields a <entity><Grid>Columns (TanStack Table column defs, each carrying meta.view so the renderer registry can look up its formatter) plus a <entity><Grid>Grid (the GridConfig). The grid's name capitalizes into both, so "name": "default" on Author gives authorDefaultColumns + authorDefaultGrid.

Consumer wiring — pair tanstackGrid() with tanstackGridHook()

<EntityGrid> is fully controlled: beyond columns/grid/data it needs rowCount, a state object and three onChange callbacks. tanstackGridHook() generates that plumbing (state + the withCount=1 query) as use<Entity><Grid>Grid(), returning exactly the prop shape <EntityGrid> wants — so wire all three generators and the page is three lines:

import { EntityGrid } from "@metaobjectsdev/tanstack";
import { authorDefaultColumns, authorDefaultGrid } from "./generated/acme/blog/Author.columns";
import { useAuthorDefaultGrid } from "./generated/acme/blog/Author.grid";

export function AuthorList() {
  const grid = useAuthorDefaultGrid();   // owns sorting/pagination/filters + the query
  return <EntityGrid {...grid} columns={authorDefaultColumns} grid={authorDefaultGrid} />;
}

Owning that state yourself is supported — drive useAuthors({ sort, limit, offset, withCount: 1 }) from your own useState and pass data, rowCount, state and the three onChange callbacks by hand — but the hook exists so you don't have to.

layout.dataGrid metadata

A layout.dataGrid child on an entity declares the column order, default sort, and page size for the generated TanStack Table column file. Column keys must reference fields defined on the entity.

YAML (sigil-free):

# metaobjects/meta.blog.yaml
metadata.root:
  package: acme::blog
  children:
    - object.entity:
        name: Author
        children:
          - source.rdb: { table: authors }
          - field.long:   { name: id, filterable: true }
          - field.string: { name: name, required: true, maxLength: 200, filterable: true, sortable: true }
          - field.string: { name: bio,  maxLength: 2000 }
          - identity.primary: { fields: id, generation: increment }
          - layout.dataGrid:
              name: default
              columns: [id, name, bio]
              defaultSortField: name
              defaultSortOrder: asc
              pageSize: 25

Canonical JSON (on-disk):

{ "layout.dataGrid": {
    "name": "default",
    "@columns": ["id", "name", "bio"],
    "@defaultSortField": "name",
    "@defaultSortOrder": "asc",
    "@pageSize": 25
}}

What tanstackGrid() emits per layout.dataGrid (both in <Entity>.columns.tsx, both named from the entity + the grid's capitalized nameauthorDefaultColumns / authorDefaultGrid for "name": "default" on Author):

  • <entity><Grid>Columns — array of TanStack ColumnDef<T>, one per column key, each carrying meta.view (resolved from the field's view.* child) for the renderer registry. Omit @columns and every field on the entity becomes a column.
  • <entity><Grid>Grid — the config const carrying { name, pageSize, defaultSort?, filterable } — shape declared as GridConfig in @metaobjectsdev/runtime-web.

tanstackGridHook() adds <Entity>.grid.ts with one use<Entity><Grid>Grid() per declared grid.

Currency

field.currency is end-to-end:

  1. Storage: integer minor units (cents for USD, yen for JPY). DB column is bigint. Float arithmetic is forbidden.
  2. Wire format: integer minor units on both request and response JSON.
  3. Browser formatting: formatCurrency(cents, currency, locale) from @metaobjectsdev/runtime-web resolves the minor-unit factor via Intl.NumberFormat.resolvedOptions().minimumFractionDigits and emits the locale-formatted string. Server never formats.
  4. Form input: <CurrencyInput> from @metaobjectsdev/react is a controlled bidirectional input — focus strips symbol + grouping for editing, blur re-formats and emits cents to onChange. Float drift is bounded by a single Math.round in parseCurrency.

Metadata:

{ "field.currency": {
    "name": "priceCents",
    "@currency": "USD",
    "@required": true,
    "children": [ { "view.currency": { "@locale": "en-US" } } ]
}}

Consumer:

import { formatCurrency } from "@metaobjectsdev/runtime-web";
import { CurrencyInput } from "@metaobjectsdev/react";

formatCurrency(1599, "USD", "en-US");           // "$15.99"
formatCurrency(1599, "JPY", "ja-JP");           // "¥1,599"

<CurrencyInput value={1599} onChange={(c) => setCents(c)} currency="USD" locale="en-US" />

See features/field-types.md for the metadata-side reference (subtype, attrs, per-port mapping).

Filter + sort URL grammar

Generated useEntities hooks serialize a typed filter object to a bracketed qs URL via buildFilterQs. Server-side route handlers (TS: parseFilterParams in @metaobjectsdev/runtime-ts/drizzle-fastify) parse the qs against a generated <Entity>FilterAllowlist.

URL grammar:

?filter[<field>][<op>]=<value>&sort=<field>:asc|desc&limit=<N>&offset=<N>

Nine operators, gated by field subtype:

Operator Strings Numbers / Dates Booleans
eq, ne, isNull yes yes yes (eq + isNull)
in, like yes in only
gt, gte, lt, lte yes

A bare value is sugar for eq. withCount=1 opts into the { rows, total } list-response envelope.

Client builder:

import { buildFilterQs } from "@metaobjectsdev/runtime-web";

buildFilterQs({
  name:      { like: "Asimov%" },
  sort:      "name:asc",
  limit:     25,
  offset:    0,
  withCount: 1,
});
// "filter[name][like]=Asimov%25&sort=name:asc&limit=25&offset=0&withCount=1"

Server-side enforcement. Every request is validated against the generated <Entity>FilterAllowlist + <Entity>SortAllowlist. Unknown field, disallowed operator, or invalid value → HTTP 400 with a structured error code. Mark fields @filterable: true (and optionally @sortable: true) to opt them in. See features/api-contract.md for the cross-port grammar definition.

Per-target output directories

When entities, routes, hooks, and forms need to land in different packages (model → database package, routes → API app, hooks/forms/grids → web app), use the targets registry. Each target is { outDir, importBase?, outputLayout?, dbImport? }.

// metaobjects.config.ts (multi-target)
import { defineConfig } from "@metaobjectsdev/cli";
// Owned generators scaffolded by `meta init` (ADR-0034 scaffold-and-own).
import { entityFile } from "./codegen/generators/entity";
import { queriesFile } from "./codegen/generators/queries";
import { routesFile } from "./codegen/generators/routes";
import { barrel } from "./codegen/generators/barrel";
import { formFile } from "@metaobjectsdev/codegen-ts-react";
import { tanstackQuery, tanstackGrid, tanstackGridHook } from "@metaobjectsdev/codegen-ts-tanstack";

export default defineConfig({
  outDir: "packages/database/src/generated",            // implicit "default" (entity module)
  apiPrefix: "/api",
  targets: {
    web: { outDir: "apps/web/src/generated" },
    api: { outDir: "apps/api/src/generated" },
  },
  // default target needs importBase so cross-target imports resolve:
  entityModuleImportBase: "@acme/database/generated",
  generators: [
    entityFile(),                                       // default target (database package)
    queriesFile(),                                      // default target
    routesFile({       target: "api" }),                // API app
    formFile({         target: "web" }),                // web app
    tanstackQuery({    target: "web" }),                // web app
    tanstackGrid({     target: "web" }),                // web app
    tanstackGridHook({ target: "web" }),                // web app (sibling of .columns)
    barrel(),
  ],
});

Cross-target references to the entity module are emitted as extension-less importBase package paths (@acme/database/generated/acme/blog/Author); same-target references stay relative. With no targets, output is byte-identical to a single-outDir project.

Full config reference: @metaobjectsdev/cli README, "Multiple output targets".

Cell renderer overrides

<EntityGrid> routes cell rendering through CellRendererProvider, keyed by the column's meta.view (a string set by codegen from the field's view.* child). Defaults are text / textarea / number / date / datetime / boolean / currency / dropdown / password. Override per-key without touching generated code:

import { CellRendererProvider, EntityGrid } from "@metaobjectsdev/tanstack";

<CellRendererProvider value={{
  // app-specific link style for emails
  text: (ctx) => <a href={`mailto:${ctx.getValue()}`}>{String(ctx.getValue() ?? "")}</a>,
  // tenant-specific currency locale
  currency: (ctx) => formatCurrency(ctx.getValue() as number, "EUR", "fr-FR"),
}}>
  <EntityGrid {...gridProps} />
</CellRendererProvider>

Per-column cell always wins; the provider only fills in when a column has no cell set.

Per-entity opt-out

Mark an entity with @emitTanstack: false and tanstackQuery() + tanstackGrid() will skip it. The entity-file + query-file + route-file generators still run unless they have their own opt-out.

{ "object.entity": {
    "name": "InternalAudit",
    "@emitTanstack": false,
    "children": [ ... ]
}}

Status across backends

The TS client is universal: it consumes any backend that implements the URL grammar + wire format in features/api-contract.md. All five ports (TS, Java, Kotlin, C#, Python) ship route codegen today, including the filter/sort querystring — the consumer does not need to hand-write a controller on any port. See features/api-contract.md for the per-port route-codegen status table (hand-written examples are also there, for the rare case where you need a shape the generator doesn't cover).

Angular 18

NOT PUBLISHED — source-only by decision (ADR-0048). The two @metaobjectsdev/angular* packages build in-repo on their own 0.6.x line, and neither has ever been released to npmnpm i @metaobjectsdev/angular returns a 404. This is a deliberate position, not a pending release: the tier is below the published tier's bar — the runtime grid lacks the TanStack tier's sorting/pagination and working cell-renderer dispatch, form codegen predates the view-kind dispatch (0.18.0) and image (0.19.0) feature lines, and the runtime behavioral suite cannot execute under the repo's Bun toolchain (Angular's standard decorators need the Angular linker). The ADR carries the full promotion checklist; until it is met, everything documented below is accurate about the code and reachable by building from client/web/packages/angular/ + server/typescript/packages/codegen-ts-angular/ and consuming them as local workspace packages, not by installing from the registry. The React + TanStack tier is the published browser client.

Angular 18 ships as a second universal browser-side client tier alongside the React + TanStack pair. Same architecture — one runtime package (@metaobjectsdev/angular) plus one codegen package (@metaobjectsdev/codegen-ts-angular). Same universality: any backend implementing the REST contract serves it.

Two new packages

Package Purpose Key exports
@metaobjectsdev/angular Angular 18 runtime — standalone components, signals, peer-deps on @angular/core, @angular/common, @angular/forms, @tanstack/angular-table EntityFetcherToken, provideEntityFetcher, <mo-currency-input> (CurrencyInputComponent), <mo-entity-grid> (EntityGridComponent), CellRendererRegistry, type EntityGridColumn; re-exports EntityFetcher, GridConfig, formatCurrency, parseCurrency, buildFilterQs from runtime-web
@metaobjectsdev/codegen-ts-angular Angular codegen — emits standalone components + signal-based services angularServiceFile(), angularFormFile(), angularGridFile(), barrel()

Per-entity opt-out: mark an entity with @emitAngular: false and all three Angular outputs are skipped.

Wiring provideEntityFetcher in app.config.ts

Angular DI replaces React context as the fetcher transport. The same EntityFetcher shape (<T>(path, init?) => Promise<T>) flows through the EntityFetcherToken injection token.

// app.config.ts
import { ApplicationConfig } from "@angular/core";
import { provideZoneChangeDetection } from "@angular/core";
import { provideRouter } from "@angular/router";
import { provideEntityFetcher } from "@metaobjectsdev/angular";
import { routes } from "./app.routes";

const fetcher = async <T,>(path: string, init?: RequestInit): Promise<T> => {
  const res = await fetch(path, {
    ...init,
    credentials: "include",
    headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status} on ${path}`);
  return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
};

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideEntityFetcher(fetcher),
  ],
};

End-to-end: Author entity → service + form + grid

Metadata (same Author entity as the React examples above):

// metaobjects/meta.blog.json
{ "metadata.root": {
    "package": "acme::blog",
    "children": [
      { "object.entity": {
        "name": "Author",
        "children": [
          { "source.rdb":   { "@table": "authors" } },
          { "field.long":   { "name": "id" } },
          { "field.string": { "name": "name", "@required": true, "@maxLength": 200 } },
          { "field.string": { "name": "bio",  "@maxLength": 2000 } },
          { "field.boolean":{ "name": "active" } },
          { "identity.primary": { "@fields": "id", "@generation": "increment" } },
          { "layout.dataGrid": {
              "name": "default",
              "@columns": ["name", "bio", "active"],
              "@defaultSortField": "name",
              "@defaultSortOrder": "asc",
              "@pageSize": 25
          }}
        ]
      }}
    ]
}}

metaobjects.config.ts adding the Angular generators:

import { defineConfig } from "@metaobjectsdev/cli";
// Owned generators scaffolded by `meta init` (ADR-0034 scaffold-and-own).
import { entityFile } from "./codegen/generators/entity";
import { queriesFile } from "./codegen/generators/queries";
import { routesFile } from "./codegen/generators/routes";
import { barrel } from "./codegen/generators/barrel";
import {
  angularServiceFile,
  angularFormFile,
  angularGridFile,
  barrel as angularBarrel,
} from "@metaobjectsdev/codegen-ts-angular";

export default defineConfig({
  outDir: "packages/database/src/generated",
  apiPrefix: "/api",
  targets: {
    angular: { outDir: "apps/angular-web/src/app/generated" },
  },
  entityModuleImportBase: "@acme/database/generated",
  generators: [
    entityFile(),
    queriesFile(),
    routesFile(),
    angularServiceFile({ target: "angular" }),
    angularFormFile({    target: "angular" }),
    angularGridFile({    target: "angular" }),
    angularBarrel({      target: "angular" }),
    barrel(),
  ],
});

Consumer's Angular component composing the generated service + grid:

// app.component.ts
import { Component, inject, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { AuthorGridComponent, AuthorService } from "./generated/acme/blog";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule, AuthorGridComponent],
  template: `<author-grid #grid />`,
})
export class AppComponent implements OnInit {
  private readonly authors = inject(AuthorService);

  async ngOnInit() {
    const rows = await this.authors.list({ limit: 25, sort: "name:asc" });
    // hand the rows to the grid component (in real code, signal binding or store)
  }
}

See also

  • docs/recipes/csharp-angular18.md — end-to-end recipe for wiring an ASP.NET Minimal API backend (C# 12 / .NET 8) to an Angular 18 client built with these packages; covers CORS, dev-server ports, and base-URL configuration.

See also

  • docs/features/api-contract.md — the cross-port REST contract this client speaks
  • docs/features/field-types.md — field subtype reference (currency, enum, etc.) shared across ports
  • docs/ports/typescript.md — server-side TypeScript port (entities, queries, routes, OMDB-style runtime)
  • CLAUDE.md — sections "TS package layout", "Codegen architecture", "Filter syntax + sort (Project D)", "Source-aware entities + projections (Project E)", "Currency (Project F)", and "TanStack codegen + metadata-driven grids (Project B)" for the authoritative design notes