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## Bind an exact typed query115116```ts117import { useLiveQuery } from "@nzip/lofi/preact";118import { app } from "../app.ts";119120const records = useLiveQuery(121 () =>122 app.schema.records123 .where({ workspaceId, archived: false })124 .orderBy("createdAt", "desc"),125 [workspaceId],126);127```128129The result preserves the row produced by the exact builder, including `select` and `include`130projections:131132```ts133const titles = useLiveQuery(134 () => app.schema.records.select("title").where({ workspaceId }),135 [workspaceId],136);137// titles.rows contains id and title, not the unselected application columns.138```139140Equivalent mounted queries share one Jazz subscription. When dependencies change, Lofi releases the141obsolete query before opening the replacement. The last consumer evicts the store. Runtime or142account recreation reconnects mounted queries and ignores callbacks from an obsolete client.143144Read state is deliberately small:145146| Field | Values | Meaning |147| -------- | --------------------------- | ---------------------------------------- |148| `status` | `loading`, `ready`, `error` | Subscription/read state |149| `rows` | Exact typed query rows | Current authorized result |150| `error` | Message or `null` | Query setup or runtime acquisition error |151152An empty array with `status: "ready"` is an empty result, not a loading signal.153154## Mutate the underlying table155156```ts157import { useTableMutations } from "@nzip/lofi/preact";158159const records = useTableMutations(app.schema.records);160const created = await records.insert({161 workspaceId,162 title: "Release notes",163 archived: false,164 createdAt: new Date(),165});166await records.update(created.id, { archived: true });167await records.remove(created.id);168```169170`insert` returns the created row, including its generated `id`. Mutation promises resolve only after171local durability. When managed sync is active, global confirmation continues in the background and172updates the shared table-scoped state:173174| Field | Values | Meaning |175| ------------ | ----------------------------------- | ---------------------------------- |176| `pending` | Number | Local durability waits in progress |177| `durability` | `none`, `local`, `global`, `failed` | Latest mutation outcome |178| `error` | Message or `null` | Awaited or asynchronous rejection |179180Several filtered queries can observe the same table mutation without owning mutation listeners. All181`useTableMutations` consumers for one schema table share one listener and one state surface.182183## Keep product UI domain-shaped184185```ts186export function useWorkspaceRecords(workspaceId: string) {187 const query = useLiveQuery(188 () => app.schema.records.where({ workspaceId, archived: false }),189 [workspaceId],190 );191 const mutations = useTableMutations(app.schema.records);192 return {193 ...query,194 durability: mutations.durability,195 createRecord: (title: string) =>196 mutations.insert({197 workspaceId,198 title,199 archived: false,200 createdAt: new Date(),201 }),202 archiveRecord: (id: string) => mutations.update(id, { archived: true }),203 };204}205```206207Components consume `createRecord` and `archiveRecord`; they do not call `getRuntime`, manage Jazz208subscriptions, or listen for runtime recreation. The generated task hook is the smallest working209example. For a complete access-aware composition, see the210[collaborative list data model](examples/collaborative-list.md).211212## Schema changes and migrations213214Use `deno task schema:validate` while editing. For existing data, create and review a migration with215`deno task migrations:create`, then use `migrations:push` and `schema:deploy` only with the intended216managed configuration. Never put server-only secrets in source or browser code. The217[schema evolution example](examples/schema-evolution.md) walks through a verified two-version218migration — add, drop, rename, and table rename — in both directions.