clone

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

feat(schema): verify migration surface + four focused facade examples

Dami committed eac35ae
Close the last unexercised areas of the schema facade:

- package/schema/migration_test.ts: defineSchema, defineMigration,
  renameTableFrom, and add/drop/renameFrom verified against a real
  JazzServer. Forward lens carries v1 rows into v2 (rename keeps data,
  add fills its default, drop disappears, table rename answers), the
  auto-generated backward lens serves v2 rows to v1 readers with
  backwardsDefault filled in, and deploying a changed schema without its
  migration reports status "missing" and withholds permissions.
- Conformance: anyOf policy alternatives verified with two sessions, and
  permissionIntrospectionColumns ($canRead/$canEdit/$canDelete) verified
  to mirror the deployed policy per row — resolving its curation
  question in favor of keeping it.

Add four small examples for discovery, slotted into the two example
subsections: policy conditions on typed columns (access templates);
collaborative sets, binary and structured payloads, and schema
evolution (data modeling). Every example teaches only engine-verified
surface and links the test that proves it.

Continues #105.
commit
eac35aec2e803c0eac45914fceb895e206222784
parent
661589a305dbaa2d4608ec92056f75465d7bb5a4
author
Dami <dami.etoile@gmail.com>
11 files changed, +491 -16
Mdeno.json2+-
Mdocs/README.md4+
Mdocs/data-and-ui.md7+-
Mdocs/decisions/schema-facade-alpha53.md16++-
Adocs/examples/collaborative-sets.md46++++++
Adocs/examples/payloads.md44++++++
Adocs/examples/policy-conditions.md59++++++++
Adocs/examples/schema-evolution.md70++++++++++
Mpackage/schema/conformance_test.ts36++++-
Apackage/schema/migration_test.ts215++++++++++++++++++++++++++++++
Mwebsite/docs-manifest.json8+-
M deno.json +1 -1
@@ -56,7 +56,7 @@
5656 "docs:links": "deno run --allow-read tools/docs_links.ts",5757 "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",5858 "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/conformance_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",6060 "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",6161 "test": "deno task test:unit",6262 "test:browser:install": "deno run -A npm:playwright@1.61.1 install chromium",
M docs/README.md +4 -0
M docs/data-and-ui.md +5 -2
@@ -104,7 +104,8 @@ CoValue types (`co.map`, CoText, FileStream). All three are verified with two sy
104104 conflict resolves to the last write to reach the server, and live replicas and fresh boots agree.105105 - **`"g-set"` works**: concurrent writers union their elements and every replica — including a fresh106106 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.107+ pinned alpha a g-set column destabilizes writes to sibling tables in the same app. See the108+ [collaborative sets example](examples/collaborative-sets.md).108109 - **`"counter"` is not dependable yet**: the server keeps the last causally ordered update value and109110 sums only concurrent updates, while live replicas add every update as a delta, so a replica that110111 watched the history diverges permanently from what a fresh boot reads. Avoid counter columns until
@@ -212,4 +213,6 @@ example. For a complete access-aware composition, see the
212213 213214 Use `deno task schema:validate` while editing. For existing data, create and review a migration with214215 `deno task migrations:create`, then use `migrations:push` and `schema:deploy` only with the intended215-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.
M docs/decisions/schema-facade-alpha53.md +12 -4
@@ -50,6 +50,9 @@ test.
5050 against `jazz-tools` and round-trips `defineApp`/`definePermissions` through the facade.5151 - `package/schema/merge_sync_test.ts` — concurrent-writer merge semantics (lww, counter, g-set) over5252 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.5356 - `apps/reference/tests/author-boundary_test.ts` — rejects `jazz-tools` imports in all author5457 source, including `schema.ts` and `permissions.ts`.5558 - `deno task check` and `deno task build` pass with the reference app (and therefore the starter,
@@ -72,10 +75,15 @@ boolean, int (within i32), float, timestamp storage, enum, ref, json, array, byt
7275 payloads observed reliable), `.optional()`, `.default()`, `.transform()` (view type on7376 insert/read/update; `where` filters take the stored type), lww merge (the default; concurrent7477 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:78+(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:7987 8088 1. **`.merge()` and `.transform()` erase column typing.** The legacy untyped signatures8189 (`merge(): this`, `transform(): ColumnBuilder`) shadow the typed overloads, degrading the column
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.
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 package/schema/conformance_test.ts +29 -7
@@ -87,7 +87,7 @@ type RuleSet = {
8787 };8888 type TableRules = Record<"allowInsert" | "allowRead" | "allowUpdate" | "allowDelete", RuleSet>;8989 90-const permissions = s.definePermissions(app, ({ policy }) => {90+const permissions = s.definePermissions(app, ({ policy, session, anyOf }) => {9191 const rules = policy as unknown as Record<string, TableRules>;9292 for (const table of allowAllTables) {9393 rules[table].allowInsert.always();
@@ -95,11 +95,14 @@ const permissions = s.definePermissions(app, ({ policy }) => {
9595 rules[table].allowUpdate.always();9696 rules[table].allowDelete.always();9797 }98- // Policy conditions must match over a typed column, not only magic columns.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.99102 policy.gated.allowInsert.always();100- policy.gated.allowRead.where({ visible: true });103+ policy.gated.allowRead.where(anyOf([{ visible: true }, { $createdBy: session.user_id }]));101104 policy.gated.allowUpdate.always();102- policy.gated.allowDelete.always();105+ policy.gated.allowDelete.where({ visible: false });103106 });104107 105108 type ThrowExpectation = {
@@ -442,11 +445,30 @@ Deno.test("policies filter on typed columns, not only magic columns", async () =
442445 const db = testApp.as(session("author"));443446 await db.insert(app.gated, { visible: true, title: "shown" }).wait({ tier: "global" });444447 await db.insert(app.gated, { visible: false, title: "hidden" }).wait({ tier: "global" });445- const readable = await db.all(app.gated.where({}));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({}));446451 assert(447- readable.length === 1 && readable[0].title === "shown",448- `gated: policy on a boolean column exposed ${readable.length} rows`,452+ authorView.length === 2,453+ `gated: anyOf alternatives exposed ${authorView.length} rows to the author, expected 2`,449454 );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+ }450472 } finally {451473 await testApp.shutdown();452474 }
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+});
M website/docs-manifest.json +6 -2