clone

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

feat/prf-provision-custody
lofi / package / schema / conformance_test.ts
conformance_test.ts 22.3K
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.9import { createPolicyTestApp } from "jazz-tools/testing";10import { assert } from "../runtime/test-assert.ts";11import { type ArrayColumn, type IntColumn, s, type StringColumn } from "./mod.ts";1213const 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});4950// 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.55const 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});61const 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});6768const 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;8384type RuleSet = {85 always(): void;86 where(condition: Record<string, unknown>): void;87};88type TableRules = Record<"allowInsert" | "allowRead" | "allowUpdate" | "allowDelete", RuleSet>;8990const 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});107108type ThrowExpectation = {109 toThrow(expected?: unknown): void;110 not: { toThrow(expected?: unknown): void };111};112113function 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}148149const session = (user_id: string) => ({ user_id, claims: {}, authMode: "local-first" as const });150151function 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}177178function 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}183184// One representative and one contrasting value per scalar column family. The185// contrasting value proves where-equality actually filters instead of186// matching everything.187const 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;205206// 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.209type Row = { id: string } & Record<string, unknown>;210type LooseHandle = { where(condition: Record<string, unknown>): unknown };211type 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};216217Deno.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"));221222 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 );229230 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 );237238 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 );245246 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});259260Deno.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"));264265 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 );291292 // 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)}`);298299 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});306307Deno.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});324325async 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}347348Deno.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});380381Deno.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()}`);397398 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});404405Deno.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 );421422 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 );428429 // 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});441442Deno.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 );461462 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});476477// 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.482Deno.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" });491492 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 );498499 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 }512513 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 }520521 // 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});