Reorganize the examples into two subsections in the docs index and the site sidebar: "access templates" (direct sharing, fixed-role group) and "data modeling" (collaborative list). Rewrite the collaborative-list example as a data-modeling walkthrough of the schema facade: each column is a deliberate palette choice (ref relationship the policy joins on, enum lifecycle with a default, optional note, order-only timestamp), with the verified merge-strategy guidance woven in. Add enum .default() coverage to the conformance modifiers test so the example only teaches engine-verified surface. Continues #105.
- commit
- 661589a305dbaa2d4608ec92056f75465d7bb5a4
- parent
- 21e23a430cc96c267293324b164824d00c7c0665
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 8 | ++- |
| M | docs/ | 2 | +- |
| M | docs/ | 96 | ++++++++++++++++++++---------- |
| M | package/ | 11 | +++ |
| M | website/ | 11 | +++- |
@@ -33,9 +33,11 @@ shortest product overview and command summary. AI agents can ingest these docs a
3333 - [Generated project layout](reference/project-layout.md)3434 - [Identity and recovery model](auth-identity.md)3535 - [Account and access API](reference/access.md)36-- [Shared example](examples/shared.md)37-- [Group example](examples/group.md)38-- [Collaborative list recipe](examples/collaborative-list.md)36+- Access template examples — who may touch which rows37+ - [Direct sharing](examples/shared.md)38+ - [Fixed-role group](examples/group.md)39+- Data modeling examples — how to shape tables and columns40+ - [Collaborative list data model](examples/collaborative-list.md)3941 - [Optional installed-app recipes](recipes/README.md)4042 - [Advanced device-auth primitive](auth.md)4143 @@ -206,7 +206,7 @@ export function useWorkspaceRecords(workspaceId: string) {
206206 Components consume `createRecord` and `archiveRecord`; they do not call `getRuntime`, manage Jazz207207 subscriptions, or listen for runtime recreation. The generated task hook is the smallest working208208 example. For a complete access-aware composition, see the209-[collaborative list recipe](examples/collaborative-list.md).209+[collaborative list data model](examples/collaborative-list.md).210210 211211 ## Schema changes and migrations212212 @@ -1,36 +1,66 @@
1-# Collaborative list recipe1+# Collaborative list data model22 3-This recipe composes ordinary typed queries and mutations with the existing Group policy. There is4-no realtime permission mode and no group-specific query hook.3+This example models a workspace task list end to end: the tables, the relationships between them,4+the column choice for each field, and the policy that scopes the rows. It is the data-modeling5+companion to the access-template examples ([Direct sharing](shared.md),6+[Fixed-role group](group.md)), which cover who may touch these rows.57 6-## Schema8+## Shape the tables9+10+Declare every table through the schema facade. Three tables carry the whole feature: the group11+resource, its membership relation, and the rows the group collaborates on.712 813 ```ts14+// src/schema.ts915 import { s } from "@nzip/lofi/schema";1016 import { groupMembershipTable } from "@nzip/lofi/access";1117 1218 export const app = s.defineApp({1319 workspaces: s.table({ name: s.string() }),1420 workspaceMembers: groupMembershipTable("workspaces"),15- records: s.table({21+ tasks: s.table({1622 workspaceId: s.ref("workspaces"),1723 title: s.string(),18- archived: s.boolean(),24+ status: s.enum("open", "done", "dropped").default("open"),25+ note: s.string().optional(),1926 createdAt: s.timestamp(),2027 }),2128 });2229 ```2330 24-## Access policy31+Each column is a deliberate choice from the [column palette](../data-and-ui.md#column-palette):32+33+- **`s.ref("workspaces")`** is the relationship. It is a filterable foreign key — every scoped query34+ below narrows on `workspaceId` — and it is also what the group policy joins on.35+- **`s.enum(...)`** models the task lifecycle instead of a boolean pair (`archived`, `completed`)36+ that could drift into contradictory states. Enum columns filter correctly with `where`, so the37+ hook queries `status: "open"` directly.38+- **`.default("open")`** keeps the insert call minimal: creating a task states only what the user39+ provided.40+- **`.optional()`** marks the one genuinely absent-able field. Prefer one optional column over a41+ sentinel value like `""` — the read side gets `null` back and can render accordingly.42+- **`s.timestamp()`** exists to order, not to filter: `where`-equality on timestamps is pinned43+ broken in the current alpha, so it participates in `orderBy` only.44+45+Concurrent edits to the same row resolve with last-write-wins per column — the verified default. If46+the model grows a collaborative set (for example, per-task tags), that is `merge("g-set")`, but keep47+such a table in a dedicated app for now; the palette section documents the constraint and the cast48+workaround.49+50+## Scope the rows51+52+The policy joins tasks to the membership relation through the `workspaceId` reference declared above53+— the model and the policy share one source of truth for the relationship:2554 2655 ```ts56+// src/permissions.ts2757 import { defineAccessPolicies, groupAccess } from "@nzip/lofi/access";2858 import { app } from "./schema.ts";2959 3060 export default defineAccessPolicies(app, [groupAccess({3161 groups: app.workspaces,3262 members: app.workspaceMembers,33- resources: app.records,63+ resources: app.tasks,3464 groupId: "workspaceId",3565 })]);3666 ```@@ -40,17 +70,20 @@ row. Admins also manage membership. Revoking membership removes rows from active
4070 updated authorization reaches the client; a rejected mutation appears through the mutation hook's4171 `error` state.4272 43-## Scoped resources and membership73+## Query the shape you render74+75+The domain hook binds one scoped query per rendered surface and derives every mutation from the76+schema types — no stringly-typed field names survive to the UI:4477 4578 ```ts4679 import { useLiveQuery, useTableMutations } from "@nzip/lofi/preact";4780 import { app } from "../app.ts";4881 49-export function useWorkspaceRecords(workspaceId: string) {50- const records = useLiveQuery(82+export function useWorkspaceTasks(workspaceId: string) {83+ const tasks = useLiveQuery(5184 () =>52- app.schema.records53- .where({ workspaceId, archived: false })85+ app.schema.tasks86+ .where({ workspaceId, status: "open" })5487 .orderBy("createdAt", "desc"),5588 [workspaceId],5689 );@@ -58,53 +91,50 @@ export function useWorkspaceRecords(workspaceId: string) {
5891 () => app.schema.workspaceMembers.where({ groupId: workspaceId }),5992 [workspaceId],6093 );61- const mutations = useTableMutations(app.schema.records);94+ const mutations = useTableMutations(app.schema.tasks);6295 6396 return {64- records,97+ tasks,6598 members,6699 mutation: {67100 pending: mutations.pending,68101 durability: mutations.durability,69102 error: mutations.error,70103 },71- create: (title: string) =>72- mutations.insert({73- workspaceId,74- title,75- archived: false,76- createdAt: new Date(),77- }),104+ create: (title: string) => mutations.insert({ workspaceId, title, createdAt: new Date() }),78105 rename: (id: string, title: string) => mutations.update(id, { title }),79- archive: (id: string) => mutations.update(id, { archived: true }),80- remove: mutations.remove,106+ complete: (id: string) => mutations.update(id, { status: "done" }),107+ drop: (id: string) => mutations.update(id, { status: "dropped" }),81108 };82109 }83110 ```84111 85-The same composition works for direct sharing. Query the resource table for the collaborative list,86-query the conventional grant table for grant-management UI, and use `createSharingOperations` for87-share/revoke actions. Access policy—not the hook—filters each identity's rows.112+Note what the schema bought here: `create` passes neither `status` (defaulted) nor `note`113+(optional), the `status` transitions are typo-checked against the enum, and completed tasks leave114+the list because the query filters on `status`, not because the UI hides them.88115 89116 ## UI90117 91118 ```tsx92-export function RecordList({ workspaceId }: { workspaceId: string }) {93- const model = useWorkspaceRecords(workspaceId);94- if (model.records.status === "loading") return <p>Loading records…</p>;95- if (model.records.status === "error") return <p role="alert">{model.records.error}</p>;119+export function TaskList({ workspaceId }: { workspaceId: string }) {120+ const model = useWorkspaceTasks(workspaceId);121+ if (model.tasks.status === "loading") return <p>Loading tasks…</p>;122+ if (model.tasks.status === "error") return <p role="alert">{model.tasks.error}</p>;96123 return (97124 <section>98- <p>{model.records.rows.length} records · {model.mutation.durability}</p>125+ <p>{model.tasks.rows.length} open tasks · {model.mutation.durability}</p>99126 {model.mutation.error && <p role="alert">{model.mutation.error}</p>}100127 <ul>101- {model.records.rows.map((record) => <li key={record.id}>{record.title}</li>)}128+ {model.tasks.rows.map((task) => <li key={task.id}>{task.title}</li>)}102129 </ul>103130 </section>104131 );105132 }106133 ```107134 135+The same composition works for direct sharing: swap the group tables for a resource table plus its136+grant table, and the policy — not the hook — keeps filtering each identity's rows.137+108138 Test at least two isolated identities: authorized insert/update/delete visibility, membership or109139 grant revocation, rejected post-revocation mutations, local durability while offline, and110140 convergence after reconnect. Use Lofi's two-client fixture for browser behavior and the official@@ -26,6 +26,7 @@ const app = s.defineApp({
2626 required: s.string(),2727 note: s.string().optional(),2828 label: s.string().default("untitled"),29+ status: s.enum("open", "done", "dropped").default("open"),2930 // CONFORMANCE FINDING (alpha.53): `.merge()` returns the untyped builder3031 // because the legacy `merge(): this` signature shadows the typed overload,3132 // which poisons the whole table's row types. The cast restores the typed@@ -270,6 +271,16 @@ Deno.test("facade column modifiers (optional, default, counter, g-set) behave th
270271 readBack.label === "untitled",271272 `default: omitted column read back as ${describeValue(readBack.label)}`,272273 );274+ assert(275+ readBack.status === "open",276+ `enum default: omitted column read back as ${describeValue(readBack.status)}`,277+ );278+ await db.update(app.modifiers, row.id, { status: "done" }).wait({ tier: "global" });279+ const transitioned = (await db.all(app.modifiers.where({ id: row.id })))[0];280+ assert(281+ transitioned?.status === "done",282+ `enum: update read back ${describeValue(transitioned?.status)}`,283+ );273284 assert(274285 readBack.total === 0,275286 `counter default: read back ${describeValue(readBack.total)}`,@@ -46,11 +46,16 @@
4646 ]4747 },4848 {49- "label": "Examples",49+ "label": "Examples: access templates",5050 "items": [5151 { "id": "examples/shared", "label": "Direct sharing" },52- { "id": "examples/group", "label": "Fixed-role group" },53- { "id": "examples/collaborative-list", "label": "Collaborative list" }52+ { "id": "examples/group", "label": "Fixed-role group" }53+ ]54+ },55+ {56+ "label": "Examples: data modeling",57+ "items": [58+ { "id": "examples/collaborative-list", "label": "Collaborative list data model" }5459 ]5560 },5661 {