clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

docs(examples): split examples into access-template and data-modeling sections

Dami committed 661589a
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>
5 files changed, +88 -40
Mdocs/README.md8++-
Mdocs/data-and-ui.md2+-
Mdocs/examples/collaborative-list.md96++++++++++++++++++++----------
Mpackage/schema/conformance_test.ts11+++
Mwebsite/docs-manifest.json11+++-
M docs/README.md +5 -3
M docs/data-and-ui.md +1 -1
M docs/examples/collaborative-list.md +63 -33
@@ -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
M package/schema/conformance_test.ts +11 -0
M website/docs-manifest.json +8 -3