Document the generic query and mutation composition, add collaborative authorization coverage, and repair stale generated-app verification fixtures.
- commit
- 724661c7834a0a48182f60d899e565b04389bcbe
- parent
- 846650f814b735cf1164520866951bb0e4ae3762
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 1 | + |
| M | docs/ | 192 | +++++++++++++++--------------- |
| A | docs/ | 111 | +++++++++++++++++ |
| M | package/ | 59 | +++++++++- |
| M | package/ | 2 | +- |
| M | tools/ | 35 | ++++- |
@@ -41,6 +41,7 @@ The root [README](../README.md) provides the shortest product overview and comma
4141 - [Account and access API](reference/access.md)4242 - [Shared example](examples/shared.md)4343 - [Group example](examples/group.md)44+- [Collaborative list recipe](examples/collaborative-list.md)4445 - [Advanced device-auth primitive](auth.md)4546 4647 ## The author boundary@@ -1,32 +1,31 @@
11 # Data and UI22 3-The generated task list demonstrates lofi's main authoring pattern:3+Lofi keeps realtime plumbing out of product components. Authors declare Jazz tables and policies,4+build exact typed queries, and compose two package hooks inside a domain hook:45 56 ```mermaid67 flowchart LR7- Schema["schema.ts<br/>table and row types"] --> Permissions["permissions.ts<br/>read and write policy"]8- Schema --> Hook["domain hook<br/>useTasks"]9- Runtime["@nzip/lofi runtime<br/>storage and subscriptions"] --> Hook10- Hook --> Island["Preact island<br/>product UI"]11- Island -- "domain mutation" --> Hook12- Hook -- "insert, update, delete" --> Runtime13- Runtime -- "typed snapshot" --> Hook8+ Schema["Typed Jazz schema"] --> Policy["Private, Shared, or Group policy"]9+ Schema --> Query["Typed scoped query"]10+ Query --> Live["useLiveQuery"]11+ Table["Typed table"] --> Mutations["useTableMutations"]12+ Live --> Domain["Author-owned domain hook"]13+ Mutations --> Domain14+ Domain --> UI["Preact product UI"]1415 ```1516 16-The runtime owns the database, durable storage, subscriptions, and sync lifecycle. The domain hook17-selects a table and exposes product-specific reads and mutations to the UI.17+The root package owns shared query stores, runtime recreation, subscription teardown, mutation18+errors, and durability tracking. `@nzip/lofi/preact` owns only the Preact lifecycle adapter.1819 19-## 1. Declare a table20-21-`src/schema.ts` uses the pinned Jazz schema API directly:20+## Declare a table and policy2221 2322 ```ts2423 import { schema as s } from "jazz-tools";2524 2625 const schema = {27- notes: s.table({26+ records: s.table({27+ workspaceId: s.string(),2828 title: s.string(),29- body: s.string(),3029 archived: s.boolean(),3130 createdAt: s.timestamp(),3231 }),@@ -36,110 +35,111 @@ type AppSchema = s.Schema<typeof schema>;
3635 export const app: s.App<AppSchema> = s.defineApp(schema);3736 ```3837 39-Keep table names stable once data has shipped. A schema change can require a migration; validate the40-schema and review the Jazz CLI output before deploying it.41-42-## 2. Define permissions for the table43-44-Every new table needs an explicit policy in `src/permissions.ts`. The starter policy lets a user45-insert rows and limits reads, updates, and deletes to the row creator. See46-[Permissions](permissions.md) before changing that boundary.47-48-## 3. Bind the table to a typed hook38+Every table needs a policy in `src/permissions.ts`. Permissions determine which rows enter a live39+query and which mutations succeed; realtime access is not a separate permission mode. See40+[Permissions](permissions.md), [direct sharing](examples/shared.md), and41+[group ownership](examples/group.md).4942 50-The generated `src/islands/use-tasks.ts` is the canonical example. Its important pieces are:43+## Bind an exact typed query5144 5245 ```ts53-import { schema as s } from "jazz-tools";54-import { getRuntime } from "@nzip/lofi";46+import { useLiveQuery } from "@nzip/lofi/preact";5547 import { app } from "../app.ts";5648 57-const notesTable = app.schema.notes;58-export type Note = s.RowOf<typeof notesTable>;49+const records = useLiveQuery(50+ () =>51+ app.schema.records52+ .where({ workspaceId, archived: false })53+ .orderBy("createdAt", "desc"),54+ [workspaceId],55+);5956 ```6057 61-`s.RowOf` derives the row type from the schema, including its `id`. Do not duplicate persisted row62-types by hand.63-64-When the hook connects, it asks the runtime for a shared store:58+The result preserves the row produced by the exact builder, including `select` and `include`59+projections:6560 6661 ```ts67-const runtime = await getRuntime();68-const store = runtime.store(notesTable);62+const titles = useLiveQuery(63+ () => app.schema.records.select("title").where({ workspaceId }),64+ [workspaceId],65+);66+// titles.rows contains id and title, not the unselected application columns.6967 ```7068 71-Repeated calls for the same table return the same store. Multiple consumers therefore share one72-underlying Jazz subscription.69+Equivalent mounted queries share one Jazz subscription. When dependencies change, Lofi releases the70+obsolete query before opening the replacement. The last consumer evicts the store. Runtime or71+account recreation reconnects mounted queries and ignores callbacks from an obsolete client.7372 74-Use the generated hook's subscription and cleanup structure rather than opening a raw Jazz75-subscription in a component. It handles unmounting and reconnects after a user enables sync or76-restores another account.73+Read state is deliberately small:7774 78-## 4. Expose domain mutations75+| Field | Values | Meaning |76+| -------- | --------------------------- | ---------------------------------------- |77+| `status` | `loading`, `ready`, `error` | Subscription/read state |78+| `rows` | Exact typed query rows | Current authorized result |79+| `error` | Message or `null` | Query setup or runtime acquisition error |7980 80-The table store provides three mutations:81+An empty array with `status: "ready"` is an empty result, not a loading signal.82+83+## Mutate the underlying table8184 8285 ```ts83-await store.insert({ title, body, archived: false, createdAt: new Date() });84-await store.update(noteId, { archived: true });85-await store.delete(noteId);86+import { useTableMutations } from "@nzip/lofi/preact";87+88+const records = useTableMutations(app.schema.records);89+const created = await records.insert({90+ workspaceId,91+ title: "Release notes",92+ archived: false,93+ createdAt: new Date(),94+});95+await records.update(created.id, { archived: true });96+await records.remove(created.id);8697 ```8798 88-Wrap these in domain verbs such as `createNote`, `archiveNote`, and `deleteNote`. Product components89-should not need to know which table or runtime implements them.90-91-Each mutation waits for local durability before its promise resolves. When the user has elected to92-sync, the store also tracks global durability in the background.99+`insert` returns the created row, including its generated `id`. Mutation promises resolve only after100+local durability. When managed sync is active, global confirmation continues in the background and101+updates the shared table-scoped state:93102 94-```mermaid95-sequenceDiagram96- participant UI as Preact island97- participant Hook as Domain hook98- participant Store as Table store99- participant Disk as OPFS100- participant Sync as Managed sync101-102- UI->>Hook: createNote(...)103- Hook->>Store: insert(...)104- Store->>Disk: persist mutation105- Disk-->>Store: local durability106- Store-->>Hook: mutation resolves107- Hook-->>UI: update status108- opt User elected to sync109- Store->>Sync: replicate mutation110- Sync-->>Store: global durability111- end112-```103+| Field | Values | Meaning |104+| ------------ | ----------------------------------- | ---------------------------------- |105+| `pending` | Number | Local durability waits in progress |106+| `durability` | `none`, `local`, `global`, `failed` | Latest mutation outcome |107+| `error` | Message or `null` | Awaited or asynchronous rejection |113108 114-## 5. Render every state honestly109+Several filtered queries can observe the same table mutation without owning mutation listeners. All110+`useTableMutations` consumers for one schema table share one listener and one state surface.115111 116-A table snapshot contains:112+## Keep product UI domain-shaped117113 118-| Field | Values | Meaning |119-| ------------ | ----------------------------------- | ------------------------------- |120-| `status` | `loading`, `ready`, `error` | Subscription/read state |121-| `rows` | Typed row array | Current local view |122-| `durability` | `none`, `local`, `global`, `failed` | Most recent mutation durability |123-| `error` | A message or `null` | Read or mutation failure |114+```ts115+export function useWorkspaceRecords(workspaceId: string) {116+ const query = useLiveQuery(117+ () => app.schema.records.where({ workspaceId, archived: false }),118+ [workspaceId],119+ );120+ const mutations = useTableMutations(app.schema.records);121+ return {122+ ...query,123+ durability: mutations.durability,124+ createRecord: (title: string) =>125+ mutations.insert({126+ workspaceId,127+ title,128+ archived: false,129+ createdAt: new Date(),130+ }),131+ archiveRecord: (id: string) => mutations.update(id, { archived: true }),132+ };133+}134+```124135 125-Render loading and failure states rather than treating an empty row array as proof that loading126-finished. A locally durable write is safe in device storage; a globally durable write has also127-reached configured sync.136+Components consume `createRecord` and `archiveRecord`; they do not call `getRuntime`, manage Jazz137+subscriptions, or listen for runtime recreation. The generated task hook is the smallest working138+example. For a complete access-aware composition, see the139+[collaborative list recipe](examples/collaborative-list.md).128140 129141 ## Schema changes and migrations130142 131-Generated projects provide these Jazz CLI tasks:132-133-```sh134-deno task schema:validate135-deno task migrations:create136-deno task migrations:push137-deno task schema:deploy138-```139-140-Use `schema:validate` while editing. When a change affects existing data, use the migration command141-to create the migration under `src/migrations/`, review the generated change, and test it against142-non-sensitive fixture data before pushing or deploying it.143-144-`migrations:push` and `schema:deploy` use the managed Jazz configuration in `.env`. Never paste its145-server-only secrets into source, client code, screenshots, or issue text.143+Use `deno task schema:validate` while editing. For existing data, create and review a migration with144+`deno task migrations:create`, then use `migrations:push` and `schema:deploy` only with the intended145+managed configuration. Never put server-only secrets in source or browser code.@@ -0,0 +1,111 @@
1+# Collaborative list recipe2+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.5+6+## Schema7+8+```ts9+import { schema as s } from "jazz-tools";10+import { groupMembershipTable } from "@nzip/lofi/access";11+12+export const app = s.defineApp({13+ workspaces: s.table({ name: s.string() }),14+ workspaceMembers: groupMembershipTable("workspaces"),15+ records: s.table({16+ workspaceId: s.ref("workspaces"),17+ title: s.string(),18+ archived: s.boolean(),19+ createdAt: s.timestamp(),20+ }),21+});22+```23+24+## Access policy25+26+```ts27+import { defineAccessPolicies, groupAccess } from "@nzip/lofi/access";28+import { app } from "./schema.ts";29+30+export default defineAccessPolicies(app, [groupAccess({31+ groups: app.workspaces,32+ members: app.workspaceMembers,33+ resources: app.records,34+ groupId: "workspaceId",35+})]);36+```37+38+Readers see authorized rows. Contributors create and edit their own rows. Writers edit any group39+row. Admins also manage membership. Revoking membership removes rows from active queries when the40+updated authorization reaches the client; a rejected mutation appears through the mutation hook's41+`error` state.42+43+## Scoped resources and membership44+45+```ts46+import { useLiveQuery, useTableMutations } from "@nzip/lofi/preact";47+import { app } from "../app.ts";48+49+export function useWorkspaceRecords(workspaceId: string) {50+ const records = useLiveQuery(51+ () =>52+ app.schema.records53+ .where({ workspaceId, archived: false })54+ .orderBy("createdAt", "desc"),55+ [workspaceId],56+ );57+ const members = useLiveQuery(58+ () => app.schema.workspaceMembers.where({ groupId: workspaceId }),59+ [workspaceId],60+ );61+ const mutations = useTableMutations(app.schema.records);62+63+ return {64+ records,65+ members,66+ mutation: {67+ pending: mutations.pending,68+ durability: mutations.durability,69+ error: mutations.error,70+ },71+ create: (title: string) =>72+ mutations.insert({73+ workspaceId,74+ title,75+ archived: false,76+ createdAt: new Date(),77+ }),78+ rename: (id: string, title: string) => mutations.update(id, { title }),79+ archive: (id: string) => mutations.update(id, { archived: true }),80+ remove: mutations.remove,81+ };82+}83+```84+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.88+89+## UI90+91+```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>;96+ return (97+ <section>98+ <p>{model.records.rows.length} records · {model.mutation.durability}</p>99+ {model.mutation.error && <p role="alert">{model.mutation.error}</p>}100+ <ul>101+ {model.records.rows.map((record) => <li key={record.id}>{record.title}</li>)}102+ </ul>103+ </section>104+ );105+}106+```107+108+Test at least two isolated identities: authorized insert/update/delete visibility, membership or109+grant revocation, rejected post-revocation mutations, local durability while offline, and110+convergence after reconnect. Use Lofi's two-client fixture for browser behavior and the official111+Jazz policy harness for deterministic permission transitions.@@ -107,6 +107,13 @@ Deno.test("official Jazz harness enforces private and direct-share templates", a
107107 user_id: "editor",108108 can_edit: true,109109 }).wait({ tier: "global" });110+ let ownerRows: Array<s.RowOf<typeof app.sharedDocs>> = [];111+ let readerRows: Array<s.RowOf<typeof app.sharedDocs>> = [];112+ let editorRows: Array<s.RowOf<typeof app.sharedDocs>> = [];113+ const liveSharedDoc = app.sharedDocs.where({ id: sharedDoc.id });114+ const stopOwner = owner.subscribeAll(liveSharedDoc, (delta) => ownerRows = delta.all);115+ const stopReader = reader.subscribeAll(liveSharedDoc, (delta) => readerRows = delta.all);116+ const stopEditor = editor.subscribeAll(liveSharedDoc, (delta) => editorRows = delta.all);110117 111118 assert(112119 (await reader.all(app.sharedDocs.where({ id: sharedDoc.id }))).length === 1,@@ -121,14 +128,28 @@ Deno.test("official Jazz harness enforces private and direct-share templates", a
121128 "stranger read shared row",122129 );123130 reader.expectDenied((db) => db.update(app.sharedDocs, sharedDoc.id, { title: "denied" }));124- editor.expectAllowed((db) => db.update(app.sharedDocs, sharedDoc.id, { title: "allowed" }));131+ await editor.update(app.sharedDocs, sharedDoc.id, { title: "allowed" }).wait({132+ tier: "global",133+ });134+ assert(135+ ownerRows[0]?.title === "allowed" && editorRows[0]?.title === "allowed",136+ "authorized live queries did not observe the shared update",137+ );125138 stranger.expectDenied((db) => db.delete(app.sharedDocs, sharedDoc.id));126139 127140 await owner.delete(app.sharedDocGrants, readGrant.id).wait({ tier: "global" });141+ assert(readerRows.length === 0, "revocation did not remove the row from the active query");128142 assert(129143 (await reader.all(app.sharedDocs.where({ id: sharedDoc.id }))).length === 0,130144 "revoked reader retained access",131145 );146+ assert(147+ ownerRows.length === 1 && editorRows.length === 1,148+ "revocation affected other identities",149+ );150+ stopOwner();151+ stopReader();152+ stopEditor();132153 } finally {133154 await testApp.shutdown();134155 }@@ -192,6 +213,39 @@ Deno.test("official Jazz harness enforces fixed group roles and membership lifec
192213 .wait({193214 tier: "global",194215 });216+ let adminRows: Array<s.RowOf<typeof app.groupDocs>> = [];217+ let readerRows: Array<s.RowOf<typeof app.groupDocs>> = [];218+ let writerRows: Array<s.RowOf<typeof app.groupDocs>> = [];219+ const liveGroupDocs = app.groupDocs.where({ workspaceId: groupId });220+ const stopAdmin = admin.subscribeAll(liveGroupDocs, (delta) => adminRows = delta.all);221+ const stopReader = reader.subscribeAll(liveGroupDocs, (delta) => readerRows = delta.all);222+ const stopWriter = writer.subscribeAll(liveGroupDocs, (delta) => writerRows = delta.all);223+ const liveDoc = await contributor.insert(app.groupDocs, {224+ workspaceId: groupId,225+ title: "live insert",226+ }).wait({ tier: "global" });227+ assert(228+ [adminRows, readerRows, writerRows].every((rows) =>229+ rows.some((row) => row.id === liveDoc.id)230+ ),231+ "authorized live queries did not observe the group insert",232+ );233+ await writer.update(app.groupDocs, liveDoc.id, { title: "live update" }).wait({234+ tier: "global",235+ });236+ assert(237+ [adminRows, readerRows, writerRows].every((rows) =>238+ rows.find((row) => row.id === liveDoc.id)?.title === "live update"239+ ),240+ "authorized live queries did not observe the group update",241+ );242+ await admin.delete(app.groupDocs, liveDoc.id).wait({ tier: "global" });243+ assert(244+ [adminRows, readerRows, writerRows].every((rows) =>245+ rows.every((row) => row.id !== liveDoc.id)246+ ),247+ "authorized live queries did not observe the group delete",248+ );195249 196250 reader.expectDenied((db) =>197251 db.insert(app.groupDocs, { workspaceId: groupId, title: "reader cannot create" })@@ -219,6 +273,9 @@ Deno.test("official Jazz harness enforces fixed group roles and membership lifec
219273 admin.expectAllowed((db) => db.delete(app.workspaceMembers, memberships[1].id));220274 writer.expectAllowed((db) => db.delete(app.workspaceMembers, memberships[2].id));221275 stranger.expectDenied((db) => db.delete(app.workspaceMembers, memberships[2].id));276+ stopAdmin();277+ stopReader();278+ stopWriter();222279 } finally {223280 await testApp.shutdown();224281 }@@ -14,7 +14,7 @@
1414 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1515 "src/islands/AccountGate.tsx": "ef541886720e984f989c2054266be8d2272b6c43fea722bde8ce615352cc0210",1616 "src/islands/TaskList.tsx": "cf559f8529e3836730abcdafab8184032c8ac662a1e794e20f0879161bee37be",17- "src/islands/use-tasks.ts": "385763aee603f29e3bc6efa2a7d68190653e2957481afbb491b5d7b5e30b13f1",17+ "src/islands/use-tasks.ts": "6a4ef635bda5fafb57965fe680dd4d87b46fa487f3eb2db6bb6bd39c4b6c266b",1818 "src/layouts/Shell.astro": "12144355def66c7bf6ed5b0d611ba3a5454cf7499b667936aa6c04494807653a",1919 "src/pages/index.astro": "de9ad86f9b25f7aff1e293931a785dbe393e4d0a35464ffbb649a0ebb4518281",2020 "src/permissions.ts": "dcb00b7f0576d04ad527b8892b8096573b107c3130ed6d835243251c406a38b7",@@ -437,19 +437,38 @@ async function assertProductionOutput(projectRoot: string) {
437437 icons: Array<{ src: string; sizes: string; type: string; purpose: string }>;438438 };439439 const expected = new Map([440- ["icon-192.png", [192, "any"] as const],441- ["icon-512.png", [512, "any"] as const],442- ["icon-maskable-512.png", [512, "maskable"] as const],440+ [441+ "icon-192.png",442+ { sizes: "192x192", type: "image/png", purpose: "any", dimensions: 192 } as const,443+ ],444+ [445+ "icon-512.png",446+ { sizes: "512x512", type: "image/png", purpose: "any", dimensions: 512 } as const,447+ ],448+ [449+ "icon-maskable-512.png",450+ { sizes: "512x512", type: "image/png", purpose: "maskable", dimensions: 512 } as const,451+ ],452+ [453+ "icon-monochrome.svg",454+ { sizes: "any", type: "image/svg+xml", purpose: "monochrome" } as const,455+ ],443456 ]);444457 for (const icon of manifest.icons) {445458 const file = basename(icon.src);446459 const contract = expected.get(file);447460 assert(contract, `manifest references unexpected ${file}`);448- assert(icon.type === "image/png", `${file} has incorrect manifest type`);449- assert(icon.sizes === `${contract[0]}x${contract[0]}`, `${file} has incorrect manifest size`);450- assert(icon.purpose === contract[1], `${file} has incorrect manifest purpose`);451- const [width, height] = pngDimensions(await Deno.readFile(join(dist, file)));452- assert(width === contract[0] && height === contract[0], `${file} dimensions are incorrect`);461+ assert(icon.type === contract.type, `${file} has incorrect manifest type`);462+ assert(icon.sizes === contract.sizes, `${file} has incorrect manifest size`);463+ assert(icon.purpose === contract.purpose, `${file} has incorrect manifest purpose`);464+ await Deno.stat(join(dist, file));465+ if ("dimensions" in contract) {466+ const [width, height] = pngDimensions(await Deno.readFile(join(dist, file)));467+ assert(468+ width === contract.dimensions && height === contract.dimensions,469+ `${file} dimensions are incorrect`,470+ );471+ }453472 }454473 assert(manifest.icons.length === expected.size, "manifest does not contain the exact icon set");455474 const [appleWidth, appleHeight] = pngDimensions(