clone

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

feat(schema): pin lww concurrent merge and .transform() conformance

Dami committed 21e23a4
Close the last two coverage gaps in the alpha.53 collaborative-value
surface (lww | counter | g-set is the complete merge-strategy union;
Jazz 2 exports no successor to the 1.x CoValue types):

- lww (default and explicit) pinned with two synced clients: a
  concurrent conflict resolves to the last write to reach the server,
  identically on live replicas and fresh boots.
- .transform() pinned in the conformance suite: view type on
  insert/read/update, where filters take the stored type, and the
  untyped legacy signature erases column typing like .merge() does
  (same cast workaround, documented on the facade).

Continues #105.
commit
21e23a430cc96c267293324b164824d00c7c0665
parent
956cd12f42bccc6d5b0cfffb292fa2c10477277e
author
Dami <dami.etoile@gmail.com>
5 files changed, +167 -23
Mdocs/data-and-ui.md17++++--
Mdocs/decisions/schema-facade-alpha53.md26++++++----
Mpackage/schema/conformance_test.ts51++++++++++++++++++-
Mpackage/schema/merge_sync_test.ts84+++++++++++++++++++++++++++++-
Mpackage/schema/mod.ts12+++-
M docs/data-and-ui.md +12 -5
@@ -77,10 +77,12 @@ it:
7777 ### Column modifiers and merge strategies7878 7979 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. In the81-pinned alpha `.merge()` returns the untyped builder (the legacy `merge(): this` signature shadows82-the typed overload), which degrades the whole table's row types — cast the result back to the83-intended column type; the runtime object is unchanged:80+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:8486 8587 ```ts8688 import { type ArrayColumn, type IntColumn, s } from "@nzip/lofi/schema";
@@ -93,8 +95,13 @@ export const app = s.defineApp({
9395 });9496 ```9597 96-Merge behavior is verified with two synced clients in `package/schema/merge_sync_test.ts`: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`:97102 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.98105 - **`"g-set"` works**: concurrent writers union their elements and every replica — including a fresh99106 boot — converges on the same set. Keep a g-set table in its own single-table app for now; in the100107 pinned alpha a g-set column destabilizes writes to sibling tables in the same app.
M docs/decisions/schema-facade-alpha53.md +16 -10
@@ -48,8 +48,8 @@ test.
4848 4949 - `package/schema/mod.ts` — the facade; `package/schema/mod_test.ts` verifies member identity5050 against `jazz-tools` and round-trips `defineApp`/`definePermissions` through the facade.51-- `package/schema/merge_sync_test.ts` — concurrent-writer merge semantics (counter, g-set) over a52- real JazzServer with two synced clients and a fresh observer.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.5353 - `apps/reference/tests/author-boundary_test.ts` — rejects `jazz-tools` imports in all author5454 source, including `schema.ts` and `permissions.ts`.5555 - `deno task check` and `deno task build` pass with the reference app (and therefore the starter,
@@ -69,14 +69,20 @@ The column-type conformance suite (`package/schema/conformance_test.ts`, task
6969 `deno task test:conformance`, part of `deno task check`) exercises every facade column type through7070 the official policy test harness against the real engine. Verified working end to end: string,7171 boolean, int (within i32), float, timestamp storage, enum, ref, json, array, bytes (≥32-byte72-payloads observed reliable), `.optional()`, `.default()`, counter merge (single-session), g-set73-merge (single-table apps), and policy conditions over typed columns. Engine and type bugs found and74-pinned in the suite so an alpha bump surfaces any change:75-76-1. **`.merge()` erases column typing.** The legacy untyped `merge(): this` signature shadows the77- typed overload, degrading the column to `ColumnBuilder` and poisoning the whole table's row78- types. Runtime unaffected. Workaround documented on `@nzip/lofi/schema`: cast the result back79- (e.g. `as unknown as IntColumn<false, true>`).72+payloads observed reliable), `.optional()`, `.default()`, `.transform()` (view type on73+insert/read/update; `where` filters take the stored type), lww merge (the default; concurrent74+conflicts resolve to the last write to reach the server on every replica), counter merge75+(single-session), g-set merge (single-table apps, cross-writer union), and policy conditions over76+typed columns. The three merge strategies (`lww`, `counter`, `g-set`) are the complete77+collaborative-value surface of the pinned alpha — Jazz 2 exports no successor to the 1.x CoValue78+types. Engine and type bugs found and pinned in the suite so an alpha bump surfaces any change:79+80+1. **`.merge()` and `.transform()` erase column typing.** The legacy untyped signatures81+ (`merge(): this`, `transform(): ColumnBuilder`) shadow the typed overloads, degrading the column82+ to `ColumnBuilder` and poisoning the whole table's row types. Runtime unaffected. Workaround83+ documented on `@nzip/lofi/schema`: cast the result back (e.g.84+ `as unknown as85+ IntColumn<false, true>`, `as unknown as StringColumn<false, false, string[]>`).8086 2. **Int columns are i32 at runtime.** Values outside i32 are rejected with8187 `InvalidArg … expected8288 i32` despite the `number` static type. Pinned.
M package/schema/conformance_test.ts +50 -1
@@ -8,7 +8,7 @@
88 // application author can actually reach.99 import { createPolicyTestApp } from "jazz-tools/testing";1010 import { assert } from "../runtime/test-assert.ts";11-import { type ArrayColumn, type IntColumn, s } from "./mod.ts";11+import { type ArrayColumn, type IntColumn, s, type StringColumn } from "./mod.ts";1212 1313 const app = s.defineApp({1414 parents: s.table({ name: s.string() }),
@@ -32,6 +32,17 @@ const app = s.defineApp({
3232 // column; the runtime object is correct without it.3333 total: s.int().default(0).merge("counter") as unknown as IntColumn<false, true>,3434 }),35+ transformed: s.table({36+ // Stored as comma-joined TEXT, viewed as string[]. The untyped37+ // `transform(): ColumnBuilder` signature shadows the typed overload the38+ // same way `.merge()` does, so the cast restores the transformed value39+ // type.40+ tags: s.string().transform({41+ from: (value: string) => value.split(","),42+ to: (value: string[]) => value.join(","),43+ }) as unknown as StringColumn<false, false, string[]>,44+ label: s.string(),45+ }),3546 gated: s.table({ visible: s.boolean(), title: s.string() }),3647 });3748
@@ -66,6 +77,7 @@ const allowAllTables = [
6677 "refs",6778 "arrays",6879 "modifiers",80+ "transformed",6981 ] as const;7082 7183 type RuleSet = {
@@ -376,6 +388,43 @@ Deno.test("CANARY alpha.53: g-set cross-table writes are unreliable", async () =
376388 );377389 });378390 391+Deno.test("transformed columns apply the TypeScript-boundary transform on insert, read, and update", async () => {392+ const testApp = await createPolicyTestApp(app, permissions, expectLike);393+ try {394+ const db = testApp.as(session("author"));395+ const row = await db.insert(app.transformed, { tags: ["a", "b"], label: "first" })396+ .wait({ tier: "global" });397+ assert(398+ sameValue(row.tags, ["a", "b"]),399+ `transformed: insert returned ${describeValue(row.tags)}, not the view value`,400+ );401+ const readBack = (await db.all(app.transformed.where({ id: row.id })))[0];402+ assert(readBack !== undefined, "transformed: row lost after insert");403+ assert(404+ sameValue(readBack.tags, ["a", "b"]),405+ `transformed: read back ${describeValue(readBack.tags)}, not the view value`,406+ );407+408+ await db.update(app.transformed, row.id, { tags: ["a", "b", "c"] }).wait({ tier: "global" });409+ const updated = (await db.all(app.transformed.where({ id: row.id })))[0];410+ assert(411+ sameValue(updated?.tags, ["a", "b", "c"]),412+ `transformed: update read back ${describeValue(updated?.tags)}`,413+ );414+415+ // Filters address the stored representation, not the view: the where type416+ // for a transformed column is the stored type, and the runtime agrees.417+ await db.insert(app.transformed, { tags: ["z"], label: "decoy" }).wait({ tier: "global" });418+ const byStored = await db.all(app.transformed.where({ tags: "a,b,c" }));419+ assert(420+ byStored.length === 1 && byStored[0].label === "first",421+ `transformed: where on the stored value returned ${byStored.length} rows`,422+ );423+ } finally {424+ await testApp.shutdown();425+ }426+});427+379428 Deno.test("policies filter on typed columns, not only magic columns", async () => {380429 const testApp = await createPolicyTestApp(app, permissions, expectLike);381430 try {
M package/schema/merge_sync_test.ts +81 -3
@@ -1,5 +1,6 @@
1-// Concurrent-writer merge semantics for the facade's merge strategies:2-// counter and g-set columns written by two synced clients. Single-session1+// 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-session34 // behavior is covered by the conformance suite; this file is the multi-client45 // half, built on the JazzServer setup from package/access/access_sync_test.ts.56 //
@@ -9,7 +10,7 @@
910 import { createDb } from "jazz-tools";1011 import { deploy, startLocalJazzServer } from "jazz-tools/testing";1112 import { assert } from "../runtime/test-assert.ts";12-import { type ArrayColumn, type IntColumn, s } from "./mod.ts";13+import { type ArrayColumn, type IntColumn, s, type StringColumn } from "./mod.ts";1314 1415 const counterApp = s.defineApp({1516 tallies: s.table({
@@ -40,6 +41,22 @@ const gsetPermissions = s.definePermissions(gsetApp, ({ policy }) => {
4041 policy.tagged.allowDelete.always();4142 });4243 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+4360 function secret(fill: number): string {4461 let binary = "";4562 for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);
@@ -266,3 +283,64 @@ Deno.test("g-set columns union concurrent offline updates and converge everywher
266283 await harness.stop();267284 }268285 });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+});
M package/schema/mod.ts +8 -4