clone

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

Merge pull request #106 from FelineStateMachine/feat/schema-facade-alpha53

FelineStateMachine committed f29e9c9
feat(schema): Jazz schema facade + alpha.53 conformance suite
commit
f29e9c9d46b02fa5947ac77b37414e476f762048
parent
1dc6ad79ff0894044f34b2eb506136d54e4c9366
parent
a0ddc0f91f315a6b08d48af7c9ddf6dda98b89b0
author
FelineStateMachine <dami.etoile@gmail.com>
32 files changed, +1939 -96
Mapps/reference/deno.json1+
Mapps/reference/deno.lock2+-
Mapps/reference/src/permissions.ts2+-
Mapps/reference/src/schema.ts8+-
Mapps/reference/tests/author-boundary_test.ts15+-
Mdeno.json8+-
Mdocs/README.md12+-
Mdocs/data-and-ui.md89++++-
Adocs/decisions/schema-facade-alpha53.md127+++++++
Mdocs/devx-contract.md20+-
Mdocs/examples/collaborative-list.md98++++--
Adocs/examples/collaborative-sets.md46+++
Mdocs/examples/group.md2+-
Adocs/examples/payloads.md44++
Adocs/examples/policy-conditions.md59+++
Adocs/examples/schema-evolution.md70++++
Mdocs/examples/shared.md2+-
Mdocs/permissions.md14+-
Mpackage/astro/mod.ts15+
Mpackage/astro/vendored_manifest_test.ts3+-
Mpackage/create_core.ts1+
Mpackage/create_core_test.ts5+-
Apackage/schema/conformance_test.ts537++++++++++++++++++++++++++++++
Apackage/schema/merge_sync_test.ts346+++++++++++++++++++
Apackage/schema/migration_test.ts215++++++++++++
Apackage/schema/mod.ts110++++++
Apackage/schema/mod_test.ts57+++
Apackage/testdata/bytes_short_payload_canary.ts42++
Apackage/testdata/gset_hang_canary.ts62+++
Mpackage/testdata/starter.snapshot.json8+-
Mtools/golden.ts2+-
Mwebsite/docs-manifest.json13+-
M apps/reference/deno.json +1 -0
M apps/reference/deno.lock +1 -1
M apps/reference/src/permissions.ts +1 -1
M apps/reference/src/schema.ts +4 -4
M apps/reference/tests/author-boundary_test.ts +3 -12
@@ -12,8 +12,8 @@ const authorUiFiles = [
1212 new URL("../src/islands/use-tasks.ts", import.meta.url),1313 ];1414 15-// schema.ts and permissions.ts are the starter's two deliberate raw-Jazz16-// surfaces; every other rule still applies to them.15+// schema.ts and permissions.ts declare data through @nzip/lofi/schema; the16+// full rule set applies to them, raw Jazz imports included.1717 const schemaSurfaceFiles = [1818 new URL("../src/schema.ts", import.meta.url),1919 new URL("../src/permissions.ts", import.meta.url),
@@ -32,7 +32,6 @@ const forbidden = [
3232 { pattern: /workbox/i, name: "Workbox configuration" },3333 { pattern: /navigator\.|globalThis\.(?:isSecureContext|SharedWorker)/, name: "browser branch" },3434 ];35-const forbiddenOnSchemaSurface = forbidden.filter((rule) => rule.name !== "raw Jazz import");3635 3736 test("author source consumes public lofi package seams and hides plumbing from product UI", async () => {3837 for (
@@ -55,7 +54,7 @@ test("author source consumes public lofi package seams and hides plumbing from p
5554 throw new Error(`${file.pathname} imports vendored framework code`);5655 }5756 }58- for (const file of authorUiFiles) {57+ for (const file of [...authorUiFiles, ...schemaSurfaceFiles]) {5958 const source = await Deno.readTextFile(file);6059 for (const rule of forbidden) {6160 if (rule.pattern.test(source)) {
@@ -63,12 +62,4 @@ test("author source consumes public lofi package seams and hides plumbing from p
6362 }6463 }6564 }66- for (const file of schemaSurfaceFiles) {67- const source = await Deno.readTextFile(file);68- for (const rule of forbiddenOnSchemaSurface) {69- if (rule.pattern.test(source)) {70- throw new Error(`${file.pathname} exposes forbidden ${rule.name}`);71- }72- }73- }7465 });
M deno.json +6 -2
@@ -14,6 +14,7 @@
1414 "./preview": "./package/commands/preview.ts",1515 "./preact": "./package/preact/mod.ts",1616 "./provision": "./package/commands/provision.ts",17+ "./schema": "./package/schema/mod.ts",1718 "./recipes/file-handler": "./package/recipes/file-handler.ts",1819 "./recipes/launch-handler": "./package/recipes/launch-handler.ts",1920 "./recipes/protocol-handler": "./package/recipes/protocol-handler.ts",
@@ -50,11 +51,12 @@
5051 },5152 "tasks": {5253 "build": "deno task --cwd apps/reference build",53- "check": "deno fmt --check . && deno lint && deno task doc && deno task docs:links && deno task test:unit && deno task test:access",54+ "check": "deno fmt --check . && deno lint && deno task doc && deno task docs:links && deno task test:unit && deno task test:access && deno task test:conformance",5455 "doc": "deno run --allow-run tools/doc_lint.ts",5556 "docs:links": "deno run --allow-read tools/docs_links.ts",56- "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/testing package/tooling apps/reference/tests",57+ "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/schema/mod_test.ts package/testing package/tooling apps/reference/tests",5758 "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",59+ "test:conformance": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/merge_sync_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/migration_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/conformance_test.ts",5860 "test:update:create": "LOFI_UPDATE_SNAPSHOT=1 deno test --allow-env=LOFI_UPDATE_SNAPSHOT --allow-read --allow-write=. --allow-run package/create_core_test.ts",5961 "test": "deno task test:unit",6062 "test:browser:install": "deno run -A npm:playwright@1.61.1 install chromium",
@@ -87,7 +89,9 @@
8789 "docs/application-origin-migration.md",8890 "docs/auth-identity.md",8991 "docs/auth.md",92+ "docs/decisions/group-creator-authority-alpha53.md",9093 "docs/decisions/passkey-backup-alpha53.md",94+ "docs/decisions/schema-facade-alpha53.md",9195 "docs/data-and-ui.md",9296 "docs/deployment.md",9397 "docs/getting-started.md",
M docs/README.md +9 -3
M docs/data-and-ui.md +79 -10
@@ -1,11 +1,12 @@
11 # Data and UI22 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:3+Lofi keeps realtime plumbing out of product components. Authors declare tables and policies through4+the lofi schema surface, build exact typed queries, and compose two package hooks inside a domain5+hook:56 67 ```mermaid78 flowchart LR8- Schema["Typed Jazz schema"] --> Policy["Private, Shared, or Group policy"]9+ Schema["Typed app schema"] --> Policy["Private, Shared, or Group policy"]910 Schema --> Query["Typed scoped query"]1011 Query --> Live["useLiveQuery"]1112 Table["Typed table"] --> Mutations["useTableMutations"]
@@ -20,7 +21,7 @@ errors, and durability tracking. `@nzip/lofi/preact` owns only the Preact lifecy
2021 ## Declare a table and policy2122 2223 ```ts23-import { schema as s } from "jazz-tools";24+import { s } from "@nzip/lofi/schema";2425 2526 const schema = {2627 records: s.table({
@@ -34,16 +35,82 @@ const schema = {
3435 export const app = s.defineApp(schema);3536 ```3637 37-`src/schema.ts` and `src/permissions.ts` are the two deliberate raw-Jazz surfaces in author source.38-UI islands stay on public package seams: derive row types with `RowOf` from `@nzip/lofi`39-(`type Record = RowOf<typeof app.schema.records>`) instead of importing the vendor module, and the40-generated author-boundary test enforces exactly that split.38+`@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 absorb40+upstream changes (see [the decision record](decisions/schema-facade-alpha53.md)). UI islands stay on41+public package seams: derive row types with `RowOf` from `@nzip/lofi`42+(`type Record = RowOf<typeof app.schema.records>`), and the generated author-boundary test rejects43+raw `jazz-tools` imports in every author source file.4144 4245 Every table needs a policy in `src/permissions.ts`. Permissions determine which rows enter a live4346 query and which mutations succeed; realtime access is not a separate permission mode. See4447 [Permissions](permissions.md), [direct sharing](examples/shared.md), and4548 [group ownership](examples/group.md).4649 50+## Column palette51+52+Every 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.54+55+| 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 })`. |67+68+Constraints of the pinned alpha, each pinned by a conformance test that fails when upstream lifts69+it:70+71+- **`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()`.76+77+### Column modifiers and merge strategies78+79+Modifiers chain on any constructor: `.optional()` makes the column nullable, `.default(value)` fills80+omitted inserts, and `.merge(strategy)` picks a conflict strategy for concurrent writers.81+`.transform({ from, to })` (experimental upstream) maps stored values to a different TypeScript view82+type: inserts, reads, and updates use the view type, while `where` filters address the stored83+representation. In the pinned alpha `.merge()` and `.transform()` return the untyped builder (the84+legacy untyped signatures shadow the typed overloads), which degrades the whole table's row types —85+cast the result back to the intended column type; the runtime object is unchanged:86+87+```ts88+import { type ArrayColumn, type IntColumn, s } from "@nzip/lofi/schema";89+90+export 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+```97+98+The 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.x100+CoValue types (`co.map`, CoText, FileStream). All three are verified with two synced clients in101+`package/schema/merge_sync_test.ts`:102+103+- **`"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)).113+47114 ## Bind an exact typed query48115 49116 ```ts
@@ -140,10 +207,12 @@ export function useWorkspaceRecords(workspaceId: string) {
140207 Components consume `createRecord` and `archiveRecord`; they do not call `getRuntime`, manage Jazz141208 subscriptions, or listen for runtime recreation. The generated task hook is the smallest working142209 example. For a complete access-aware composition, see the143-[collaborative list recipe](examples/collaborative-list.md).210+[collaborative list data model](examples/collaborative-list.md).144211 145212 ## Schema changes and migrations146213 147214 Use `deno task schema:validate` while editing. For existing data, create and review a migration with148215 `deno task migrations:create`, then use `migrations:push` and `schema:deploy` only with the intended149-managed configuration. Never put server-only secrets in source or browser code.216+managed configuration. Never put server-only secrets in source or browser code. The217+[schema evolution example](examples/schema-evolution.md) walks through a verified two-version218+migration — add, drop, rename, and table rename — in both directions.
A docs/decisions/schema-facade-alpha53.md +127 -0
@@ -0,0 +1,127 @@
1+# Lofi-owned schema facade over the Jazz 2 DSL2+3+Date: 2026-07-18\4+Reviewed dependency: `jazz-tools@2.0.0-alpha.53`\5+Decision: adopt a curated one-to-one re-export; reject a translating facade6+7+## Question8+9+The developer-experience contract deferred whether a lofi-owned schema facade is "honest and10+affordable" (previously gated on #7). Until now `src/schema.ts` and `src/permissions.ts` were the11+two deliberate raw-Jazz surfaces in author source, importing `schema as s` from `jazz-tools`12+directly.13+14+## Context and control15+16+The baseline (control) is the M1 arrangement: direct pinned `jazz-tools` imports in author schema17+and permission files, with the author-boundary test exempting those two files from its raw-Jazz18+rule. Two facts changed the trade-off:19+20+1. Jazz 2 replaced the publicly documented 1.x CoValue API (`co.map`, `co.list`, `co.feed`, CoText,21+ FileStream) with a relational table DSL. Public Jazz documentation describes 1.x, so a22+ transparent surface delegates authors to documentation that is wrong for the pinned version.23+2. Alpha releases change behavior without notice (see24+ [group-creator-authority-alpha53](group-creator-authority-alpha53.md)); every schema author was25+ exposed to that churn directly.26+27+## Decision28+29+Adopt the **re-export kind** of facade and reject the **translating kind**:30+31+- `@nzip/lofi/schema` exports `s`, a curated `Pick` of the pinned Jazz 2 `schema` namespace. Every32+ exposed member is the Jazz original by object identity — verified by test — so names, semantics,33+ error messages, and tooling output cannot drift from what runs underneath. The module also34+ re-exports the DSL's schema-authoring types (`App`, `Schema`, `InsertOf`, `WhereOf`, and related).35+- Curation is by omission only: the deprecated `col.rename` and the unexercised `defineSliceableApp`36+ are left out. Nothing is renamed, wrapped, or reinterpreted; a translating facade (a lofi-invented37+ schema language) remains rejected as a non-goal.38+- Author files declare data exclusively through `@nzip/lofi/schema`. The generated author-boundary39+ test now applies the raw-Jazz rule to every author source file with no exemptions. Direct40+ `jazz-tools` imports remain possible as an explicitly unsupported escape hatch, per the existing41+ escape-hatch row of the contract.42+43+Honest: the facade translates nothing, so it cannot misrepresent the vendor surface. Affordable: it44+re-exports rather than reimplements, so its maintenance cost is the curation list and one identity45+test.46+47+## Procedure and evidence48+49+- `package/schema/mod.ts` — the facade; `package/schema/mod_test.ts` verifies member identity50+ against `jazz-tools` and round-trips `defineApp`/`definePermissions` through the facade.51+- `package/schema/merge_sync_test.ts` — concurrent-writer merge semantics (lww, counter, g-set) over52+ a real JazzServer with two synced clients and a fresh observer.53+- `package/schema/migration_test.ts` — the schema-evolution surface (`defineSchema`,54+ `defineMigration`, `renameTableFrom`, `add`/`drop`/`renameFrom`) over a real JazzServer: forward55+ and backward lens directions plus the missing-migration deploy guardrail.56+- `apps/reference/tests/author-boundary_test.ts` — rejects `jazz-tools` imports in all author57+ source, including `schema.ts` and `permissions.ts`.58+- `deno task check` and `deno task build` pass with the reference app (and therefore the starter,59+ which copies it) migrated to the facade.60+61+## Contract delta62+63+The author-boundary rows for `schema.ts` and `permissions.ts` no longer allow direct pinned Jazz64+declarations; both declare through `@nzip/lofi/schema`. The schema safe default changes from "use65+the smallest verified Jazz 2 surface" to "expose the pinned DSL through the re-export, never a66+second schema language." Version bumps of `jazz-tools` become package releases: upstream renames are67+absorbed by the facade instead of breaking application schemas.68+69+## Conformance findings (alpha.53)70+71+The column-type conformance suite (`package/schema/conformance_test.ts`, task72+`deno task test:conformance`, part of `deno task check`) exercises every facade column type through73+the official policy test harness against the real engine. Verified working end to end: string,74+boolean, int (within i32), float, timestamp storage, enum, ref, json, array, bytes (≥32-byte75+payloads observed reliable), `.optional()`, `.default()`, `.transform()` (view type on76+insert/read/update; `where` filters take the stored type), lww merge (the default; concurrent77+conflicts resolve to the last write to reach the server on every replica), counter merge78+(single-session), g-set merge (single-table apps, cross-writer union), policy conditions over typed79+columns with `anyOf` alternatives, `permissionIntrospectionColumns` (`$canRead`/`$canEdit`/80+`$canDelete` selected per row, mirroring the deployed policy), and the full migration surface81+(`defineSchema`, `defineMigration`, `renameTableFrom`, `add`/`drop`/`renameFrom`; forward and82+backward lens directions carry data correctly, and deploying a changed schema without its migration83+reports `status: "missing"` and withholds permissions). The three merge strategies (`lww`,84+`counter`, `g-set`) are the complete collaborative-value surface of the pinned alpha — Jazz 285+exports no successor to the 1.x CoValue types. Engine and type bugs found and pinned in the suite so86+an alpha bump surfaces any change:87+88+1. **`.merge()` and `.transform()` erase column typing.** The legacy untyped signatures89+ (`merge(): this`, `transform(): ColumnBuilder`) shadow the typed overloads, degrading the column90+ to `ColumnBuilder` and poisoning the whole table's row types. Runtime unaffected. Workaround91+ documented on `@nzip/lofi/schema`: cast the result back (e.g.92+ `as unknown as93+ IntColumn<false, true>`, `as unknown as StringColumn<false, false, string[]>`).94+2. **Int columns are i32 at runtime.** Values outside i32 are rejected with95+ `InvalidArg … expected96+ i32` despite the `number` static type. Pinned.97+3. **Timestamp where-equality matches every row** (verified with distinct non-epoch dates), and98+ running that query wedges the FFI driver: the next harness boot in the same process hangs on its99+ first write. Pinned; the scalar test deliberately runs last.100+4. **A g-set column destabilizes cross-table writes.** In a process that has booted harnesses101+ before, writing a sibling table before the g-set table's first write hangs indefinitely102+ (reproduced five times in bisection); a fresh process may not reproduce it. Covered by a tolerant103+ subprocess canary; g-set coverage stays isolated in a single-table app.104+5. **Short byte payloads are unreliable.** A 2-byte insert has been observed succeeding, failing105+ with `WriteError … data too short for column value`, and hanging, depending on process context.106+ Covered by a tolerant subprocess canary.107+6. **Counter merge semantics are inconsistent across replica lifetimes.** Verified with two synced108+ clients plus a fresh observer (`package/schema/merge_sync_test.ts`): the server keeps the last109+ causally ordered update value and sums only concurrent updates, while live replicas apply every110+ update — including the echo of their own reconnected write — as a delta. A replica that watched111+ the history diverges permanently from what a fresh boot reads (observed: canonical 8, first112+ writer 13 after a concurrent 5/3 conflict on 0; canonical 3, live replicas 5 after causal 2 then113+ 3). The full matrix is pinned. G-set union merge, by contrast, is verified convergent across114+ writers and fresh boots.115+116+Hangs cannot be cancelled through FFI, so the canaries run in child processes that force-exit117+(`package/testdata/gset_hang_canary.ts`, `package/testdata/bytes_short_payload_canary.ts`). The118+concurrent-writer suite runs before the conformance suite in a separate process because the scalar119+test wedges the FFI driver last.120+121+## Follow-up122+123+- Re-verify all pins on each alpha bump and remove the ones upstream fixes; findings 2–6 stay124+ internal until our own testing is complete and a report is explicitly decided.125+- Merge the g-set table back into the main conformance app when the cross-table hang is gone.126+- Revisit the curation list on each alpha bump; `defineSliceableApp` joins the surface when a use127+ case and tests exist.
M docs/devx-contract.md +10 -10
@@ -184,19 +184,19 @@ pinned package; they are not author-editing surfaces.
184184 185185 ## Author boundary186186 187-| Location | Default allowance | M1 qualification |188-| ------------------------ | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |189-| Product UI/pages/islands | lofi components/hooks and application types only. | Raw Jazz, worker, transport, Workbox, and capability branching are forbidden. |190-| `schema.ts` | Application schema declarations. | Direct pinned Jazz 2 schema declarations are allowed in the M1 prototype until #7 determines whether a lofi-owned schema facade is honest and affordable. |191-| `permissions.ts` | Application access policy next to the schema. | Direct pinned Jazz 2 permission declarations are allowed; Jazz tooling watches this location. |192-| `app.ts` | Named lofi configuration and composition. | Raw vendor setup is allowed only inside spike controls, not the graduated prototype. |193-| Package runtime | Vendor clients, workers, storage, sync, auth, and PWA wiring. | May change across package pins without changing product UI. |194-| Escape-hatch module | Explicitly isolated unsupported vendor access. | Never generated by default; package upgrades provide no compatibility promise for vendor internals. |187+| Location | Default allowance | M1 qualification |188+| ------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |189+| Product UI/pages/islands | lofi components/hooks and application types only. | Raw Jazz, worker, transport, Workbox, and capability branching are forbidden. |190+| `schema.ts` | Application schema declarations. | Declared through `@nzip/lofi/schema`, the curated one-to-one re-export of the pinned Jazz 2 DSL; see `docs/decisions/schema-facade-alpha53.md`. |191+| `permissions.ts` | Application access policy next to the schema. | Declared through the same `@nzip/lofi/schema` surface; Jazz tooling watches this location. |192+| `app.ts` | Named lofi configuration and composition. | Raw vendor setup is allowed only inside spike controls, not the graduated prototype. |193+| Package runtime | Vendor clients, workers, storage, sync, auth, and PWA wiring. | May change across package pins without changing product UI. |194+| Escape-hatch module | Explicitly isolated unsupported vendor access. | Never generated by default; package upgrades provide no compatibility promise for vendor internals. |195195 196196 ## Safe defaults197197 198-- **Schema:** use the smallest verified Jazz 2 surface during M1; do not invent a second schema199- language before #7.198+- **Schema:** expose the pinned Jazz 2 DSL through the `@nzip/lofi/schema` re-export — identical199+ names and semantics, curated to the tested surface; never invent a second schema language.200200 - **Identity:** create or restore the verified local-first identity without a server round trip;201201 passkey backup is never mislabeled as conventional login.202202 - **Sync:** no config means clearly reported local-only mode if supported; a complete public pair
M docs/examples/collaborative-list.md +64 -34
@@ -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 ```ts9-import { schema as s } from "jazz-tools";14+// src/schema.ts15+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
A docs/examples/collaborative-sets.md +46 -0
@@ -0,0 +1,46 @@
1+# Collaborative sets2+3+A g-set column is the right shape when several people add elements to the same collection and4+nobody's contribution may be lost: concurrent writers union their elements, and every replica —5+including a fresh boot — converges on the same set. This is verified with two synced clients in6+`package/schema/merge_sync_test.ts`.7+8+## Declare the set9+10+```ts11+// src/schema.ts12+import { type ArrayColumn, s } from "@nzip/lofi/schema";13+14+export const app = s.defineApp({15+ tagged: s.table({16+ name: s.string(),17+ tags: s.array(s.string()).merge("g-set") as unknown as ArrayColumn<"TEXT">,18+ }),19+});20+```21+22+Two constraints of the pinned alpha shape this declaration:23+24+- **The cast is required.** `.merge()` returns the untyped builder (a pinned upstream type bug); the25+ cast restores the array column type. The runtime object is unchanged.26+- **Keep the g-set table in its own single-table app.** A g-set column destabilizes writes to27+ sibling tables in the same app — the conformance canary pins the hang. When the model also needs28+ ordinary tables, they belong in a separate app until upstream stabilizes this.29+30+## Write whole sets, converge on the union31+32+Writers always submit their full local set; the merge takes care of the rest:33+34+```ts35+const doc = await db.insert(app.tagged, { name: "doc", tags: ["a"] }).wait({ tier: "global" });36+37+// Two clients, offline at the same time:38+alice.update(app.tagged, doc.id, { tags: ["a", "b"] });39+bob.update(app.tagged, doc.id, { tags: ["a", "c"] });40+41+// After both reconnect, every replica reads ["a", "b", "c"].42+```43+44+The set is grow-only: the verified behavior is that concurrent writes union. Removing an element is45+not part of the verified surface — if entries must be retractable, model the collection as its own46+table with one row per element and delete rows under an ordinary policy instead.
M docs/examples/group.md +1 -1
A docs/examples/payloads.md +44 -0
@@ -0,0 +1,44 @@
1+# Binary and structured payloads2+3+Two column types carry non-scalar values: `s.bytes()` for binary data and `s.json()` for structured4+data. Both round-trip through the engine byte- and shape-exact — verified in5+`package/schema/conformance_test.ts`.6+7+## Declare the payload columns8+9+```ts10+// src/schema.ts11+import { s } from "@nzip/lofi/schema";12+13+export const app = s.defineApp({14+ attachments: s.table({15+ fileName: s.string(),16+ thumbnail: s.bytes(),17+ metadata: s.json(),18+ }),19+});20+```21+22+```ts23+const attachment = await db.insert(app.attachments, {24+ fileName: "cover.png",25+ thumbnail: new Uint8Array(await blob.arrayBuffer()),26+ metadata: { width: 320, height: 180, palette: ["#282828", "#ebdbb2"] },27+}).wait({ tier: "global" });28+// Reads return the same Uint8Array bytes and the same nested JSON shape.29+```30+31+## Choosing between them32+33+- **`s.bytes()`** stores an opaque `Uint8Array`: thumbnails, hashes, encrypted blobs, anything the34+ app treats as bytes. One pinned constraint: payloads under 32 bytes are unreliable in the current35+ alpha — pad short values, or store small binary-ish data as `s.json()` until upstream fixes it.36+- **`s.json()`** stores any JSON value and reads back the same nested shape. Reach for it when the37+ data is a document the app consumes whole. When you need to _filter_ on a field, promote that38+ field to its own typed column instead — `where` addresses columns, not JSON internals.39+- **`s.array(inner)`** sits between the two: use it when the value is a flat list of one typed40+ element and `s.json()` when the shape nests.41+42+Rows travel through sync whole, so a payload column is for data sized like a row — icons and43+metadata, not video. Large media belongs outside the table with a payload column holding the44+reference (hash, URL, or identifier).
A docs/examples/policy-conditions.md +59 -0
@@ -0,0 +1,59 @@
1+# Policy conditions on typed columns2+3+The access templates ([direct sharing](shared.md), [fixed-role group](group.md)) cover the common4+collaboration shapes. When they don't fit, shape the policy yourself with `s.definePermissions` —5+the same surface the generated starter uses for its owner-only default. Conditions match on magic6+columns (`$createdBy`) and on your own typed columns alike; both are conformance-verified.7+8+## Gate rows on an application column9+10+A moderation flow where drafts are private until published, and published rows become undeletable:11+12+```ts13+// src/permissions.ts14+import { s } from "@nzip/lofi/schema";15+import { app } from "./schema.ts";16+17+export default s.definePermissions(app, ({ policy, session, anyOf }) => {18+ policy.posts.allowInsert.always();19+ // Readable when published, or always by the author.20+ policy.posts.allowRead.where(anyOf([21+ { published: true },22+ { $createdBy: session.user_id },23+ ]));24+ policy.posts.allowUpdate.where({ $createdBy: session.user_id });25+ // Only unpublished drafts may be deleted.26+ policy.posts.allowDelete.where({ published: false, $createdBy: session.user_id });27+});28+```29+30+`anyOf` composes alternatives: a row enters live queries when any branch matches, and an object31+condition with several keys requires all of them. Policy conditions are the filter of record — a row32+that fails them never reaches the client, so the UI does not need defensive re-checks.33+34+## Let the UI ask what it may do35+36+Rather than re-deriving the policy in components, select the permission introspection columns and37+render from them:38+39+```ts40+import { s } from "@nzip/lofi/schema";41+42+const posts = useLiveQuery(43+ () =>44+ app.schema.posts45+ .select("title", "published", ...s.permissionIntrospectionColumns)46+ .where({}),47+ [],48+);49+// Each row carries $canRead, $canEdit, and $canDelete, evaluated for the50+// current session against the deployed policy.51+```52+53+A row's `$canDelete` is `false` exactly when the delete rule above rejects it, so a delete button54+can disable itself without duplicating the condition. If the policy changes later, the UI follows55+the deployment instead of drifting.56+57+Test custom policies the same way as the templates: at least two isolated identities, one row that58+each rule admits and one it rejects, and a rejected mutation surfacing through the mutation hook's59+`error` state.
A docs/examples/schema-evolution.md +70 -0
@@ -0,0 +1,70 @@
1+# Schema evolution2+3+A deployed schema changes with a migration: a bidirectional lens between two schema versions. Rows4+written under the old schema read correctly through the new one, and — because the backward5+direction is generated automatically — clients still on the old schema keep reading rows written6+under the new one. The whole surface below is verified against a real server in7+`package/schema/migration_test.ts`.8+9+## Two versions of the schema10+11+The migration names both versions explicitly, so keep the outgoing version around as a value:12+13+```ts14+import { s } from "@nzip/lofi/schema";15+16+const v1 = s.defineSchema({17+ todos: s.table({ title: s.string(), done: s.boolean(), detail: s.string() }),18+ projects: s.table({ name: s.string() }),19+});20+21+const v2 = s.defineSchema({22+ todos: s.table({23+ title: s.string(),24+ note: s.string(),25+ priority: s.enum("low", "medium", "high"),26+ }),27+ workspaces: s.table({ name: s.string() }),28+});29+30+export const app = s.defineApp(v2);31+```32+33+## Describe the change, both directions come free34+35+```ts36+export const migration = s.defineMigration({37+ from: v1,38+ to: v2,39+ renameTables: { workspaces: s.renameTableFrom("projects") },40+ migrate: {41+ todos: {42+ priority: s.add.enum("low", "medium", "high", { default: "medium" }),43+ note: s.renameFrom("detail"),44+ done: s.drop.boolean({ backwardsDefault: false }),45+ },46+ },47+});48+```49+50+Every added or removed table and column must be covered — `renameTables`/`createTables`/51+`dropTables` at the table level, `s.add.*`, `s.drop.*`, and `s.renameFrom` at the column level — and52+the definition fails to type-check until it is. `s.add.*` requires a `default` (what old rows show53+under the new schema); `s.drop.*` requires a `backwardsDefault` (what old clients see on rows54+written without the column).55+56+Verified behavior across the deployment:57+58+- A v1 row reads under v2 with `note` carrying the old `detail` value, `priority` defaulted to59+ `"medium"`, no `done`, and `projects` rows answering under `workspaces`.60+- A v2-written row reads under v1 handles with the rename reversed and `done: false` from the61+ backwards default; the v2-only `priority` column does not leak.62+- Deploying a changed schema **without** its migration publishes nothing usable: the deploy reports63+ `migration: { status: "missing" }` and withholds the permissions, so clients never see a schema64+ they cannot read data through.65+66+## Day to day67+68+Use `deno task schema:validate` while editing. For a deployed app, generate the migration stub with69+`deno task migrations:create`, fill in the ops as above, review it, then publish with70+`migrations:push` and `schema:deploy` under the intended managed configuration.
M docs/examples/shared.md +1 -1
M docs/permissions.md +7 -7
@@ -8,7 +8,7 @@ identity may read or mutate each table; hiding a button in the UI is not a subst
88 The generated task app uses creator-owned rows:99 1010 ```ts11-import { schema as s } from "jazz-tools";11+import { s } from "@nzip/lofi/schema";1212 import { app } from "./schema.ts";1313 1414 export default s.definePermissions(app, ({ policy, session }) => {
@@ -62,10 +62,10 @@ The starter remains private by default. When a resource has a concrete collabora
6262 | `sharedAccess` | Creator plus explicit recipients | Creator; recipients with editable grants may update | Required |6363 | `groupAccess` | Group members | Determined by the fixed group role | Required |6464 65-The schema remains raw Jazz:65+The schema stays on the lofi schema surface:6666 6767 ```ts68-import { schema as s } from "jazz-tools";68+import { s } from "@nzip/lofi/schema";6969 import { groupMembershipTable, sharedGrantTable } from "@nzip/lofi/access";7070 7171 const schema = {
@@ -92,9 +92,9 @@ export default defineAccessPolicies(app, [
9292 ]);9393 ```9494 95-This is not a general schema facade. Use `s.table()`, `s.defineApp()`, and `s.definePermissions()`96-directly whenever the templates do not fit. `defineAccessPolicies` also accepts a final raw-policy97-callback for a small extension without hiding Jazz.95+The templates do not replace the schema surface. Use `s.table()`, `s.defineApp()`, and96+`s.definePermissions()` directly whenever they do not fit. `defineAccessPolicies` also accepts a97+final raw-policy callback for a small extension without hiding the underlying policy builder.9898 9999 ## Fixed group roles100100
@@ -107,7 +107,7 @@ callback for a small extension without hiding Jazz.
107107 108108 The helper stores the role label plus fixed capability bits because the pinned Jazz alpha rejects109109 role-string comparisons inside relationship policies. Only the four role names are accepted by the110-typed operations; custom roles belong in a raw Jazz policy.110+typed operations; custom roles belong in a raw policy callback.111111 112112 **The group creator holds a permanent superseat.** Whoever created the group row can always update113113 it and — because membership management derives from group-update authority — can always restore
M package/astro/mod.ts +15 -0
@@ -61,6 +61,11 @@ export const accessFiles = [
6161 "schema.ts",6262 ] as const;6363 64+/** Schema facade modules vendored into `.lofi/`; kept in lockstep by the manifest test. */65+export const schemaFiles = [66+ "mod.ts",67+] as const;68+6469 /** Preact modules vendored into `.lofi/`; kept in lockstep by the manifest test. */6570 export const preactFiles = [6671 "DeviceStatus.tsx",
@@ -96,6 +101,7 @@ function renderConfig(projectRoot: string): string {
96101 const runtimeEntry = join(projectRoot, ".lofi", "package", "runtime", "mod.ts");97102 const accessEntry = join(projectRoot, ".lofi", "package", "access", "mod.ts");98103 const preactEntry = join(projectRoot, ".lofi", "package", "preact", "mod.ts");104+ const schemaEntry = join(projectRoot, ".lofi", "package", "schema", "mod.ts");99105 const fileHandlerRecipe = join(projectRoot, ".lofi", "package", "recipes", "file-handler.ts");100106 const launchHandlerRecipe = join(101107 projectRoot,
@@ -195,6 +201,7 @@ export default defineConfig({
195201 } },196202 { find: /^jsr:@nzip\\/lofi@[^/]+\\/preact$/, replacement: ${JSON.stringify(preactEntry)} },197203 { find: /^jsr:@nzip\\/lofi@[^/]+\\/access$/, replacement: ${JSON.stringify(accessEntry)} },204+ { find: /^jsr:@nzip\\/lofi@[^/]+\\/schema$/, replacement: ${JSON.stringify(schemaEntry)} },198205 { find: /^jsr:@nzip\\/lofi@[^/]+$/, replacement: ${JSON.stringify(runtimeEntry)} },199206 { find: /^npm:preact@[^/]+\\/hooks$/, replacement: "preact/hooks" },200207 { find: /^npm:preact@[^/]+\\/jsx-dev-runtime$/, replacement: "preact/jsx-dev-runtime" },
@@ -224,6 +231,7 @@ export default defineConfig({
224231 } },225232 { find: "@nzip/lofi/preact", replacement: ${JSON.stringify(preactEntry)} },226233 { find: "@nzip/lofi/access", replacement: ${JSON.stringify(accessEntry)} },234+ { find: "@nzip/lofi/schema", replacement: ${JSON.stringify(schemaEntry)} },227235 { find: "@nzip/lofi", replacement: ${JSON.stringify(runtimeEntry)} },228236 ],229237 noExternal: ["@astrojs/preact"],
@@ -249,6 +257,7 @@ export async function prepareLofiAstroConfig(options: LofiAstroOptions = {}): Pr
249257 await Deno.mkdir(join(packageDir, "access"), { recursive: true });250258 await Deno.mkdir(join(packageDir, "preact"), { recursive: true });251259 await Deno.mkdir(join(packageDir, "recipes"), { recursive: true });260+ await Deno.mkdir(join(packageDir, "schema"), { recursive: true });252261 for (const file of runtimeFiles) {253262 await Deno.writeTextFile(254263 join(packageDir, "runtime", file),
@@ -273,6 +282,12 @@ export async function prepareLofiAstroConfig(options: LofiAstroOptions = {}): Pr
273282 await readPackageFile(`../recipes/${file}`),274283 );275284 }285+ for (const file of schemaFiles) {286+ await Deno.writeTextFile(287+ join(packageDir, "schema", file),288+ await readPackageFile(`../schema/${file}`),289+ );290+ }276291 const configSource = renderConfig(projectRoot);277292 let current = "";278293 try {
M package/astro/vendored_manifest_test.ts +2 -1
M package/create_core.ts +1 -0
M package/create_core_test.ts +3 -2
A package/schema/conformance_test.ts +537 -0
@@ -0,0 +1,537 @@
1+// Column-type conformance: every data shape the schema facade exposes must2+// round-trip through the real pinned Jazz engine — insert, read-back, where3+// filtering, update, modifiers, and policy matching. A member that only4+// compiles is not supported; this suite is what fails when an alpha bump5+// changes engine behavior for a column type the facade exports.6+//7+// Uses only the facade surface (`s` from ./mod.ts) so the suite proves what an8+// application author can actually reach.9+import { createPolicyTestApp } from "jazz-tools/testing";10+import { assert } from "../runtime/test-assert.ts";11+import { type ArrayColumn, type IntColumn, s, type StringColumn } from "./mod.ts";12+13+const app = s.defineApp({14+ parents: s.table({ name: s.string() }),15+ strings: s.table({ value: s.string() }),16+ booleans: s.table({ value: s.boolean() }),17+ ints: s.table({ value: s.int() }),18+ floats: s.table({ value: s.float() }),19+ timestamps: s.table({ value: s.timestamp() }),20+ blobs: s.table({ value: s.bytes() }),21+ jsons: s.table({ value: s.json() }),22+ enums: s.table({ value: s.enum("draft", "published", "archived") }),23+ refs: s.table({ parentId: s.ref("parents"), label: s.string() }),24+ arrays: s.table({ value: s.array(s.string()) }),25+ modifiers: s.table({26+ required: s.string(),27+ note: s.string().optional(),28+ label: s.string().default("untitled"),29+ status: s.enum("open", "done", "dropped").default("open"),30+ // CONFORMANCE FINDING (alpha.53): `.merge()` returns the untyped builder31+ // because the legacy `merge(): this` signature shadows the typed overload,32+ // which poisons the whole table's row types. The cast restores the typed33+ // column; the runtime object is correct without it.34+ total: s.int().default(0).merge("counter") as unknown as IntColumn<false, true>,35+ }),36+ transformed: s.table({37+ // Stored as comma-joined TEXT, viewed as string[]. The untyped38+ // `transform(): ColumnBuilder` signature shadows the typed overload the39+ // same way `.merge()` does, so the cast restores the transformed value40+ // type.41+ tags: s.string().transform({42+ from: (value: string) => value.split(","),43+ to: (value: string[]) => value.join(","),44+ }) as unknown as StringColumn<false, false, string[]>,45+ label: s.string(),46+ }),47+ gated: s.table({ visible: s.boolean(), title: s.string() }),48+});49+50+// CONFORMANCE FINDING (alpha.53): a `g-set` column anywhere in a schema makes51+// writes to *other* tables in the same app hang forever (bisected via a52+// cumulative table-by-table probe; boots fine, first cross-table write never53+// resolves). The g-set coverage therefore lives in its own single-table app,54+// and the hang itself is pinned by the subprocess canary test below.55+const gsetApp = s.defineApp({56+ tagged: s.table({57+ name: s.string(),58+ tags: s.array(s.string()).merge("g-set") as unknown as ArrayColumn<"TEXT">,59+ }),60+});61+const gsetPermissions = s.definePermissions(gsetApp, ({ policy }) => {62+ policy.tagged.allowInsert.always();63+ policy.tagged.allowRead.always();64+ policy.tagged.allowUpdate.always();65+ policy.tagged.allowDelete.always();66+});67+68+const allowAllTables = [69+ "parents",70+ "strings",71+ "booleans",72+ "ints",73+ "floats",74+ "timestamps",75+ "blobs",76+ "jsons",77+ "enums",78+ "refs",79+ "arrays",80+ "modifiers",81+ "transformed",82+] as const;83+84+type RuleSet = {85+ always(): void;86+ where(condition: Record<string, unknown>): void;87+};88+type TableRules = Record<"allowInsert" | "allowRead" | "allowUpdate" | "allowDelete", RuleSet>;89+90+const permissions = s.definePermissions(app, ({ policy, session, anyOf }) => {91+ const rules = policy as unknown as Record<string, TableRules>;92+ for (const table of allowAllTables) {93+ rules[table].allowInsert.always();94+ rules[table].allowRead.always();95+ rules[table].allowUpdate.always();96+ rules[table].allowDelete.always();97+ }98+ // Policy conditions must match over a typed column, not only magic columns,99+ // and anyOf must compose alternatives. Delete is deliberately denied for100+ // visible rows so the permission introspection columns have something to101+ // discriminate.102+ policy.gated.allowInsert.always();103+ policy.gated.allowRead.where(anyOf([{ visible: true }, { $createdBy: session.user_id }]));104+ policy.gated.allowUpdate.always();105+ policy.gated.allowDelete.where({ visible: false });106+});107+108+type ThrowExpectation = {109+ toThrow(expected?: unknown): void;110+ not: { toThrow(expected?: unknown): void };111+};112+113+function expectLike(value: unknown): ThrowExpectation {114+ const run = (): unknown => {115+ if (typeof value !== "function") throw new Error("expectLike requires a callback");116+ try {117+ value();118+ return undefined;119+ } catch (error) {120+ return error ?? new Error("threw undefined");121+ }122+ };123+ const matches = (error: unknown, expected: unknown): boolean => {124+ if (expected === undefined) return error !== undefined;125+ const message = error instanceof Error ? error.message : String(error);126+ return typeof expected === "string"127+ ? message.includes(expected)128+ : error instanceof (expected as typeof Error);129+ };130+ return {131+ toThrow(expected?: unknown) {132+ const error = run();133+ if (!matches(error, expected)) {134+ throw new Error(`expected callback to throw ${String(expected)}`);135+ }136+ },137+ not: {138+ toThrow(expected?: unknown) {139+ const error = run();140+ if (error !== undefined && (expected === undefined || matches(error, expected))) {141+ const message = error instanceof Error ? error.message : String(error);142+ throw new Error(`expected callback not to throw, received: ${message}`);143+ }144+ },145+ },146+ };147+}148+149+const session = (user_id: string) => ({ user_id, claims: {}, authMode: "local-first" as const });150+151+function sameValue(actual: unknown, expected: unknown): boolean {152+ if (expected instanceof Date) {153+ return actual instanceof Date154+ ? actual.getTime() === expected.getTime()155+ : actual === expected.getTime();156+ }157+ if (expected instanceof Uint8Array) {158+ return actual instanceof Uint8Array &&159+ actual.length === expected.length &&160+ expected.every((byte, index) => actual[index] === byte);161+ }162+ if (Array.isArray(expected)) {163+ return Array.isArray(actual) &&164+ actual.length === expected.length &&165+ expected.every((item, index) => sameValue(actual[index], item));166+ }167+ if (expected !== null && typeof expected === "object") {168+ if (actual === null || typeof actual !== "object") return false;169+ const expectedEntries = Object.entries(expected);170+ return expectedEntries.length === Object.keys(actual).length &&171+ expectedEntries.every(([key, item]) =>172+ sameValue((actual as Record<string, unknown>)[key], item)173+ );174+ }175+ return actual === expected;176+}177+178+function describeValue(value: unknown): string {179+ if (value instanceof Uint8Array) return `Uint8Array(${value.length})[${value.join(",")}]`;180+ if (value instanceof Date) return `Date(${value.toISOString()})`;181+ return JSON.stringify(value);182+}183+184+// One representative and one contrasting value per scalar column family. The185+// contrasting value proves where-equality actually filters instead of186+// matching everything.187+const scalarCases = [188+ { table: "strings", value: "héllo wörld", other: "" },189+ { table: "booleans", value: true, other: false },190+ // CONFORMANCE FINDING (alpha.53): int columns are i32 at runtime despite191+ // the `number` static type — see the i64 rejection pin below.192+ { table: "ints", value: 2_147_483_640, other: -1 },193+ { table: "floats", value: 3.141592653589793, other: -0.5 },194+ // PINNED (alpha.53): where-equality on a timestamp column matches every195+ // row (verified with distinct non-epoch dates). When the pinned count196+ // below reads 1 again, upstream fixed the comparison — clear the flag.197+ {198+ table: "timestamps",199+ value: new Date("2026-07-18T12:34:56.789Z"),200+ other: new Date("2001-02-03T04:05:06.007Z"),201+ brokenWhereEquality: true,202+ },203+ { table: "enums", value: "published", other: "draft" },204+] as const;205+206+// The scalar sweep iterates tables dynamically, which steps outside the typed207+// query surface; these loose structural views cover only the sweep. The208+// structured-columns test below stays fully typed.209+type Row = { id: string } & Record<string, unknown>;210+type LooseHandle = { where(condition: Record<string, unknown>): unknown };211+type LooseDb = {212+ insert(table: unknown, values: unknown): { wait(opts: { tier: "global" }): Promise<Row> };213+ update(table: unknown, id: string, patch: unknown): { wait(opts: { tier: "global" }): unknown };214+ all(query: unknown): Promise<Row[]>;215+};216+217+Deno.test("facade structured columns (bytes, json, array, ref) round-trip through the real engine", async () => {218+ const testApp = await createPolicyTestApp(app, permissions, expectLike);219+ try {220+ const db = testApp.as(session("author"));221+222+ const payload = new Uint8Array(32).map((_, index) => (index * 37) % 256);223+ const blob = await db.insert(app.blobs, { value: payload }).wait({ tier: "global" });224+ const blobRows = await db.all(app.blobs.where({ id: blob.id }));225+ assert(226+ blobRows.length === 1 && sameValue(blobRows[0].value, payload),227+ `bytes: read back ${describeValue(blobRows[0]?.value)}`,228+ );229+230+ const document = { nested: { list: [1, "two", null], flag: true }, empty: {} };231+ const json = await db.insert(app.jsons, { value: document }).wait({ tier: "global" });232+ const jsonRows = await db.all(app.jsons.where({ id: json.id }));233+ assert(234+ jsonRows.length === 1 && sameValue(jsonRows[0].value, document),235+ `json: read back ${describeValue(jsonRows[0]?.value)}`,236+ );237+238+ const items = ["alpha", "beta", "beta"];239+ const array = await db.insert(app.arrays, { value: items }).wait({ tier: "global" });240+ const arrayRows = await db.all(app.arrays.where({ id: array.id }));241+ assert(242+ arrayRows.length === 1 && sameValue(arrayRows[0].value, items),243+ `array: read back ${describeValue(arrayRows[0]?.value)}`,244+ );245+246+ const parent = await db.insert(app.parents, { name: "parent" }).wait({ tier: "global" });247+ const decoy = await db.insert(app.parents, { name: "decoy" }).wait({ tier: "global" });248+ await db.insert(app.refs, { parentId: parent.id, label: "child" }).wait({ tier: "global" });249+ await db.insert(app.refs, { parentId: decoy.id, label: "other" }).wait({ tier: "global" });250+ const children = await db.all(app.refs.where({ parentId: parent.id }));251+ assert(252+ children.length === 1 && children[0].label === "child",253+ `ref: where on the foreign key returned ${children.length} rows`,254+ );255+ } finally {256+ await testApp.shutdown();257+ }258+});259+260+Deno.test("facade column modifiers (optional, default, counter, g-set) behave through the real engine", async () => {261+ const testApp = await createPolicyTestApp(app, permissions, expectLike);262+ try {263+ const db = testApp.as(session("author"));264+265+ const row = await db.insert(app.modifiers, { required: "present" })266+ .wait({ tier: "global" });267+ const readBack = (await db.all(app.modifiers.where({ id: row.id })))[0];268+ assert(readBack !== undefined, "modifiers: row lost after insert");269+ assert(270+ readBack.note === null || readBack.note === undefined,271+ `optional: omitted column read back as ${describeValue(readBack.note)}`,272+ );273+ assert(274+ readBack.label === "untitled",275+ `default: omitted column read back as ${describeValue(readBack.label)}`,276+ );277+ assert(278+ readBack.status === "open",279+ `enum default: omitted column read back as ${describeValue(readBack.status)}`,280+ );281+ await db.update(app.modifiers, row.id, { status: "done" }).wait({ tier: "global" });282+ const transitioned = (await db.all(app.modifiers.where({ id: row.id })))[0];283+ assert(284+ transitioned?.status === "done",285+ `enum: update read back ${describeValue(transitioned?.status)}`,286+ );287+ assert(288+ readBack.total === 0,289+ `counter default: read back ${describeValue(readBack.total)}`,290+ );291+292+ // Single-session semantics only; concurrent-writer merge behavior is293+ // pinned in ./merge_sync_test.ts, which runs first in its own process.294+ await db.update(app.modifiers, row.id, { total: 5 }).wait({ tier: "global" });295+ const bumped = (await db.all(app.modifiers.where({ id: row.id })))[0];296+ assert(bumped !== undefined, "modifiers: row lost after update");297+ assert(bumped.total === 5, `counter: update read back ${describeValue(bumped.total)}`);298+299+ await db.update(app.modifiers, row.id, { note: "written" }).wait({ tier: "global" });300+ const cleared = (await db.all(app.modifiers.where({ id: row.id })))[0];301+ assert(cleared?.note === "written", "optional: column did not accept a value");302+ } finally {303+ await testApp.shutdown();304+ }305+});306+307+Deno.test("g-set columns round-trip inside their own single-table app", async () => {308+ const testApp = await createPolicyTestApp(gsetApp, gsetPermissions, expectLike);309+ try {310+ const db = testApp.as(session("author"));311+ const row = await db.insert(gsetApp.tagged, { name: "first", tags: ["a"] })312+ .wait({ tier: "global" });313+ await db.update(gsetApp.tagged, row.id, { tags: ["a", "b"] }).wait({ tier: "global" });314+ const readBack = (await db.all(gsetApp.tagged.where({ id: row.id })))[0];315+ assert(readBack !== undefined, "g-set: row lost after update");316+ assert(317+ sameValue(readBack.tags, ["a", "b"]),318+ `g-set: update read back ${describeValue(readBack.tags)}`,319+ );320+ } finally {321+ await testApp.shutdown();322+ }323+});324+325+async function runGsetCanary(mode: "sibling-first" | "gset-first"): Promise<string> {326+ const script = new URL("../testdata/gset_hang_canary.ts", import.meta.url);327+ const repoRoot = new URL("../../", import.meta.url);328+ const command = new Deno.Command(Deno.execPath(), {329+ args: [330+ "run",331+ "--allow-env",332+ "--allow-net=127.0.0.1",333+ "--allow-read",334+ "--allow-write",335+ "--allow-run",336+ "--allow-ffi",337+ script.pathname,338+ mode,339+ ],340+ cwd: repoRoot.pathname,341+ stdout: "piped",342+ stderr: "piped",343+ });344+ const output = await command.output();345+ return new TextDecoder().decode(output.stdout);346+}347+348+Deno.test("CANARY alpha.53: short byte payloads are unreliable", async () => {349+ // A 2-byte insert has been observed succeeding, failing with the350+ // flat-history encoder error ("data too short for column value"), and351+ // hanging — context-dependent, so it runs behind a race in a child process352+ // and every known mode is accepted. An unknown error message is the only353+ // failure: it means the encoder changed and the bytes coverage should be354+ // revisited (ideally widened to short payloads).355+ const script = new URL("../testdata/bytes_short_payload_canary.ts", import.meta.url);356+ const repoRoot = new URL("../../", import.meta.url);357+ const command = new Deno.Command(Deno.execPath(), {358+ args: [359+ "run",360+ "--allow-env",361+ "--allow-net=127.0.0.1",362+ "--allow-read",363+ "--allow-write",364+ "--allow-run",365+ "--allow-ffi",366+ script.pathname,367+ ],368+ cwd: repoRoot.pathname,369+ stdout: "piped",370+ stderr: "piped",371+ });372+ const output = await command.output();373+ const stdout = new TextDecoder().decode(output.stdout);374+ const known = stdout.includes("short-bytes:OK") ||375+ stdout.includes("short-bytes:HUNG") ||376+ (stdout.includes("short-bytes:ERROR") && stdout.includes("data too short"));377+ assert(known, `bytes canary: unknown short-payload behavior: ${stdout}`);378+ console.log(`[conformance] ${stdout.trim()}`);379+});380+381+Deno.test("CANARY alpha.53: g-set cross-table writes are unreliable", async () => {382+ // Writing a sibling table before a g-set table's first write hangs383+ // reproducibly inside a process that has already booted policy test384+ // harnesses (five independent bisection runs), but not always in a fresh385+ // process — the wedge depends on process state, like the timestamp-query386+ // wedge documented on the scalar test. Both modes therefore run in child387+ // processes that force-exit, and either outcome is accepted and reported;388+ // only an outright error is a failure. The g-set coverage stays isolated in389+ // its own single-table app until upstream stabilizes this.390+ const siblingFirst = await runGsetCanary("sibling-first");391+ assert(392+ siblingFirst.includes("sibling-table-write:OK") ||393+ siblingFirst.includes("sibling-table-write:HUNG"),394+ `g-set canary: unexpected sibling-first outcome: ${siblingFirst}`,395+ );396+ console.log(`[conformance] g-set sibling-first: ${siblingFirst.trim()}`);397+398+ const gsetFirst = await runGsetCanary("gset-first");399+ assert(400+ gsetFirst.includes("gset-table-write:OK") && gsetFirst.includes("sibling-table-write:OK"),401+ `g-set canary: seeding the g-set table first no longer unblocks sibling writes: ${gsetFirst}`,402+ );403+});404+405+Deno.test("transformed columns apply the TypeScript-boundary transform on insert, read, and update", async () => {406+ const testApp = await createPolicyTestApp(app, permissions, expectLike);407+ try {408+ const db = testApp.as(session("author"));409+ const row = await db.insert(app.transformed, { tags: ["a", "b"], label: "first" })410+ .wait({ tier: "global" });411+ assert(412+ sameValue(row.tags, ["a", "b"]),413+ `transformed: insert returned ${describeValue(row.tags)}, not the view value`,414+ );415+ const readBack = (await db.all(app.transformed.where({ id: row.id })))[0];416+ assert(readBack !== undefined, "transformed: row lost after insert");417+ assert(418+ sameValue(readBack.tags, ["a", "b"]),419+ `transformed: read back ${describeValue(readBack.tags)}, not the view value`,420+ );421+422+ await db.update(app.transformed, row.id, { tags: ["a", "b", "c"] }).wait({ tier: "global" });423+ const updated = (await db.all(app.transformed.where({ id: row.id })))[0];424+ assert(425+ sameValue(updated?.tags, ["a", "b", "c"]),426+ `transformed: update read back ${describeValue(updated?.tags)}`,427+ );428+429+ // Filters address the stored representation, not the view: the where type430+ // for a transformed column is the stored type, and the runtime agrees.431+ await db.insert(app.transformed, { tags: ["z"], label: "decoy" }).wait({ tier: "global" });432+ const byStored = await db.all(app.transformed.where({ tags: "a,b,c" }));433+ assert(434+ byStored.length === 1 && byStored[0].label === "first",435+ `transformed: where on the stored value returned ${byStored.length} rows`,436+ );437+ } finally {438+ await testApp.shutdown();439+ }440+});441+442+Deno.test("policies filter on typed columns, not only magic columns", async () => {443+ const testApp = await createPolicyTestApp(app, permissions, expectLike);444+ try {445+ const db = testApp.as(session("author"));446+ await db.insert(app.gated, { visible: true, title: "shown" }).wait({ tier: "global" });447+ await db.insert(app.gated, { visible: false, title: "hidden" }).wait({ tier: "global" });448+ // The author matches the $createdBy alternative and sees both rows; a449+ // second identity only matches the typed-column alternative.450+ const authorView = await db.all(app.gated.where({}));451+ assert(452+ authorView.length === 2,453+ `gated: anyOf alternatives exposed ${authorView.length} rows to the author, expected 2`,454+ );455+ const viewer = testApp.as(session("viewer"));456+ const viewerView = await viewer.all(app.gated.where({}));457+ assert(458+ viewerView.length === 1 && viewerView[0].title === "shown",459+ `gated: policy on a boolean column exposed ${viewerView.length} rows to a non-author`,460+ );461+462+ const introspected = await db.all(463+ app.gated.select("title", "visible", ...s.permissionIntrospectionColumns).where({}),464+ );465+ for (const row of introspected) {466+ assert(467+ row.$canRead === true && row.$canEdit === true && row.$canDelete === !row.visible,468+ `introspection: permission columns read back ${JSON.stringify(row)}, expected ` +469+ `canDelete to mirror the visible-only delete rule`,470+ );471+ }472+ } finally {473+ await testApp.shutdown();474+ }475+});476+477+// CONFORMANCE FINDING (alpha.53): this test runs LAST deliberately. Running478+// the broken timestamp where-equality query (pinned below) leaves the FFI479+// driver wedged: the next createPolicyTestApp boot in the same process hangs480+// on its first write. Observed across three orderings; tests that run before481+// this one are unaffected.482+Deno.test("facade scalar columns round-trip, filter, and update through the real engine", async () => {483+ const testApp = await createPolicyTestApp(app, permissions, expectLike);484+ try {485+ const db = testApp.as(session("author")) as unknown as LooseDb;486+ const handles = app as unknown as Record<string, LooseHandle>;487+ for (const { table, value, other } of scalarCases) {488+ const handle = handles[table];489+ const inserted = await db.insert(handle, { value }).wait({ tier: "global" });490+ await db.insert(handle, { value: other }).wait({ tier: "global" });491+492+ const byId = await db.all(handle.where({ id: inserted.id }));493+ assert(byId.length === 1, `${table}: row lost after insert`);494+ assert(495+ sameValue(byId[0].value, value),496+ `${table}: read back ${describeValue(byId[0].value)}, inserted ${describeValue(value)}`,497+ );498+499+ const byValue = await db.all(handle.where({ value }));500+ if ("brokenWhereEquality" in scalarCases.find((c) => c.table === table)!) {501+ assert(502+ byValue.length === 2,503+ `${table}: pinned broken where-equality changed — returned ${byValue.length} rows; ` +504+ `if 1, upstream fixed the filter: remove the pin`,505+ );506+ } else {507+ assert(508+ byValue.length === 1 && byValue[0].id === inserted.id,509+ `${table}: where-equality on the typed column returned ${byValue.length} rows`,510+ );511+ }512+513+ await db.update(handle, inserted.id, { value: other }).wait({ tier: "global" });514+ const afterUpdate = await db.all(handle.where({ id: inserted.id }));515+ assert(516+ afterUpdate.length === 1 && sameValue(afterUpdate[0].value, other),517+ `${table}: update read back ${describeValue(afterUpdate[0]?.value)}`,518+ );519+ }520+521+ // PINNED (alpha.53): despite the `number` static type, int columns reject522+ // values outside i32 with InvalidArg. When this pin fails, upstream523+ // widened the runtime type — update the docs and the scalar case above.524+ let i64Error = "";525+ try {526+ await db.insert(handles.ints, { value: 2 ** 40 + 7 }).wait({ tier: "global" });527+ } catch (error) {528+ i64Error = error instanceof Error ? error.message : String(error);529+ }530+ assert(531+ i64Error.includes("expected i32"),532+ `ints: pinned i32 limit changed: ${i64Error || "no error thrown"}`,533+ );534+ } finally {535+ await testApp.shutdown();536+ }537+});
A package/schema/merge_sync_test.ts +346 -0
@@ -0,0 +1,346 @@
1+// Concurrent-writer merge semantics for the facade's merge strategies: lww2+// (the default), counter, and g-set columns written by two synced clients.3+// Single-session4+// behavior is covered by the conformance suite; this file is the multi-client5+// half, built on the JazzServer setup from package/access/access_sync_test.ts.6+//7+// Runs in its own process before the conformance suite (see the8+// test:conformance task) because the conformance scalar test deliberately9+// wedges the FFI driver last.10+import { createDb } from "jazz-tools";11+import { deploy, startLocalJazzServer } from "jazz-tools/testing";12+import { assert } from "../runtime/test-assert.ts";13+import { type ArrayColumn, type IntColumn, s, type StringColumn } from "./mod.ts";14+15+const counterApp = s.defineApp({16+ tallies: s.table({17+ name: s.string(),18+ total: s.int().default(0).merge("counter") as unknown as IntColumn<false, true>,19+ }),20+});21+const counterPermissions = s.definePermissions(counterApp, ({ policy }) => {22+ policy.tallies.allowInsert.always();23+ policy.tallies.allowRead.always();24+ policy.tallies.allowUpdate.always();25+ policy.tallies.allowDelete.always();26+});27+28+// Single-table app: a g-set column destabilizes sibling-table writes in the29+// pinned alpha (see the conformance canary), so the g-set coverage stays30+// isolated here too.31+const gsetApp = s.defineApp({32+ tagged: s.table({33+ name: s.string(),34+ tags: s.array(s.string()).merge("g-set") as unknown as ArrayColumn<"TEXT">,35+ }),36+});37+const gsetPermissions = s.definePermissions(gsetApp, ({ policy }) => {38+ policy.tagged.allowInsert.always();39+ policy.tagged.allowRead.always();40+ policy.tagged.allowUpdate.always();41+ policy.tagged.allowDelete.always();42+});43+44+// One column with the implicit default strategy and one with explicit45+// .merge("lww"): the same conflict runs through both so the test also pins46+// that the default is last-write-wins.47+const lwwApp = s.defineApp({48+ docs: s.table({49+ title: s.string(),50+ body: s.string().merge("lww") as unknown as StringColumn<false, false>,51+ }),52+});53+const lwwPermissions = s.definePermissions(lwwApp, ({ policy }) => {54+ policy.docs.allowInsert.always();55+ policy.docs.allowRead.always();56+ policy.docs.allowUpdate.always();57+ policy.docs.allowDelete.always();58+});59+60+function secret(fill: number): string {61+ let binary = "";62+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);63+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");64+}65+66+async function within<T>(operation: Promise<T>, label: string, milliseconds = 10_000): Promise<T> {67+ let timer: ReturnType<typeof setTimeout> | undefined;68+ try {69+ return await Promise.race([70+ operation,71+ new Promise<never>((_resolve, reject) => {72+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);73+ }),74+ ]);75+ } finally {76+ if (timer !== undefined) clearTimeout(timer);77+ }78+}79+80+type DeployOptions = Parameters<typeof deploy>[0];81+type Harness = {82+ server: Awaited<ReturnType<typeof startLocalJazzServer>>;83+ client(fill: number): Promise<Awaited<ReturnType<typeof createDb>>>;84+ stop(): Promise<void>;85+};86+87+async function bootHarness(88+ app: DeployOptions["schema"],89+ permissions: DeployOptions["permissions"],90+): Promise<Harness> {91+ const server = await startLocalJazzServer({ allowLocalFirstAuth: true });92+ await deploy({93+ appId: server.appId,94+ serverUrl: server.url,95+ adminSecret: server.adminSecret,96+ schema: app,97+ permissions,98+ });99+ const clients: Awaited<ReturnType<typeof createDb>>[] = [];100+ return {101+ server,102+ async client(fill: number) {103+ const db = await createDb({104+ appId: server.appId,105+ serverUrl: server.url,106+ secret: secret(fill),107+ userBranch: "main",108+ driver: { type: "memory" },109+ });110+ clients.push(db);111+ return db;112+ },113+ async stop() {114+ await Promise.allSettled(115+ clients.map((db, index) => within(db.logout(), `client ${index} cleanup`, 3_000)),116+ );117+ await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);118+ },119+ };120+}121+122+// PINNED (alpha.53): counter columns have no single semantics across replica123+// lifetimes. The server merges an update as a replacement when it is causally124+// latest and as a sum when updates are concurrent, while live replicas apply125+// every update — including the echo of their own reconnected write — as a126+// delta. A replica that watched the history read therefore diverges127+// permanently from what a fresh boot of the same account reads. Each assert128+// below pins one cell of that matrix; when one fails, upstream changed129+// counter merge behavior — re-derive the matrix and update the column-palette130+// section of docs/data-and-ui.md.131+Deno.test("counter merge: concurrent offline updates sum on the server, diverge on a live writer", async () => {132+ const harness = await bootHarness(counterApp, counterPermissions);133+ try {134+ const alice = await harness.client(1);135+ const bob = await harness.client(2);136+ const row = await within(137+ alice.insert(counterApp.tallies, { name: "hits", total: 0 }).wait({ tier: "global" }),138+ "seed insert",139+ );140+ const bobView = await within(141+ bob.all(counterApp.tallies.where({ id: row.id }), { tier: "global" }),142+ "bob initial read",143+ );144+ assert(bobView.length === 1 && bobView[0].total === 0, "bob did not sync the seeded row");145+146+ await alice.disconnect();147+ await bob.disconnect();148+ const aliceWrite = alice.update(counterApp.tallies, row.id, { total: 5 });149+ const bobWrite = bob.update(counterApp.tallies, row.id, { total: 3 });150+ await alice.reconnect();151+ await bob.reconnect();152+ await within(aliceWrite.wait({ tier: "global" }), "alice counter durability");153+ await within(bobWrite.wait({ tier: "global" }), "bob counter durability");154+155+ const read = async (db: typeof alice, label: string) => {156+ const rows = await within(157+ db.all(counterApp.tallies.where({ id: row.id }), { tier: "global" }),158+ `${label} read`,159+ );160+ return rows[0]?.total;161+ };162+ const aliceTotal = await read(alice, "alice");163+ const bobTotal = await read(bob, "bob");164+ const charlie = await harness.client(3);165+ const canonical = await read(charlie, "fresh client");166+167+ assert(168+ canonical === 8,169+ `counter: concurrent updates no longer sum on the server (fresh client read ${canonical}, ` +170+ `not 5 + 3); the merge model changed — remap it`,171+ );172+ assert(173+ bobTotal === 8,174+ `counter: second writer read ${bobTotal}, not the canonical sum 8`,175+ );176+ assert(177+ aliceTotal === 13,178+ `counter: pinned first-writer divergence changed — read ${aliceTotal}; if 8, upstream ` +179+ `stopped double-counting the reconnected writer's own echo: remove the pin`,180+ );181+ } finally {182+ await harness.stop();183+ }184+});185+186+Deno.test("counter merge: causally ordered updates replace on the server, accumulate on live replicas", async () => {187+ const harness = await bootHarness(counterApp, counterPermissions);188+ try {189+ const alice = await harness.client(1);190+ const bob = await harness.client(2);191+ const row = await within(192+ alice.insert(counterApp.tallies, { name: "causal", total: 0 }).wait({ tier: "global" }),193+ "seed insert",194+ );195+ await within(196+ alice.update(counterApp.tallies, row.id, { total: 2 }).wait({ tier: "global" }),197+ "alice update durability",198+ );199+ const bobBefore = await within(200+ bob.all(counterApp.tallies.where({ id: row.id }), { tier: "global" }),201+ "bob pre-write read",202+ );203+ assert(bobBefore[0]?.total === 2, "bob did not sync alice's update before writing");204+ await within(205+ bob.update(counterApp.tallies, row.id, { total: 3 }).wait({ tier: "global" }),206+ "bob update durability",207+ );208+209+ const bobTotal =210+ (await within(bob.all(counterApp.tallies.where({ id: row.id })), "bob read"))[0]?.total;211+ const aliceTotal = (await within(212+ alice.all(counterApp.tallies.where({ id: row.id }), { tier: "global" }),213+ "alice read",214+ ))[0]?.total;215+ const charlie = await harness.client(3);216+ const canonical = (await within(217+ charlie.all(counterApp.tallies.where({ id: row.id }), { tier: "global" }),218+ "fresh client read",219+ ))[0]?.total;220+221+ assert(222+ canonical === 3,223+ `counter: pinned causal replacement changed — fresh client read ${canonical}; if 5, ` +224+ `upstream now sums causally ordered updates like live replicas do: remove the pin`,225+ );226+ assert(227+ aliceTotal === 5 && bobTotal === 5,228+ `counter: live replicas no longer accumulate deltas (alice=${aliceTotal}, bob=${bobTotal}, ` +229+ `expected both 5 = 2 + 3)`,230+ );231+ } finally {232+ await harness.stop();233+ }234+});235+236+Deno.test("g-set columns union concurrent offline updates and converge everywhere", async () => {237+ const harness = await bootHarness(gsetApp, gsetPermissions);238+ try {239+ const alice = await harness.client(1);240+ const bob = await harness.client(2);241+ const row = await within(242+ alice.insert(gsetApp.tagged, { name: "doc", tags: ["a"] }).wait({ tier: "global" }),243+ "seed insert",244+ );245+ const bobView = await within(246+ bob.all(gsetApp.tagged.where({ id: row.id }), { tier: "global" }),247+ "bob initial read",248+ );249+ assert(bobView.length === 1, "bob did not sync the seeded row");250+251+ await alice.disconnect();252+ await bob.disconnect();253+ const aliceWrite = alice.update(gsetApp.tagged, row.id, { tags: ["a", "b"] });254+ const bobWrite = bob.update(gsetApp.tagged, row.id, { tags: ["a", "c"] });255+ await alice.reconnect();256+ await bob.reconnect();257+ await within(aliceWrite.wait({ tier: "global" }), "alice g-set durability");258+ await within(bobWrite.wait({ tier: "global" }), "bob g-set durability");259+260+ const read = async (db: typeof alice, label: string) => {261+ const rows = await within(262+ db.all(gsetApp.tagged.where({ id: row.id }), { tier: "global" }),263+ `${label} read`,264+ );265+ return [...(rows[0]?.tags ?? [])].sort();266+ };267+ const aliceTags = await read(alice, "alice");268+ const bobTags = await read(bob, "bob");269+ const charlie = await harness.client(3);270+ const canonical = await read(charlie, "fresh client");271+272+ const union = JSON.stringify(["a", "b", "c"]);273+ for (274+ const [label, tags] of [["alice", aliceTags], ["bob", bobTags], ["fresh client", canonical]]275+ .map(([l, t]) => [l, JSON.stringify(t)] as const)276+ ) {277+ assert(278+ tags === union,279+ `g-set: ${label} read ${tags}, not the union of both writers' elements`,280+ );281+ }282+ } finally {283+ await harness.stop();284+ }285+});286+287+Deno.test("lww columns resolve concurrent offline updates to one winner everywhere", async () => {288+ const harness = await bootHarness(lwwApp, lwwPermissions);289+ try {290+ const alice = await harness.client(1);291+ const bob = await harness.client(2);292+ const row = await within(293+ alice.insert(lwwApp.docs, { title: "seed-title", body: "seed-body" }).wait({294+ tier: "global",295+ }),296+ "seed insert",297+ );298+ const bobView = await within(299+ bob.all(lwwApp.docs.where({ id: row.id }), { tier: "global" }),300+ "bob initial read",301+ );302+ assert(bobView.length === 1, "bob did not sync the seeded row");303+304+ await alice.disconnect();305+ await bob.disconnect();306+ const aliceWrite = alice.update(lwwApp.docs, row.id, {307+ title: "alice-title",308+ body: "alice-body",309+ });310+ const bobWrite = bob.update(lwwApp.docs, row.id, { title: "bob-title", body: "bob-body" });311+ await alice.reconnect();312+ await bob.reconnect();313+ await within(aliceWrite.wait({ tier: "global" }), "alice lww durability");314+ await within(bobWrite.wait({ tier: "global" }), "bob lww durability");315+316+ const read = async (db: typeof alice, label: string) => {317+ const rows = await within(318+ db.all(lwwApp.docs.where({ id: row.id }), { tier: "global" }),319+ `${label} read`,320+ );321+ return { title: rows[0]?.title, body: rows[0]?.body };322+ };323+ const aliceDoc = await read(alice, "alice");324+ const bobDoc = await read(bob, "bob");325+ const charlie = await harness.client(3);326+ const canonical = await read(charlie, "fresh client");327+328+ // Bob reconnects after Alice, so his write is the last to arrive and wins329+ // on every replica — for the implicit-default column and the explicit330+ // "lww" column alike. Unlike counter, live replicas and fresh boots agree.331+ for (332+ const [label, doc] of [333+ ["alice", aliceDoc],334+ ["bob", bobDoc],335+ ["fresh client", canonical],336+ ] as const337+ ) {338+ assert(339+ doc.title === "bob-title" && doc.body === "bob-body",340+ `lww: ${label} read ${JSON.stringify(doc)}, not the later-arriving writer's values`,341+ );342+ }343+ } finally {344+ await harness.stop();345+ }346+});
A package/schema/migration_test.ts +215 -0
@@ -0,0 +1,215 @@
1+// Migration conformance: the facade's schema-evolution members exercised2+// against a real JazzServer — defineSchema, defineMigration, renameTableFrom,3+// and the column ops add/drop/renameFrom. A v1 deployment takes writes, then4+// v2 deploys with the migration lens and both schema versions read the same5+// rows. Runs in its own process before the conformance suite (see the6+// test:conformance task).7+import { createDb } from "jazz-tools";8+import { deploy, startLocalJazzServer } from "jazz-tools/testing";9+import { assert } from "../runtime/test-assert.ts";10+import { s } from "./mod.ts";11+12+const v1 = s.defineSchema({13+ todos: s.table({ title: s.string(), done: s.boolean(), detail: s.string() }),14+ projects: s.table({ name: s.string() }),15+});16+const v2 = s.defineSchema({17+ todos: s.table({18+ title: s.string(),19+ note: s.string(),20+ priority: s.enum("low", "medium", "high"),21+ }),22+ workspaces: s.table({ name: s.string() }),23+});24+25+const appV1 = s.defineApp(v1);26+const appV2 = s.defineApp(v2);27+28+const migration = s.defineMigration({29+ from: v1,30+ to: v2,31+ renameTables: { workspaces: s.renameTableFrom("projects") },32+ migrate: {33+ todos: {34+ priority: s.add.enum("low", "medium", "high", { default: "medium" }),35+ note: s.renameFrom("detail"),36+ done: s.drop.boolean({ backwardsDefault: false }),37+ },38+ },39+});40+41+type PolicyRules = {42+ allowInsert: { always(): void };43+ allowRead: { always(): void };44+ allowUpdate: { always(): void };45+ allowDelete: { always(): void };46+};47+function allowAll(app: unknown, tables: string[]) {48+ return s.definePermissions(49+ app as Parameters<typeof s.definePermissions>[0],50+ ({ policy }) => {51+ const rules = policy as unknown as Record<string, PolicyRules>;52+ for (const table of tables) {53+ rules[table].allowInsert.always();54+ rules[table].allowRead.always();55+ rules[table].allowUpdate.always();56+ rules[table].allowDelete.always();57+ }58+ },59+ );60+}61+const permissionsV1 = allowAll(appV1, ["todos", "projects"]);62+const permissionsV2 = allowAll(appV2, ["todos", "workspaces"]);63+64+function secret(fill: number): string {65+ let binary = "";66+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);67+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");68+}69+70+async function within<T>(operation: Promise<T>, label: string, milliseconds = 10_000): Promise<T> {71+ let timer: ReturnType<typeof setTimeout> | undefined;72+ try {73+ return await Promise.race([74+ operation,75+ new Promise<never>((_resolve, reject) => {76+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);77+ }),78+ ]);79+ } finally {80+ if (timer !== undefined) clearTimeout(timer);81+ }82+}83+84+Deno.test("schema migrations carry data forward across add, drop, rename, and table rename", async () => {85+ const server = await startLocalJazzServer({ allowLocalFirstAuth: true });86+ const clients: Awaited<ReturnType<typeof createDb>>[] = [];87+ const client = async (fill: number) => {88+ const db = await createDb({89+ appId: server.appId,90+ serverUrl: server.url,91+ secret: secret(fill),92+ userBranch: "main",93+ driver: { type: "memory" },94+ });95+ clients.push(db);96+ return db;97+ };98+ try {99+ await deploy({100+ appId: server.appId,101+ serverUrl: server.url,102+ adminSecret: server.adminSecret,103+ schema: appV1,104+ permissions: permissionsV1,105+ });106+ const alice = await client(1);107+ const todoV1 = await within(108+ alice.insert(appV1.todos, { title: "first", done: true, detail: "carried" })109+ .wait({ tier: "global" }),110+ "v1 todo insert",111+ );112+ const projectV1 = await within(113+ alice.insert(appV1.projects, { name: "renamed-table" }).wait({ tier: "global" }),114+ "v1 project insert",115+ );116+117+ const unmigrated = await within(118+ deploy({119+ appId: server.appId,120+ serverUrl: server.url,121+ adminSecret: server.adminSecret,122+ schema: appV2,123+ permissions: permissionsV2,124+ }).then(125+ (result) => ({ outcome: "resolved" as const, result }),126+ (error) => ({127+ outcome: "rejected" as const,128+ message: error instanceof Error ? error.message : String(error),129+ }),130+ ),131+ "v2 deploy without migration",132+ );133+ assert(134+ unmigrated.outcome === "resolved" &&135+ unmigrated.result.migration?.status === "missing",136+ `deploy without a required migration no longer reports status "missing": ${137+ JSON.stringify(unmigrated)138+ }`,139+ );140+141+ await within(142+ deploy({143+ appId: server.appId,144+ serverUrl: server.url,145+ adminSecret: server.adminSecret,146+ schema: appV2,147+ permissions: permissionsV2,148+ migration,149+ }),150+ "v2 deploy with migration",151+ );152+153+ // Forward lens: the v1 row arrives under v2 with the renamed column's154+ // data, the added column's default, and without the dropped column.155+ const bob = await client(2);156+ const todosV2 = await within(157+ bob.all(appV2.todos.where({ id: todoV1.id }), { tier: "global" }),158+ "v2 todo read",159+ );160+ const migrated = todosV2[0];161+ assert(migrated !== undefined, "v1 todo lost across the migration");162+ assert(163+ migrated.title === "first" && migrated.note === "carried",164+ `renameFrom: v1 detail read back as note=${JSON.stringify(migrated.note)}`,165+ );166+ assert(167+ migrated.priority === "medium",168+ `add: v2 column read back ${JSON.stringify(migrated.priority)}, not its default`,169+ );170+ assert(171+ !("done" in migrated),172+ `drop: v1 column still present under v2: ${JSON.stringify(migrated)}`,173+ );174+175+ const workspacesV2 = await within(176+ bob.all(appV2.workspaces.where({ id: projectV1.id }), { tier: "global" }),177+ "v2 workspace read",178+ );179+ assert(180+ workspacesV2.length === 1 && workspacesV2[0].name === "renamed-table",181+ `renameTableFrom: v1 project row not readable under the renamed table`,182+ );183+184+ // Backward lens (generated automatically): a client still on v1 handles185+ // reads a v2-written row with the rename reversed and the dropped186+ // column's backwardsDefault filled in.187+ const todoV2 = await within(188+ bob.insert(appV2.todos, { title: "second", note: "fresh", priority: "high" })189+ .wait({ tier: "global" }),190+ "v2 todo insert",191+ );192+ const backward = (await within(193+ alice.all(appV1.todos.where({ id: todoV2.id }), { tier: "global" }),194+ "v1 read of v2 row",195+ ))[0];196+ assert(backward !== undefined, "v2 todo invisible to the v1 reader");197+ assert(198+ backward.title === "second" && backward.detail === "fresh",199+ `backward rename: v2 note read back as detail=${JSON.stringify(backward.detail)}`,200+ );201+ assert(202+ backward.done === false,203+ `backward drop: backwardsDefault read back ${JSON.stringify(backward.done)}`,204+ );205+ assert(206+ !("priority" in backward),207+ `backward add: v2-only column leaked into the v1 view: ${JSON.stringify(backward)}`,208+ );209+ } finally {210+ await Promise.allSettled(211+ clients.map((db, index) => within(db.logout(), `client ${index} cleanup`, 3_000)),212+ );213+ await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);214+ }215+});
A package/schema/mod.ts +110 -0
@@ -0,0 +1,110 @@
1+/**2+ * Declare application tables, permissions, and migrations without importing3+ * Jazz directly.4+ *5+ * This module is a curated re-export of the pinned Jazz 2 schema DSL: the6+ * same names and semantics, narrowed to the surface lofi documents and tests.7+ * Author code imports {@link s} from here instead of `jazz-tools`, so version8+ * bumps and upstream renames are absorbed by the package rather than by every9+ * application schema.10+ *11+ * ```ts12+ * import { s } from "@nzip/lofi/schema";13+ *14+ * export const app = s.defineApp({15+ * tasks: s.table({16+ * text: s.string(),17+ * completed: s.boolean(),18+ * createdAt: s.timestamp(),19+ * }),20+ * });21+ * ```22+ *23+ * @module24+ */25+import { schema } from "jazz-tools";26+27+/**28+ * The curated schema DSL type: every member is the pinned Jazz 2 original,29+ * unchanged. Deprecated members (`rename`) and surface lofi does not yet30+ * exercise (`defineSliceableApp`) are omitted; everything else re-exports31+ * one-to-one.32+ */33+export type SchemaDsl = Pick<34+ typeof schema,35+ // Column constructors.36+ | "string"37+ | "boolean"38+ | "int"39+ | "float"40+ | "timestamp"41+ | "bytes"42+ | "json"43+ | "enum"44+ | "ref"45+ | "array"46+ // Column migration operations.47+ | "add"48+ | "drop"49+ | "renameFrom"50+ // Tables, schemas, and apps.51+ | "table"52+ | "defineSchema"53+ | "defineApp"54+ // Schema migrations.55+ | "defineMigration"56+ | "renameTableFrom"57+ // Permissions.58+ | "definePermissions"59+ | "permissionIntrospectionColumns"60+>;61+62+/**63+ * The lofi schema surface. Use in `src/schema.ts` and `src/permissions.ts` in64+ * place of a raw `jazz-tools` import; member names and behavior are identical65+ * to the pinned Jazz 2 DSL.66+ */67+export const s: SchemaDsl = schema;68+69+/**70+ * Schema-authoring types re-exported from the pinned Jazz 2 DSL for use in71+ * application signatures. Row types for UI code come from `@nzip/lofi`72+ * (`RowOf`), not from this module.73+ */74+export type {75+ App,76+ DefinedTable,77+ InsertOf,78+ Schema,79+ SchemaDefinition,80+ TableDefinition,81+ WhereOf,82+} from "jazz-tools";83+84+/**85+ * Column builder types re-exported for signatures and for the alpha.5386+ * workaround below: calling `.merge()` or `.transform()` on a column loses87+ * the typed builder (the legacy untyped signatures shadow the typed88+ * overloads upstream), so cast the result back to the intended column type.89+ * The runtime object is unchanged; only the declaration needs repair:90+ *91+ * ```ts92+ * total: s.int().default(0).merge("counter") as unknown as IntColumn<false, true>,93+ * tags: s.string().transform({94+ * from: (value: string) => value.split(","),95+ * to: (value: string[]) => value.join(","),96+ * }) as unknown as StringColumn<false, false, string[]>,97+ * ```98+ */99+export type {100+ ArrayColumn,101+ BooleanColumn,102+ BytesColumn,103+ EnumColumn,104+ FloatColumn,105+ IntColumn,106+ JsonColumn,107+ RefColumn,108+ StringColumn,109+ TimestampColumn,110+} from "jazz-tools";
A package/schema/mod_test.ts +57 -0
@@ -0,0 +1,57 @@
1+import { schema } from "jazz-tools";2+import { s } from "./mod.ts";3+4+// The facade re-exports, never reimplements: every curated member must be the5+// pinned Jazz 2 original by identity, so semantics cannot drift.6+const curatedMembers = [7+ "string",8+ "boolean",9+ "int",10+ "float",11+ "timestamp",12+ "bytes",13+ "json",14+ "enum",15+ "ref",16+ "array",17+ "add",18+ "drop",19+ "renameFrom",20+ "table",21+ "defineSchema",22+ "defineApp",23+ "defineMigration",24+ "renameTableFrom",25+ "definePermissions",26+ "permissionIntrospectionColumns",27+] as const;28+29+Deno.test("schema facade exposes every curated member as the Jazz original", () => {30+ for (const member of curatedMembers) {31+ if (!(member in schema)) {32+ throw new Error(`jazz-tools schema namespace no longer provides "${member}"`);33+ }34+ if (s[member] !== (schema as Record<string, unknown>)[member]) {35+ throw new Error(`facade member "${member}" is not the jazz-tools original`);36+ }37+ }38+});39+40+Deno.test("a schema declared through the facade round-trips app and permissions", () => {41+ const app = s.defineApp({42+ tasks: s.table({43+ text: s.string(),44+ completed: s.boolean(),45+ createdAt: s.timestamp(),46+ }),47+ });48+ if (typeof app.tasks?.where !== "function") {49+ throw new Error("facade-declared app does not expose a queryable table");50+ }51+ const permissions = s.definePermissions(app, ({ policy }) => {52+ policy.tasks.allowInsert.always();53+ });54+ if (!permissions || typeof permissions !== "object") {55+ throw new Error("facade-declared permissions did not compile");56+ }57+});
A package/testdata/bytes_short_payload_canary.ts +42 -0
@@ -0,0 +1,42 @@
1+// Child process for the short-bytes canary in2+// package/schema/conformance_test.ts. In jazz-tools@2.0.0-alpha.53 a short3+// byte payload insert has been observed succeeding, failing with the4+// flat-history encoder error, and hanging — context-dependent — so it runs5+// behind a race in a process that force-exits.6+import { createPolicyTestApp } from "jazz-tools/testing";7+import { s } from "../schema/mod.ts";8+9+const expectLike = (value: unknown) => ({10+ toThrow() {11+ if (typeof value === "function") value();12+ },13+ not: {14+ toThrow() {15+ if (typeof value === "function") value();16+ },17+ },18+});19+20+const app = s.defineApp({ blobs: s.table({ value: s.bytes() }) });21+const permissions = s.definePermissions(app, ({ policy }) => {22+ policy.blobs.allowInsert.always();23+ policy.blobs.allowRead.always();24+ policy.blobs.allowUpdate.always();25+ policy.blobs.allowDelete.always();26+});27+28+const testApp = await createPolicyTestApp(app, permissions, expectLike);29+const db = testApp.as({ user_id: "canary", claims: {}, authMode: "local-first" });30+31+const outcome = await Promise.race([32+ db.insert(app.blobs, { value: new Uint8Array([1, 2]) }).wait({ tier: "global" }).then(33+ () => "short-bytes:OK",34+ (error) => {35+ const message = error instanceof Error ? error.message : String(error);36+ return `short-bytes:ERROR:${message}`;37+ },38+ ),39+ new Promise<string>((resolve) => setTimeout(() => resolve("short-bytes:HUNG"), 8_000)),40+]);41+console.log(outcome);42+Deno.exit(0);
A package/testdata/gset_hang_canary.ts +62 -0
@@ -0,0 +1,62 @@
1+// Child process for the g-set cross-table write canary in2+// package/schema/conformance_test.ts. Runs the writes that hang in3+// jazz-tools@2.0.0-alpha.53 behind short races and force-exits, because a4+// hung FFI write cannot be cancelled from inside a test.5+import { createPolicyTestApp } from "jazz-tools/testing";6+import { s } from "../schema/mod.ts";7+8+const expectLike = (value: unknown) => ({9+ toThrow() {10+ if (typeof value === "function") value();11+ },12+ not: {13+ toThrow() {14+ if (typeof value === "function") value();15+ },16+ },17+});18+19+const app = s.defineApp({20+ parents: s.table({ name: s.string() }),21+ tagged: s.table({ tags: s.array(s.string()).merge("g-set") as never }),22+});23+const permissions = s.definePermissions(app, ({ policy }) => {24+ for (const table of [policy.parents, policy.tagged]) {25+ table.allowInsert.always();26+ table.allowRead.always();27+ table.allowUpdate.always();28+ table.allowDelete.always();29+ }30+});31+32+const testApp = await createPolicyTestApp(app, permissions, expectLike);33+const db = testApp.as({ user_id: "canary", claims: {}, authMode: "local-first" });34+35+const race = (work: Promise<unknown>, label: string) =>36+ Promise.race([37+ work.then(() => `${label}:OK`, (error) => {38+ const message = error instanceof Error ? error.message : String(error);39+ return `${label}:ERROR:${message}`;40+ }),41+ new Promise<string>((resolve) => setTimeout(() => resolve(`${label}:HUNG`), 8_000)),42+ ]);43+44+// Mode "sibling-first" writes a sibling table before the g-set table has45+// ever been written (the shape that hangs in alpha.53). Mode "gset-first"46+// seeds the g-set table, after which sibling writes succeed.47+const mode = Deno.args[0] ?? "sibling-first";48+if (mode === "gset-first") {49+ console.log(50+ await race(51+ db.insert(app.tagged, { tags: ["a"] }).wait({ tier: "global" }),52+ "gset-table-write",53+ ),54+ );55+}56+console.log(57+ await race(58+ db.insert(app.parents, { name: "w" }).wait({ tier: "global" }),59+ "sibling-table-write",60+ ),61+);62+Deno.exit(0);
M package/testdata/starter.snapshot.json +4 -4
@@ -1,7 +1,7 @@
11 {22 ".env.example": "0cfad81a3ba6442ac15d7cbe44a00374bf4dbf8af68ca412e54fb100241ddeab",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",4- "deno.json": "8f784c739f7e80000be257e0553d7f785fb9d52e329dcc85320d783544f2c54d",4+ "deno.json": "ca2993e89da3fcd9680cb07ca6aa3e6cd3ee9559fd42aeca0cac079ee01d3bf2",55 "public/apple-touch-icon.png": "be2b77066d9681709210388413ddcd94318906799ddd6645934b789a04bd4a61",66 "public/favicon.svg": "35b92ad48231c470b348eaebe6d6fe14acf2ec4a86e6726a03ccb130328a0d85",77 "public/icon-192.png": "6ee711f9ec990ec55f789d7943813c5193973bf2ca9199078ee85d285317e07c",
@@ -19,11 +19,11 @@
1919 "src/islands/use-tasks.ts": "22400693c9b7197adb8af858b0af13456459823229908c87e3af4fd4f6336554",2020 "src/layouts/Shell.astro": "57f5c144813fad7d6c7223b0e68498665f41dcd8b0855f2e8bf020d3bb072711",2121 "src/pages/index.astro": "884afb177743ccef0d996b8f60158f948b8bf38c966281f29564a9b4e767fa4c",22- "src/permissions.ts": "dcb00b7f0576d04ad527b8892b8096573b107c3130ed6d835243251c406a38b7",23- "src/schema.ts": "9f88050f6ece01d28242a6d93ed32d32c1c5a71cff35e0dc421ab3715d591748",22+ "src/permissions.ts": "7a2dad8ce48816c1611c85049209f4fddef08ab332dcbd94bac4fa162fc6c1b0",23+ "src/schema.ts": "d2bff3864609433071c3a3d94dac79a56e3cdd05e7cf7640831928bba74a8b2e",2424 "src/styles/global.css": "da8594026ea8420d00c58549b9b38040b4456f75615959ca2e94997b18be1997",2525 "tests/auth_e2e_test.ts": "87dc3eb441eeaa96fc82b15df90bf4108096506217e5bfae907b483a3e75b4bf",26- "tests/author-boundary_test.ts": "9fdb6d94180cef044b1d2e74601a01d8f0b26253700bb494bbb45d4127dc728a",26+ "tests/author-boundary_test.ts": "8340fda5d2764ed3c57e72e72d36974efca00c1c0ae126adfbdc4b394963d81f",2727 "tests/backup_migration_e2e_test.ts": "7ad87dad687641ba937ab20b0c5d06a34478c7c08527ed6e8c6861ad6c97d622",2828 "tests/convergence_e2e_test.ts": "71ec0ac935c25ff0722905e5b195c730f32a3b886dd4172e11d9e0e80f19cb38",2929 "tests/testing-contract_test.ts": "4257306060d42566b879d92b3a6c395805b20f53717bee55d670c0ef7a117eec",
M tools/golden.ts +1 -1
M website/docs-manifest.json +11 -2