@metaobjectsdev/sdk 0.21.3 → 0.21.4-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -719,8 +719,56 @@ Multi-source: multiple `source.rdb` children, each with a `@role`, exactly one
719
719
  ```
720
720
 
721
721
  **An entity's PRIMARY source must be writable** (`table`) — read-only kinds are
722
- legal only in non-primary roles (e.g. table `primary` + view `replica` for
723
- read-through). A derived read model over a view/proc is an **`object.projection`**
722
+ legal only in non-primary roles.
723
+
724
+ ### Derived/computed columns: reach for an ENTITY READ-VIEW first
725
+
726
+ **Do not default to `object.projection` for a list screen, grid, or "one extra
727
+ column" read.** The usual need — an entity's own rows plus a joined display name or a
728
+ per-row count, filterable and sortable like any other column — is an **entity
729
+ read-view**, not a projection:
730
+
731
+ ```jsonc
732
+ { "object.entity": { "name": "Order", "children": [
733
+ { "source.rdb": { "@kind": "table", "@table": "orders" } }, // writes
734
+ { "source.rdb": { "@kind": "view", "@view": "v_order", "@role": "replica" } }, // reads
735
+ // …the entity's own fields stay as they are — you re-state NOTHING…
736
+ { "field.string": { "name": "customerName", "@filterable": true, "children": [
737
+ { "origin.passthrough": { "@from": "Customer.name" } } ]}},
738
+ { "field.int": { "name": "itemCount", "@filterable": true, "children": [
739
+ { "origin.aggregate": { "@agg": "count", "@of": "OrderItem.id", "@via": "Order.items" } } ]}}
740
+ ]}}
741
+ ```
742
+
743
+ That is the whole change. Writes route to the table (derived fields are excluded from
744
+ the write codecs); reads route to the view; `meta migrate` emits the `CREATE VIEW` from
745
+ the **same assembly logic** a projection view uses — one emitter, two hosts (FR-024 §7,
746
+ #213/#214). Filtering and sorting on `customerName` / `itemCount` work like any other
747
+ column because the filter tier runs against the view.
748
+
749
+ **Reach for a projection only when one of these is true** (from
750
+ `docs/features/source-kinds.md`, which carries the full decision table):
751
+
752
+ | Entity read-view | Projection |
753
+ |---|---|
754
+ | extras sit over the entity's **own** table | an independent **exposure contract** |
755
+ | same trust domain — a new entity field showing up in the view is correct | a subset, **renamed** columns, versioned, or external consumers |
756
+ | the base is still INSERTable and is the record of truth | keyless, multi-base, proc-backed, all-derived, or borrowed identity |
757
+
758
+ Two things genuinely force a projection: **renamed base columns** (a field carries one
759
+ `@column` per paradigm) and **row-filtered views** (soft-delete / `WHERE status='active'`
760
+ — an entity read-view exposes the whole entity; only a projection carries a row-scope
761
+ `@filter`, #207).
762
+
763
+ Choosing a projection when a read-view would do costs a second URL, a second type, a
764
+ second identity declaration, and re-stating every passthrough column — for no gain.
765
+
766
+ > Historical note for anyone porting older advice: before 0.17.0 (2026-07-18) an entity
767
+ > hosting a derived field silently produced no view and read the wrong table, so a
768
+ > projection genuinely WAS the only way to get a grid with a derived column. That is
769
+ > fixed; guidance written before then is stale.
770
+
771
+ A derived read model that IS an independent exposure is an **`object.projection`**
724
772
  (FR-024): its fields `extends` entity fields (`extends: "Author.id"` — dotted
725
773
  child traversal, package only on the root segment) and/or carry `origin.*`
726
774
  children (`passthrough` / `aggregate` / `collection` / `computed` / `first`)
@@ -125,6 +125,24 @@ fetcher-provider at the tree root; every generated hook reads it from context. T
125
125
  generated grid and form components, filter-qs serializer, and cell renderers all
126
126
  sit on top of this one seam.
127
127
 
128
+ ## Grids are opt-in per entity
129
+
130
+ Read/CRUD hooks are generated for **every** entity. Grids are the exception:
131
+ wherever your stack generates them (the TanStack client today), an entity produces
132
+ grid artifacts only when it declares a `layout.dataGrid` child. So wiring the grid
133
+ generator and seeing no grid files is expected metadata, not a broken build — the
134
+ run says so in its `meta gen` warnings. Opt an entity in with:
135
+
136
+ ```jsonc
137
+ { "layout.dataGrid": { "name": "default", "@columns": ["name", "email"] } }
138
+ ```
139
+
140
+ Generated grid components are fully **controlled** — they need row-count and
141
+ sort/pagination/filter state on top of the column definitions — so pair the columns
142
+ generator with the grid-**hook** generator, which generates that state plumbing
143
+ instead of leaving you to hand-write it. Your client's reference fragment has the
144
+ generator names and a rendered example.
145
+
128
146
  ---
129
147
 
130
148
  For this project's runtime + web-client specifics, read every `references/*.md` file in this skill's directory (one per server language and client framework in this project's stack).
@@ -4,14 +4,15 @@
4
4
  hooks + a TanStack Table grid component. Like the React client it is **universal**:
5
5
  it consumes any backend (TS / Java / Kotlin / C# / Python) that speaks the
6
6
  cross-port REST contract. It pairs with `codegen-ts-tanstack`, which emits
7
- `<Entity>.hooks.ts` and `<Entity>.columns.tsx` that import from this package.
7
+ `<Entity>.hooks.ts`, `<Entity>.columns.tsx` and `<Entity>.grid.ts` that import from
8
+ this package.
8
9
 
9
10
  ## Contents
10
11
  - Install
11
12
  - Key exports
12
13
  - The `EntityFetcher` contract
13
- - Generated hooks (`tanstackQuery()`)
14
- - Generated grid (`tanstackGrid()`)
14
+ - Generated hooks (`tanstackQuery()`) — every entity
15
+ - Generated grid (`tanstackGrid()` + `tanstackGridHook()`) — **opt-in per entity**
15
16
  - Cell renderer overrides
16
17
 
17
18
  ## Install
@@ -84,23 +85,68 @@ projections):
84
85
  Query hooks return `UseQueryResult`; mutation hooks return `UseMutationResult` and
85
86
  invalidate the entity's query keys so lists re-fetch after writes.
86
87
 
87
- ## Generated grid (`tanstackGrid()`)
88
+ ## Generated grid (`tanstackGrid()`) — **opt-in per entity**
89
+
90
+ Grid artifacts are the one generator pair that is **not** emitted for every entity.
91
+ `tanstackGrid()` emits `<Entity>.columns.tsx` **only for an entity that declares a
92
+ `layout.dataGrid` child**; an entity without one gets its `.hooks.ts` and no
93
+ columns file at all. That is intended — a grid is a presentation decision about a
94
+ particular entity, so declaring one is how you say "this entity is displayed in a
95
+ grid"; emitting columns for every entity in the model would be noise. A run that
96
+ skips grids for this reason says so in its `meta gen` warnings.
97
+
98
+ So the minimum to get a grid is a `layout.dataGrid` on the entity:
99
+
100
+ ```jsonc
101
+ { "object.entity": { "name": "Author", "children": [
102
+ // ...fields...
103
+ { "layout.dataGrid": {
104
+ "name": "default",
105
+ "@columns": ["name", "email", "createdAt"], // ordered; omit for every field
106
+ "@pageSize": 25,
107
+ "@defaultSortField": "createdAt",
108
+ "@defaultSortOrder": "desc"
109
+ }}
110
+ ]}}
111
+ ```
112
+
113
+ One `layout.dataGrid` → one pair of generated consts, named
114
+ `<entity><Grid>Columns` (the `ColumnDef<T>[]`, each carrying `meta.view` for the
115
+ renderer registry) and `<entity><Grid>Grid` (the `GridConfig`). The grid's `name`
116
+ is capitalized into both, so `"name": "default"` on `Author` yields
117
+ `authorDefaultColumns` + `authorDefaultGrid`. Declare several named grids on one
118
+ entity and you get several pairs.
88
119
 
89
- Emits `<Entity>.columns.tsx` from the entity's `layout.dataGrid` child — TanStack
90
- `ColumnDef<T>[]`, each carrying `meta.view` for the renderer registry. Render with
91
- `<EntityGrid>`:
120
+ ### Rendering: pair it with `tanstackGridHook()`
121
+
122
+ `<EntityGrid>` is **fully controlled** — beyond `columns`/`grid`/`data` it also
123
+ requires `rowCount`, a `state` object, and three `onChange` callbacks. Wiring that
124
+ by hand (sorting + pagination + column filters + the `withCount=1` query and its
125
+ `buildFilterQs` serialization) is a page of boilerplate that the metadata already
126
+ describes, so **`tanstackGridHook()` generates it**: add it to the config and each
127
+ grid gets a `use<Entity><Grid>Grid()` returning exactly the prop shape
128
+ `<EntityGrid>` wants.
129
+
130
+ ```ts
131
+ // metaobjects.config.ts
132
+ generators: [entityFile(), tanstackQuery(), tanstackGrid(), tanstackGridHook()],
133
+ ```
92
134
 
93
135
  ```tsx
94
- import { useAuthors } from "./generated/Author.hooks";
95
- import { authorColumns } from "./generated/Author.columns";
96
136
  import { EntityGrid } from "@metaobjectsdev/tanstack";
137
+ import { authorDefaultColumns, authorDefaultGrid } from "./generated/Author.columns";
138
+ import { useAuthorDefaultGrid } from "./generated/Author.grid";
97
139
 
98
- const { data } = useAuthors({ sort: "name:asc", limit: 25, offset: 0, withCount: 1 });
99
- <EntityGrid columns={authorColumns} data={data?.rows ?? []} rowCount={data?.total ?? 0} />
140
+ export function AuthorList() {
141
+ const grid = useAuthorDefaultGrid(); // owns sorting/pagination/filters + the query
142
+ return <EntityGrid {...grid} columns={authorDefaultColumns} grid={authorDefaultGrid} />;
143
+ }
100
144
  ```
101
145
 
102
- `tanstackGridHook()` (optional) wraps the sorting/pagination/filter state plumbing
103
- into a `useAuthorGrid()` so the consumer renders `<EntityGrid {...useAuthorGrid()} />`.
146
+ `tanstackGridHook()` is optional only in the sense that you may own that state
147
+ yourself; if you do, supply `data`, `rowCount`, `state`, `onSortingChange`,
148
+ `onPaginationChange` and `onColumnFiltersChange` by hand — the hook exists so you
149
+ don't have to.
104
150
 
105
151
  ## Cell renderer overrides
106
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metaobjectsdev/sdk",
3
- "version": "0.21.3",
3
+ "version": "0.21.4-rc.1",
4
4
  "description": "Workspace helpers and agent-docs utilities for MetaObjects projects.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -56,7 +56,7 @@
56
56
  "access": "public"
57
57
  },
58
58
  "dependencies": {
59
- "@metaobjectsdev/metadata": "0.21.3",
59
+ "@metaobjectsdev/metadata": "0.21.4-rc.1",
60
60
  "zod": "^3.23.0"
61
61
  },
62
62
  "devDependencies": {