clone

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

feat/141-effect-library
lofi / docs / data-and-ui.md
data-and-ui.md 18.0K rendered
1# Data and UI23Lofi keeps realtime plumbing out of product components. Authors declare tables and policies through4the lofi schema surface, build exact typed queries, and compose two package hooks inside a domain5hook:67```mermaid8flowchart LR9 Schema["Typed app schema"] --> Policy["Private, Shared, or Group policy"]10 Schema --> Query["Typed scoped query"]11 Query --> Live["useLiveQuery"]12 Table["Typed table"] --> Mutations["useTableMutations"]13 Live --> Domain["Author-owned domain hook"]14 Mutations --> Domain15 Domain --> UI["Preact product UI"]16```1718The root package owns shared query stores, runtime recreation, subscription teardown, mutation19errors, and durability tracking. `@nzip/lofi/preact` owns only the Preact lifecycle adapter.2021## Declare a table and policy2223```ts24import { s } from "@nzip/lofi/schema";2526const schema = {27 records: s.table({28 workspaceId: s.string(),29 title: s.string(),30 archived: s.boolean(),31 createdAt: s.timestamp(),32 }),33};3435export const app = s.defineApp(schema);36```3738`@nzip/lofi/schema` exports the pinned Jazz 2 schema DSL one-to-one — same names, same behavior — so39`src/schema.ts` and `src/permissions.ts` never import the vendor module and package upgrades absorb40upstream changes (see [the decision record](decisions/schema-facade-alpha53.md)). UI islands stay on41public package seams: derive row types with `RowOf` from `@nzip/lofi`42(`type Record = RowOf<typeof app.schema.records>`), and the generated author-boundary test rejects43raw `jazz-tools` imports in every author source file.4445Every table needs a policy in `src/permissions.ts`. Permissions determine which rows enter a live46query and which mutations succeed; realtime access is not a separate permission mode. See47[Permissions](permissions.md), [direct sharing](examples/shared.md), and48[group ownership](examples/group.md).4950## Column palette5152Every column constructor below is exercised against the real pinned engine by the conformance suite53(`deno task test:conformance`); a member that only compiles is not part of the supported surface.5455| Constructor | Row value | Notes |56| ------------------ | ------------------------ | ---------------------------------------------- |57| `s.string()` | `string` | Unicode round-trips. |58| `s.boolean()` | `boolean` | |59| `s.int()` | `number` | i32 range only at runtime; see below. |60| `s.float()` | `number` | Double precision. |61| `s.timestamp()` | `Date` | Storage round-trips; do not filter on it yet. |62| `s.enum("a", "b")` | Union of the literals | |63| `s.bytes()` | `Uint8Array` | Keep payloads at 32 bytes or more; see below. |64| `s.json()` | Any JSON value | Nested objects and arrays round-trip. |65| `s.array(inner)` | Array of the inner value | |66| `s.ref("table")` | Referenced row id | Filterable foreign key: `where({ parentId })`. |6768Constraints of the pinned alpha, each pinned by a conformance test that fails when upstream lifts69it:7071- **`s.int()` accepts only the i32 range at runtime.** Despite the `number` static type, values72 outside ±2³¹ are rejected with `InvalidArg … expected i32`.73- **Timestamp `where`-equality matches every row** instead of filtering, so query timestamps by id74 or another column for now.75- **Byte payloads under 32 bytes are unreliable**; pad short payloads or store them as `s.json()`.7677### Column modifiers and merge strategies7879Modifiers chain on any constructor: `.optional()` makes the column nullable, `.default(value)` fills80omitted inserts, and `.merge(strategy)` picks a conflict strategy for concurrent writers.81`.transform({ from, to })` (experimental upstream) maps stored values to a different TypeScript view82type: inserts, reads, and updates use the view type, while `where` filters address the stored83representation. In the pinned alpha `.merge()` and `.transform()` return the untyped builder (the84legacy untyped signatures shadow the typed overloads), which degrades the whole table's row types —85cast the result back to the intended column type; the runtime object is unchanged:8687```ts88import { type ArrayColumn, type IntColumn, s } from "@nzip/lofi/schema";8990export const app = s.defineApp({91 tagged: s.table({92 name: s.string(),93 tags: s.array(s.string()).merge("g-set") as unknown as ArrayColumn<"TEXT">,94 }),95});96```9798The pinned alpha ships exactly three merge strategies — `"lww"` (the default), `"counter"`, and99`"g-set"` — and they are the whole collaborative-value surface: Jazz 2 has no successor to the 1.x100CoValue types (`co.map`, CoText, FileStream). All three are verified with two synced clients in101`package/schema/merge_sync_test.ts`:102103- **`"lww"` (the default) works**: with or without an explicit `.merge("lww")`, a concurrent104 conflict resolves to the last write to reach the server, and live replicas and fresh boots agree.105- **`"g-set"` works**: concurrent writers union their elements and every replica — including a fresh106 boot — converges on the same set. Keep a g-set table in its own single-table app for now; in the107 pinned alpha a g-set column destabilizes writes to sibling tables in the same app. See the108 [collaborative sets example](examples/collaborative-sets.md).109- **`"counter"` is not dependable yet**: the server keeps the last causally ordered update value and110 sums only concurrent updates, while live replicas add every update as a delta, so a replica that111 watched the history diverges permanently from what a fresh boot reads. Avoid counter columns until112 an alpha bump clears the pins (see [the decision record](decisions/schema-facade-alpha53.md)).113114### Encrypted columns115116`s.encryptedText(label)`, `s.encryptedJson<T>(label)`, `s.encryptedNumber(label)`, and117`s.encryptedDate(label)` seal a field on the client before it enters Jazz: the sync store holds118versioned ciphertext (XChaCha20-Poly1305, a per-column subkey derived from the account secret), so119the store operator sees that the column exists — writers, timestamps, size classes — but never its120content. Plaintext is padded to bucketed sizes before sealing, so stored lengths reveal which size121class a value falls in rather than its exact length; every short scalar shares one class. The122runtime installs the key at boot; touching an encrypted column before then fails closed rather than123writing plaintext.124125```ts126notes: s.table({127 title: s.string(),128 body: s.encryptedText("notes.body"),129 attachments: s.encryptedJson<{ name: string; size: number }[]>("notes.attachments"),130 balance: s.encryptedNumber("notes.balance"),131 reviewedAt: s.encryptedDate("notes.reviewedAt"),132}),133```134135Sealed numbers view as `number` and sealed dates as `Date`, matching their plaintext counterparts;136because the stored representation is text, sealed numbers are not subject to the 32-bit limit of137plain integer columns. Non-finite numbers and invalid dates are rejected at write time.138139The `label` is the column's cryptographic identity, conventionally `"table.column"`: it binds the140ciphertext to the column (a value replayed into another column refuses to open) and changing it141later makes existing values unreadable — treat it like a column name. Constraints to design around:142143- **Account-private.** The key derives from the account secret, so every device holding the account144 decrypts and nobody else does — including other members of a shared group. A row read under a145 different account throws `EncryptedColumnError` instead of returning garbage. Fields the whole146 group should read use [shared encrypted columns](#shared-encrypted-columns) instead.147- **Not filterable, not policy-visible — enforced.** A `where` on an encrypted column is a compile148 error (the column type is excluded from filter positions), and a permission policy referencing one149 fails configuration with `AccessError` — the server cannot evaluate what it cannot read. Gate on150 plaintext columns beside it. For filtering, rows reaching your code are already decrypted, so151 match them client-side with `matchDecrypted` from `@nzip/lofi/schema`: narrow the query with152 plaintext filters first, then apply the predicate, and only then any limit (a `limit()` before the153 predicate under-fetches). Sort returned rows in code rather than ordering by an encrypted column —154 ciphertext order is arbitrary.155- **No chaining modifiers.** `.default()`, `.merge()`, `.transform()`, and `.optional()` are compile156 errors on encrypted columns: a default would be applied below the seal boundary as plaintext,157 merge strategies and transforms cannot operate on ciphertext, and optional stays disabled until158 the engine's null handling of transformed columns is pinned.159160#### Encrypt by default: `s.privateTable`161162`s.privateTable(labelPrefix, columns)` inverts the posture from "encrypt what is sensitive" to163"expose what the server needs": every column seals by default, and plaintext is the explicit choice.164Labels derive as `"prefix.column"` — treat the prefix like the table name and never reuse it across165tables.166167```ts168notes: s.privateTable("notes", {169 body: s.string(), // sealed: encryptedText("notes.body")170 score: s.int(), // sealed: encryptedNumber("notes.score") — no i32 limit171 reviewedAt: s.timestamp(), // sealed: encryptedDate("notes.reviewedAt")172 meta: s.json(), // sealed: encryptedJson("notes.meta")173 workspaceId: s.ref("workspaces"), // plaintext: foreign keys stay joinable174 title: s.plain(s.string()), // plaintext by author choice: a filter target175}),176```177178Reference columns stay plaintext automatically — a foreign key the server cannot read cannot join or179gate. `s.plain(column)` marks a deliberate plaintext column (a filter, sort key, or policy target)180at the column it affects. Byte columns stay plaintext with a report — store byte payloads as base64181in an encrypted json column to seal them. A column already sealed with an explicit `s.encrypted*`182keeps its own label. Optionals, defaults, non-lww merge strategies, and transforms on sealed columns183are configuration errors: each would either leak plaintext below the seal boundary or silently184discard author intent.185186### Shared encrypted columns187188`s.sharedEncryptedText(label, options)` and `s.sharedEncryptedJson<T>(label, options)` seal a field189under a **group field key**: every member of the group holding the key reads it; the store operator190never does. The key travels as ordinary synced data — wrapped to each member's public key, published191in a key directory, delivered through the group's wrapped-key table — so the server relays key192material it cannot open, and substituting keys is a detected active attack rather than silent193reading.194195```ts196const schema = {197 workspaces: s.table({ name: s.string() }),198 workspaceMembers: groupMembershipTable("workspaces"),199 workspaceFieldKeys: sharedFieldKeyTable("workspaces"),200 keyDirectory: sharedFieldDirectoryTable(),201 docs: s.table({202 workspaceId: s.ref("workspaces"),203 title: s.string(),204 body: s.sharedEncryptedText("docs.body", {205 group: "workspaces",206 groupIdColumn: "workspaceId",207 keys: "workspaceFieldKeys",208 directory: "keyDirectory",209 }),210 }),211};212```213214The two helper tables come from `@nzip/lofi/access` and get their policies from215`sharedFieldAccess({ directory })` and the `fieldKeys` option of `groupAccess`; group operations216created with `fieldKeys`/`directory` tables then mint, deliver, and rotate keys automatically —217creation bootstraps the first key, an arriving member receives every held generation, and removal218rotates to a generation the removed member never receives.219220**Reads are state-valued.** A shared column's row value is a `SharedFieldValue<T>`:221`{ state: "ready", value }` once the key is installed, `{ state: "pending-key" }` while the wrap is222still in flight — the normal condition of a freshly added member — and `{ state: "corrupt" }` when223authentication fails. Render the pending state; never treat it as an error. Live queries224re-materialize automatically when the key arrives. `sharedFieldReady` narrows, and225`unwrapSharedField` throws for callers who prefer exceptions.226227**Writes go through lofi's write path** — the store or a declared verb — which resolves the row's228group from `groupIdColumn` and seals under the newest key this device holds before anything is229journaled. A write that bypasses the framework fails closed rather than storing plaintext. An update230that patches a shared column must include the group column in the verb path (the synchronous journal231cannot fetch the row); the store path fetches it automatically.232233Constraints to design around, in addition to everything that applies to encrypted columns:234235- **Removal is lazy rekey.** A removed member never receives keys minted after their removal, so236 content written afterwards is sealed to them — but generations they already held keep opening,237 because they already possess that key material and re-encrypting CRDT history is unsound under238 concurrent merges. Removal protects future content.239- **Key delivery is asynchronous.** A new member reads `pending-key` until a device holding the key240 comes online and wraps for them; `reconcileSharedFieldKeys` on the group operations repairs241 missing wraps. Members who have not yet published a directory entry cannot receive keys.242- **Substitution is detected, not prevented.** Peer keys pin on first sight per device, and a243 sharing identity minted by a shared-field-capable app carries the account's key fingerprint, so244 members added person-to-person are pinned with no server first-sight window. A key that later245 disagrees surfaces in `diagnostics.sharedFieldAlerts` and its wraps are refused until the user246 explicitly re-trusts.247248## Bind an exact typed query249250```ts251import { useLiveQuery } from "@nzip/lofi/preact";252import { app } from "../app.ts";253254const records = useLiveQuery(255 () =>256 app.schema.records257 .where({ workspaceId, archived: false })258 .orderBy("createdAt", "desc"),259 [workspaceId],260);261```262263The result preserves the row produced by the exact builder, including `select` and `include`264projections:265266```ts267const titles = useLiveQuery(268 () => app.schema.records.select("title").where({ workspaceId }),269 [workspaceId],270);271// titles.rows contains id and title, not the unselected application columns.272```273274Equivalent mounted queries share one Jazz subscription. When dependencies change, Lofi releases the275obsolete query before opening the replacement. The last consumer evicts the store. Runtime or276account recreation reconnects mounted queries and ignores callbacks from an obsolete client.277278Read state is deliberately small:279280| Field | Values | Meaning |281| -------- | --------------------------- | ---------------------------------------- |282| `status` | `loading`, `ready`, `error` | Subscription/read state |283| `rows` | Exact typed query rows | Current authorized result |284| `error` | Message or `null` | Query setup or runtime acquisition error |285286An empty array with `status: "ready"` is an empty result, not a loading signal.287288## Mutate the underlying table289290```ts291import { useTableMutations } from "@nzip/lofi/preact";292293const records = useTableMutations(app.schema.records);294const created = await records.insert({295 workspaceId,296 title: "Release notes",297 archived: false,298 createdAt: new Date(),299});300await records.update(created.id, { archived: true });301await records.remove(created.id);302```303304Each method returns a thenable `WriteHandle`: awaiting `insert` resolves with the created row,305including its generated `id`, once the write is durable on this device. The handle's `saved` and306`synced` promises and its `stage` property expose the write's sync fate; named verbs with effect307handlers build on the same lifecycle — see [Nouns and verbs](nouns-and-verbs.md). When managed sync308is active, global confirmation continues in the background and updates the shared table-scoped309state:310311| Field | Values | Meaning |312| ------------ | ----------------------------------- | ---------------------------------- |313| `pending` | Number | Local durability waits in progress |314| `durability` | `none`, `local`, `global`, `failed` | Latest mutation outcome |315| `error` | Message or `null` | Awaited or asynchronous rejection |316317Several filtered queries can observe the same table mutation without owning mutation listeners. All318`useTableMutations` consumers for one schema table share one listener and one state surface.319320## Keep product UI domain-shaped321322```ts323export function useWorkspaceRecords(workspaceId: string) {324 const query = useLiveQuery(325 () => app.schema.records.where({ workspaceId, archived: false }),326 [workspaceId],327 );328 const mutations = useTableMutations(app.schema.records);329 return {330 ...query,331 durability: mutations.durability,332 createRecord: (title: string) =>333 mutations.insert({334 workspaceId,335 title,336 archived: false,337 createdAt: new Date(),338 }),339 archiveRecord: (id: string) => mutations.update(id, { archived: true }),340 };341}342```343344Components consume `createRecord` and `archiveRecord`; they do not call `getRuntime`, manage Jazz345subscriptions, or listen for runtime recreation. The generated task hook is the smallest working346example. For a complete access-aware composition, see the347[collaborative list data model](examples/collaborative-list.md).348349## Schema changes and migrations350351Use `deno task schema:validate` while editing. For existing data, create and review a migration with352`deno task migrations:create`, then use `migrations:push` and `schema:deploy` only with the intended353managed configuration. Never put server-only secrets in source or browser code. The354[schema evolution example](examples/schema-evolution.md) walks through a verified two-version355migration — add, drop, rename, and table rename — in both directions.