clone

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

feat/184-task-notice-migration
lofi / docs / examples / collaborative-list.md
collaborative-list.md 5.6K rendered
1# Collaborative list data model23This example models a workspace task list end to end: the tables, the relationships between them,4the column choice for each field, and the policy that scopes the rows. It is the data-modeling5companion to the access-template examples ([Direct sharing](shared.md),6[Fixed-role group](group.md)), which cover who may touch these rows.78## Shape the tables910Declare every table through the schema facade. Three tables carry the whole feature: the group11resource, its membership relation, and the rows the group collaborates on.1213```ts14// src/schema.ts15import { s } from "@nzip/lofi/schema";16import { groupMembershipTable } from "@nzip/lofi/access";1718export const app = s.defineApp({19 workspaces: s.table({ name: s.string() }),20 workspaceMembers: groupMembershipTable("workspaces"),21 tasks: s.table({22 workspaceId: s.ref("workspaces"),23 title: s.string(),24 status: s.enum("open", "done", "dropped").default("open"),25 note: s.string().optional(),26 createdAt: s.timestamp(),27 }),28});29```3031Each column is a deliberate choice from the [column palette](../data-and-ui.md#column-palette):3233- **`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.4445Concurrent edits to the same row resolve with last-write-wins per column — the verified default. If46the model grows a collaborative set (for example, per-task tags), that is `merge("g-set")`, but keep47such a table in a dedicated app for now; the palette section documents the constraint and the cast48workaround.4950## Scope the rows5152The 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:5455```ts56// src/permissions.ts57import { defineAccessPolicies, groupAccess } from "@nzip/lofi/access";58import { app } from "./schema.ts";5960export default defineAccessPolicies(app, [groupAccess({61 groups: app.workspaces,62 members: app.workspaceMembers,63 resources: app.tasks,64 groupId: "workspaceId",65})]);66```6768Readers see authorized rows. Contributors create and edit their own rows. Writers edit any group69row. Admins also manage membership. Revoking membership removes rows from active queries when the70updated authorization reaches the client; a rejected mutation appears through the mutation hook's71`error` state.7273## Query the shape you render7475The domain hook binds one scoped query per rendered surface and derives every mutation from the76schema types — no stringly-typed field names survive to the UI:7778```ts79import { useLiveQuery, useTableMutations } from "@nzip/lofi/preact";80import { app } from "../app.ts";8182export function useWorkspaceTasks(workspaceId: string) {83 const tasks = useLiveQuery(84 () =>85 app.schema.tasks86 .where({ workspaceId, status: "open" })87 .orderBy("createdAt", "desc"),88 [workspaceId],89 );90 const members = useLiveQuery(91 () => app.schema.workspaceMembers.where({ groupId: workspaceId }),92 [workspaceId],93 );94 const mutations = useTableMutations(app.schema.tasks);9596 return {97 tasks,98 members,99 mutation: {100 pending: mutations.pending,101 durability: mutations.durability,102 error: mutations.error,103 },104 create: (title: string) => mutations.insert({ workspaceId, title, createdAt: new Date() }),105 rename: (id: string, title: string) => mutations.update(id, { title }),106 complete: (id: string) => mutations.update(id, { status: "done" }),107 drop: (id: string) => mutations.update(id, { status: "dropped" }),108 };109}110```111112Note 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 leave114the list because the query filters on `status`, not because the UI hides them.115116## UI117118```tsx119export 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>;123 return (124 <section>125 <p>{model.tasks.rows.length} open tasks · {model.mutation.durability}</p>126 {model.mutation.error && <p role="alert">{model.mutation.error}</p>}127 <ul>128 {model.tasks.rows.map((task) => <li key={task.id}>{task.title}</li>)}129 </ul>130 </section>131 );132}133```134135The same composition works for direct sharing: swap the group tables for a resource table plus its136grant table, and the policy — not the hook — keeps filtering each identity's rows.137138Test at least two isolated identities: authorized insert/update/delete visibility, membership or139grant revocation, rejected post-revocation mutations, local durability while offline, and140convergence after reconnect. Use Lofi's two-client fixture for browser behavior and the official141Jazz policy harness for deterministic permission transitions.