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
100 changes: 100 additions & 0 deletions client/web/packages/runtime-web/src/grid-from-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Metadata-driven grid — the runtime twin of the `tanstackGrid()` codegen.
//
// Builds grid columns + config by walking a loaded MetaObject's fields and
// views at RUNTIME. Fully generic: no object or field names are hardcoded, so
// one call renders any object you've described in metadata. Pass any MetaObject
// (e.g. obtained from a loader, or via a MetaObjectAware row's getMetaData()).
//
// Mirrors the derivation in codegen-ts-tanstack's columns-file (humanized
// headers, the field's view subtype as the cell-renderer hint, @columns / grid
// attrs from the dataGrid layout) so a runtime-built grid matches a generated
// one. Browser-safe: depends only on @metaobjectsdev/metadata.
import type { MetaObject, MetaField, MetaView } from "@metaobjectsdev/metadata";
import {
LAYOUT_SUBTYPE_DATA_GRID,
LAYOUT_DATA_GRID_ATTR_COLUMNS,
LAYOUT_DATA_GRID_ATTR_PAGE_SIZE,
LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_FIELD,
LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_ORDER,
LAYOUT_DATA_GRID_ATTR_FILTERABLE,
} from "@metaobjectsdev/metadata";
import type { GridConfig } from "./fetcher.js";

const DEFAULT_PAGE_SIZE = 25;
const DEFAULT_GRID_NAME = "default";
// The view's display-label attr. Mirrors the literal used by the columns-file
// codegen; there is no exported constant for it in @metaobjectsdev/metadata.
const VIEW_ATTR_LABEL = "label";

/** A neutral, framework-agnostic column descriptor derived from metadata. */
export interface MetaColumn {
/** The field's logical name (the data accessor key). */
field: string;
/** Display header — the field view's `label`, else the humanized field name. */
header: string;
/** Cell-renderer hint — the field's view subtype, else the field subtype. */
viewKind: string;
}

export interface MetaGrid {
config: GridConfig;
columns: MetaColumn[];
}

/** camelCase / PascalCase → "Title Case" (matches the codegen `humanize`). */
function humanize(s: string): string {
return s.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase());
}

function firstView(field: MetaField): MetaView | undefined {
return field.ownViews()[0];
}

function header(field: MetaField): string {
const label = firstView(field)?.ownAttr(VIEW_ATTR_LABEL);
return typeof label === "string" ? label : humanize(field.name);
}

function viewKind(field: MetaField): string {
return firstView(field)?.subType ?? field.subType;
}

/**
* Build a grid (config + columns) from a MetaObject at runtime.
*
* If the object declares a `dataGrid` layout (optionally selected by `gridName`),
* its `@columns`, `@pageSize`, sort and `@filterable` attrs drive the result.
* Otherwise every field becomes a column with sensible defaults — so it works
* for ANY object, with or without a declared grid.
*/
export function buildGrid(meta: MetaObject, gridName?: string): MetaGrid {
const layouts = meta.layouts().filter((l) => l.subType === LAYOUT_SUBTYPE_DATA_GRID);
const layout = gridName ? layouts.find((l) => l.name === gridName) : layouts[0];

const fieldsByName = new Map(meta.fields().map((f) => [f.name, f] as const));

const columnsAttr = layout?.ownAttr(LAYOUT_DATA_GRID_ATTR_COLUMNS);
const names: string[] = Array.isArray(columnsAttr)
? columnsAttr.filter((x): x is string => typeof x === "string")
: meta.fields().map((f) => f.name);

const columns: MetaColumn[] = names
.map((n) => fieldsByName.get(n))
.filter((f): f is MetaField => f !== undefined)
.map((f) => ({ field: f.name, header: header(f), viewKind: viewKind(f) }));

const pageSizeAttr = layout?.ownAttr(LAYOUT_DATA_GRID_ATTR_PAGE_SIZE);
const sortField = layout?.ownAttr(LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_FIELD);
const sortOrder = layout?.ownAttr(LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_ORDER);

const config: GridConfig = {
name: layout?.name ?? gridName ?? DEFAULT_GRID_NAME,
pageSize: typeof pageSizeAttr === "number" ? pageSizeAttr : DEFAULT_PAGE_SIZE,
filterable: layout?.ownAttr(LAYOUT_DATA_GRID_ATTR_FILTERABLE) === true,
...(typeof sortField === "string"
? { defaultSort: { field: sortField, order: sortOrder === "desc" ? "desc" : "asc" } }
: {}),
};

return { config, columns };
}
2 changes: 2 additions & 0 deletions client/web/packages/runtime-web/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
export { formatCurrency, parseCurrency, minorUnitsFor } from "./currency.js";
export { buildFilterQs } from "./filter-qs.js";
export type { EntityFetcher, GridConfig } from "./fetcher.js";
export { buildGrid } from "./grid-from-metadata.js";
export type { MetaColumn, MetaGrid } from "./grid-from-metadata.js";
80 changes: 80 additions & 0 deletions client/web/packages/runtime-web/test/grid-from-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, test, expect } from "bun:test";
import {
MetaDataLoader,
InMemoryStringSource,
type MetaObject,
} from "@metaobjectsdev/metadata";
import { buildGrid } from "../src/grid-from-metadata.js";

async function loadObject(doc: unknown, name: string): Promise<MetaObject> {
const loader = new MetaDataLoader();
const { errors } = await loader.load([
new InMemoryStringSource(JSON.stringify(doc), { id: "grid-test.json" }),
]);
if (errors.length) throw new Error(`load errors: ${JSON.stringify(errors)}`);
return loader.findByTypeAndName("object", name) as MetaObject;
}

const SUBSCRIBER = (extraChildren: unknown[] = []) => ({
"metadata.root": {
package: "demo",
children: [
{ "object.entity": { name: "Subscriber", children: [
{ "field.long": { name: "id" } },
{ "field.string": { name: "firstName" } },
{ "field.boolean": { name: "subscribed", "@required": true } },
{ "field.enum": { name: "status", "@values": ["ACTIVE", "PENDING"] } },
{ "identity.primary": { name: "id", "@fields": "id" } },
...extraChildren,
] } },
],
},
});

describe("buildGrid (metadata-driven, generic)", () => {
test("no dataGrid layout → every field becomes a column, headers humanized", async () => {
const meta = await loadObject(SUBSCRIBER(), "Subscriber");
const { config, columns } = buildGrid(meta);

expect(columns.map((c) => c.field)).toEqual(["id", "firstName", "subscribed", "status"]);
expect(columns.map((c) => c.header)).toEqual(["Id", "First Name", "Subscribed", "Status"]);
// cell-renderer hint falls back to the field subtype when no view is declared
expect(columns.map((c) => c.viewKind)).toEqual(["long", "string", "boolean", "enum"]);
expect(config).toEqual({ name: "default", pageSize: 25, filterable: false });
});

test("dataGrid layout drives column set, order, page size, sort, filterable", async () => {
const grid = { "layout.dataGrid": {
name: "default",
"@columns": ["firstName", "status"],
"@pageSize": 10,
"@defaultSortField": "firstName",
"@defaultSortOrder": "desc",
"@filterable": true,
} };
const meta = await loadObject(SUBSCRIBER([grid]), "Subscriber");
const { config, columns } = buildGrid(meta);

expect(columns.map((c) => c.field)).toEqual(["firstName", "status"]);
expect(config).toEqual({
name: "default",
pageSize: 10,
filterable: true,
defaultSort: { field: "firstName", order: "desc" },
});
});

test("is generic — the SAME call works on a different object with no shared names", async () => {
const doc = { "metadata.root": { package: "demo", children: [
{ "object.entity": { name: "Invoice", children: [
{ "field.long": { name: "id" } },
{ "field.currency": { name: "amountDue", "@currency": "USD" } },
{ "identity.primary": { name: "id", "@fields": "id" } },
] } },
] } };
const meta = await loadObject(doc, "Invoice");
const { columns } = buildGrid(meta);
expect(columns.map((c) => c.field)).toEqual(["id", "amountDue"]);
expect(columns.map((c) => c.header)).toEqual(["Id", "Amount Due"]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Runtime metadata-driven grid — Slice 1 design (list + sort + pagination, no codegen)

> Status: design. Written 2026-06-16. Slice 1 of a larger program: a fully runtime,
> metadata-driven data grid — the **runtime twin of the codegen grid stack** (`tanstackGrid`
> columns + grid hook + `mountCrudRoutes`), driven entirely from a loaded `MetaObject` with
> **no code generation** on either the server or the client.

## 1. Motivation

Today the full-featured grid exists only through **codegen**: you run `meta gen`, which emits
`<Entity>.columns.tsx`, a `use<Entity>Grid()` hook, and per-entity Fastify CRUD routes, all
consumed by the shipped `<EntityGrid>` (a controlled, server-driven TanStack Table with sort,
pagination, filtering, search, and cell rendering via `CellRendererProvider`).

MetaObjects' thesis is "one model, two delivery modes — generate the code, **or** drive behavior
live from the model at runtime." The grid is currently codegen-only. This program builds the
**runtime delivery mode**: load a `MetaObject` and get the same grid, with no generated code.

## 2. The keystone: codegen ≡ metadata, proven by the shared corpus

The codegen REST API is **already cross-port** — TS Fastify, Java & Kotlin Spring, C# ASP.NET
Minimal API, Python FastAPI — and every port is gated by the shared, language-agnostic
**`fixtures/api-contract-conformance/`** corpus (URL grammar + wire format: list, pagination,
`withCount`, sort, get-by-id, CRUD, filter operators, invalid-sort/field/op `400`s). Per-port
runners boot a real HTTP server and assert each response matches.

The runtime metadata-driven endpoint is therefore **not a new API** — it is a **new lane against
the same corpus**, joining the two lanes that already run per port (a hand-rolled reference
server and the generated artifact). Passing the identical scenarios is the proof that the
**codegen approach and the metadata approach answer byte-identically**. Because each port's
codegen API uses that port's idiomatic framework, the metadata endpoint mirrors it per port —
**for TS that is Fastify**, mirroring `mountCrudRoutes` so the two are swappable.

Slice 1 ships the **TypeScript** instance. Cross-port replication (Java/Kotlin/C#/Python) is
mechanical follow-up work against the same corpus and is out of scope here.

## 3. Goals (Slice 1)

A minimal **end-to-end** vertical slice that proves the no-codegen architecture:

- **Server:** a generic, metadata-driven list endpoint — `GET /<plural>?sort=f:dir&limit&offset`
(+ `withCount`) — returning `{ rows, total }`, with sort validated against metadata.
- **Client:** build TanStack columns from a `MetaObject` and a `useMetaGrid` hook that owns
sort + pagination state and queries the endpoint, feeding the existing `<EntityGrid>`.
- **Proof:** the endpoint passes the api-contract-conformance Slice-1 scenarios — the same ones
the generated Fastify routes pass.

## 4. Non-goals (deferred to later slices)

- Filtering, the per-field **operator allowlist** (`eq/ne/gt/gte/lt/lte/in/like/isNull`), and
filter `400`s (`filter-invalid-field/op`).
- Free-text search; `layout.dataGrid` **`@filter` presets**.
- Metadata-driven **writes** (create/update/delete).
- Cross-port replication of the endpoint.
- Projections/views read paths beyond what `ObjectManager.findMany` already supports.

## 5. Architecture & components

Each unit is small, single-purpose, and independently testable.

### 5.1 `sortableFields(meta: MetaObject): Set<string>` — runtime-ts (minimal Piece 0)
Derives the sortable field set from metadata: a field is sortable when it carries `@sortable`
(which, per the authoring convention, defaults to `@filterable`). This is the runtime
replacement for the codegen-generated `SortAllowlist`. The full filter-operator allowlist
(per-subtype operator bands) is deferred to Slice 2; only sortability is needed here.

### 5.2 `handleList(meta, query, om): Promise<{ rows; total }>` — runtime-ts (neutral core)
Framework-agnostic. Inputs: the resolved `MetaObject`, a parsed query
(`{ sort?, limit?, offset?, withCount? }`), and an `ObjectManager`. Behavior:
1. Parse `sort` (`"field:asc|desc"`) → validate the field against `sortableFields(meta)`;
an unknown/disallowed sort field throws a typed error mapped to **HTTP 400** with the
**same error code/shape the codegen route emits** (`invalid-sort-400` parity).
2. Apply `limit`/`offset` (defaults mirror the codegen route).
3. `om.findMany(entityName, undefined, { orderBy, limit, offset })`; if `withCount`,
`om.count(entityName)` → `total` (else `total` omitted, matching the opt-in envelope).
Returns `{ rows, total? }`. No HTTP, no framework — unit-testable directly.

### 5.3 `mountMetaCrudRoutes(fastify, { loader, objectManager, apiPrefix })` — runtime-ts (Fastify adapter)
The runtime twin of `mountCrudRoutes`. Iterates the loader's entities and mounts
`GET <apiPrefix>/<plural>` for each, parsing the request's querystring and delegating to
`handleList`. Maps the typed sort error → `400`. Read/list only in Slice 1. The public shape
mirrors `mountCrudRoutes` so a consumer can swap codegen routes for metadata routes.

### 5.4 `buildColumns(meta: MetaObject, gridName?): ColumnDef<Row>[]` — tanstack
Adapts the neutral `buildGrid()` (`@metaobjectsdev/runtime-web`, already shipped) into TanStack
`ColumnDef[]`: `accessorKey`/`id` = field name, `header` = the metadata header, `meta.view` =
the field's view subtype (drives `CellRendererProvider`), `enableSorting` from `sortableFields`.

### 5.5 `useMetaGrid(meta, fetcher, gridName?)` — tanstack (React hook)
The runtime twin of the generated grid hook. Owns `{ sorting, pagination }` state, derives the
grid config via `buildGrid()`, serializes the query with `buildFilterQs` (+ `withCount=1`),
calls the `fetcher`, and returns the controlled `<EntityGrid>` prop shape
(`columns`, `grid`, `data`, `rowCount`, `state`, `onSortingChange`, `onPaginationChange`, …).
`columnFilters`/`search` are wired as no-ops in Slice 1 (filtering arrives in Slice 2).

### 5.6 Consumer shape (the payoff)
```ts
const grid = useMetaGrid(subscriberMeta, fetcher);
return <EntityGrid {...grid} />; // sortable, paged grid — no generated code
```

## 6. Data flow

```
loaded MetaObject ──► buildColumns / buildGrid ──► ColumnDef[] + GridConfig
│ │
useMetaGrid (sort+page state) ──buildFilterQs──► GET /<plural>?sort&limit&offset&withCount
│ │
│ mountMetaCrudRoutes ─► handleList ─► ObjectManager
│ │ findMany + count
└────────────── <EntityGrid> ◄──── { rows, total } ─┘ (sortableFields validates sort)
```

## 7. Testing (TDD)

- **Keystone (integration):** boot `mountMetaCrudRoutes` over HTTP backed by the **in-memory
`ObjectManager` driver** seeded from the corpus `seed.json`, and run the api-contract-conformance
Slice-1 scenarios (`list-empty`, `list-with-pagination`, `list-with-withcount`, `sort-asc-desc`,
`invalid-sort-400`) against the corpus `meta.json` `Author` entity. Same scenarios the generated
Fastify lane passes → codegen ≡ metadata.
- **Unit:** `sortableFields` (derivation incl. the `@sortable`←`@filterable` default);
`handleList` (sort applied, pagination math, `total` present iff `withCount`, invalid-sort
throws the typed error); `buildColumns` (`ColumnDef` shape, `meta.view`, `enableSorting`);
`useMetaGrid` (state transitions + query string + envelope handling).

## 8. Error handling

Invalid sort field → typed error in `handleList` → **HTTP 400** with the same structured error
code the codegen route returns. The corpus `invalid-sort-400` scenario enforces the parity.

## 9. File placement

- `server/typescript/packages/runtime-ts/src/metadata-routes/` — `sortable-fields.ts`,
`handle-list.ts`, `mount-meta-crud.ts` (Fastify adapter) + index export.
- `client/web/packages/tanstack/src/` — `build-columns.ts`, `use-meta-grid.tsx` + index exports.
- Reuses (no change): `@metaobjectsdev/runtime-web` `buildGrid`/`buildFilterQs`/`GridConfig`,
`@metaobjectsdev/tanstack` `<EntityGrid>` + `CellRendererProvider`, `ObjectManager`,
in-memory driver, and `fixtures/api-contract-conformance/`.

## 10. Risks / open items

- **Plural/route naming:** the generic mount must derive the same `<plural>` path the codegen
route uses (entity `$path`). Reuse the existing path derivation so the corpus URLs match.
- **Envelope parity:** `withCount` on/off must match the codegen envelope exactly (the corpus
`list-with-withcount` vs `list-empty` scenarios enforce this).
- **In-memory driver coverage:** confirm the in-memory `ObjectManager` driver supports `orderBy`
+ `count` for the corpus `Author` entity; if a gap appears, close it as part of Slice 1.

## 11. Program context — the full runtime UI (to match the product video)

The video promises a runtime, no-codegen admin UI of **views · forms · grids · validators**. That
is two tracks off the same metadata-driven endpoint, both proven against `api-contract-conformance`:

**Read path (grids):**
- **Slice 1 (this spec):** list + sort + pagination.
- **Slice 2:** filtering + the per-field operator allowlist (runtime Piece 0 full) + filter `400`s,
gated by the corpus `filter-*` scenarios.
- **Slice 3:** free-text search + `layout.dataGrid` `@filter` presets.

**Write path (forms + validation):**
- **Slice 4 — runtime writes endpoint:** metadata-driven `POST`/`PATCH`/`PUT`/`DELETE` via
`ObjectManager`, validated by the **existing** `runtime-ts/validator-runner` (required/length/regex
from metadata — already shipped server-side). Gated by the corpus `create-201`,
`update-patch-and-put`, `delete-204-and-404` scenarios.
- **Slice 5 — runtime form:** `buildForm(meta)` (visible fields + input kind by field/view subtype,
mirroring the `form-file` codegen) + `useMetaForm(meta, fetcher)` hook (state + submit), with a
**browser-safe validator** (the `validator-runner` logic ported to `runtime-web`) so forms
validate from metadata instead of the generated Zod schema.
- **Slice 6 — field-input renderers:** a `FieldInputProvider` registry (the form analog of the
existing `CellRendererProvider`), keyed by field/view subtype, so inputs render at runtime.

**Cross-cutting:** Slice 7+ replicates the endpoint per port (Java/Kotlin Spring, C# ASP.NET,
Python FastAPI) against the same corpus. (The client grid/form are TS-only — the browser is
TS-native; the cross-port obligation is the **server contract**, which the shared corpus enforces.)

"Views" are largely in place already (cell rendering via `CellRendererProvider`, field view
subtypes); Slice 6 adds the input side.
Loading