Add @nzip/lofi/schema, a curated one-to-one re-export of the pinned Jazz 2 schema DSL, so author code (src/schema.ts, src/permissions.ts) no longer imports jazz-tools directly. The author-boundary test now enforces the boundary with no exemptions. A column-type conformance suite (package/schema/conformance_test.ts, deno task test:conformance, wired into deno task check) exercises every facade column type against the real engine and pins known alpha.53 findings so they self-announce when upstream fixes land: - .merge() erases column typing (cast workaround documented) - s.int() is i32 at runtime despite the number static type - timestamp where-equality matches every row and wedges the FFI driver - g-set cross-table write instability (subprocess canary) - short byte payloads unreliable (subprocess canary) Also updates docs and the reference app to import from the facade, and adds the decision record docs/decisions/schema-facade-alpha53.md. Closes the "land the facade + conformance branch" item of #105.
- commit
- 85720bcc6276b354e35ca0dcadd0e5d2e0ba948d
- parent
- 1dc6ad79ff0894044f34b2eb506136d54e4c9366
- author
- Dami <dami.etoile@gmail.com>
@@ -19,6 +19,7 @@
1919 "@nzip/lofi/recipes/scope-extension": "../../package/recipes/scope-extension.ts",2020 "@nzip/lofi/recipes/web-share": "../../package/recipes/web-share.ts",2121 "@nzip/lofi/recipes/window-controls-overlay": "../../package/recipes/window-controls-overlay.ts",22+ "@nzip/lofi/schema": "../../package/schema/mod.ts",2223 "@nzip/lofi/test": "../../package/commands/run_tests.ts",2324 "@nzip/lofi/testing": "../../package/testing/mod.ts",2425 "astro": "npm:astro@7.0.9",@@ -1,8 +1,8 @@
1-import { schema as s } from "jazz-tools";1+import { s } from "@nzip/lofi/schema";22 3-// Author-owned schema. This file and permissions.ts are the starter's two4-// deliberate raw-Jazz surfaces; UI islands consume the schema only through5-// public @nzip/lofi seams (see tests/author-boundary_test.ts).3+// Author-owned schema, declared through the lofi schema surface; UI islands4+// consume it only through public @nzip/lofi seams (see5+// tests/author-boundary_test.ts).66 const schema = {77 tasks: s.table({88 text: s.string(),@@ -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 });@@ -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/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",@@ -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,10 +35,12 @@ 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. See@@ -0,0 +1,103 @@
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+- `apps/reference/tests/author-boundary_test.ts` — rejects `jazz-tools` imports in all author52+ source, including `schema.ts` and `permissions.ts`.53+- `deno task check` and `deno task build` pass with the reference app (and therefore the starter,54+ which copies it) migrated to the facade.55+56+## Contract delta57+58+The author-boundary rows for `schema.ts` and `permissions.ts` no longer allow direct pinned Jazz59+declarations; both declare through `@nzip/lofi/schema`. The schema safe default changes from "use60+the smallest verified Jazz 2 surface" to "expose the pinned DSL through the re-export, never a61+second schema language." Version bumps of `jazz-tools` become package releases: upstream renames are62+absorbed by the facade instead of breaking application schemas.63+64+## Conformance findings (alpha.53)65+66+The column-type conformance suite (`package/schema/conformance_test.ts`, task67+`deno task test:conformance`, part of `deno task check`) exercises every facade column type through68+the official policy test harness against the real engine. Verified working end to end: string,69+boolean, int (within i32), float, timestamp storage, enum, ref, json, array, bytes (≥32-byte70+payloads observed reliable), `.optional()`, `.default()`, counter merge (single-session), g-set71+merge (single-table apps), and policy conditions over typed columns. Engine and type bugs found and72+pinned in the suite so an alpha bump surfaces any change:73+74+1. **`.merge()` erases column typing.** The legacy untyped `merge(): this` signature shadows the75+ typed overload, degrading the column to `ColumnBuilder` and poisoning the whole table's row76+ types. Runtime unaffected. Workaround documented on `@nzip/lofi/schema`: cast the result back77+ (e.g. `as unknown as IntColumn<false, true>`).78+2. **Int columns are i32 at runtime.** Values outside i32 are rejected with79+ `InvalidArg … expected80+ i32` despite the `number` static type. Pinned.81+3. **Timestamp where-equality matches every row** (verified with distinct non-epoch dates), and82+ running that query wedges the FFI driver: the next harness boot in the same process hangs on its83+ first write. Pinned; the scalar test deliberately runs last.84+4. **A g-set column destabilizes cross-table writes.** In a process that has booted harnesses85+ before, writing a sibling table before the g-set table's first write hangs indefinitely86+ (reproduced five times in bisection); a fresh process may not reproduce it. Covered by a tolerant87+ subprocess canary; g-set coverage stays isolated in a single-table app.88+5. **Short byte payloads are unreliable.** A 2-byte insert has been observed succeeding, failing89+ with `WriteError … data too short for column value`, and hanging, depending on process context.90+ Covered by a tolerant subprocess canary.91+92+Hangs cannot be cancelled through FFI, so the canaries run in child processes that force-exit93+(`package/testdata/gset_hang_canary.ts`, `package/testdata/bytes_short_payload_canary.ts`).94+95+## Follow-up96+97+- Report findings 2–5 upstream to Jazz; re-verify all pins on each alpha bump and remove the ones98+ upstream fixes.99+- Document the full column palette in `docs/data-and-ui.md`, including the i32 limit and the100+ merge-cast workaround, once the surface stabilizes enough to teach.101+- Concurrent-writer merge semantics (counter, g-set) need two synced clients and remain untested.102+- Revisit the curation list on each alpha bump; `defineSliceableApp` joins the surface when a use103+ case and tests exist.@@ -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@@ -6,7 +6,7 @@ no realtime permission mode and no group-specific query hook.
66 ## Schema77 88 ```ts9-import { schema as s } from "jazz-tools";9+import { s } from "@nzip/lofi/schema";1010 import { groupMembershipTable } from "@nzip/lofi/access";1111 1212 export const app = s.defineApp({@@ -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@@ -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 {@@ -4,7 +4,7 @@
44 // when a new runtime module lands without being added to the manifest55 // (namespace-state.ts and transport-gate.ts shipped exactly that way and66 // broke every generated project's build until vendoring caught up).7-import { accessFiles, preactFiles, recipeFiles, runtimeFiles } from "./mod.ts";7+import { accessFiles, preactFiles, recipeFiles, runtimeFiles, schemaFiles } from "./mod.ts";88 99 function assert(condition: unknown, message: string): asserts condition {1010 if (!condition) throw new Error(message);@@ -33,6 +33,7 @@ const manifests: ReadonlyArray<[string, readonly string[]]> = [
3333 ["access", accessFiles],3434 ["preact", preactFiles],3535 ["recipes", recipeFiles],36+ ["schema", schemaFiles],3637 ];3738 3839 Deno.test("the .lofi vendoring manifests match the package directories", async () => {@@ -159,6 +159,7 @@ const lofiImportTargets: Record<string, { subpath: string; localPath: string }>
159159 subpath: "recipes/window-controls-overlay",160160 localPath: "recipes/window-controls-overlay.ts",161161 },162+ "@nzip/lofi/schema": { subpath: "schema", localPath: "schema/mod.ts" },162163 "@nzip/lofi/test": { subpath: "test", localPath: "commands/run_tests.ts" },163164 "@nzip/lofi/testing": { subpath: "testing", localPath: "testing/mod.ts" },164165 };@@ -106,8 +106,8 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
106106 .filter(([name]) => name.startsWith("@nzip/lofi/"))107107 .map(([, specifier]) => String(specifier));108108 assert(109- lofiSpecifiers.length === 18,110- `expected one package prefix, access, two integrations, six commands, seven recipes, and testing, received ${lofiSpecifiers.length}`,109+ lofiSpecifiers.length === 19,110+ `expected one package prefix, access, schema, two integrations, six commands, seven recipes, and testing, received ${lofiSpecifiers.length}`,111111 );112112 assert(113113 lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.4.0/")),@@ -158,6 +158,7 @@ Deno.test("package manifest and generated version stay coupled", () => {
158158 "./recipes/scope-extension",159159 "./recipes/web-share",160160 "./recipes/window-controls-overlay",161+ "./schema",161162 "./test",162163 "./testing",163164 ]);@@ -0,0 +1,456 @@
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 } 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+ // CONFORMANCE FINDING (alpha.53): `.merge()` returns the untyped builder30+ // because the legacy `merge(): this` signature shadows the typed overload,31+ // which poisons the whole table's row types. The cast restores the typed32+ // column; the runtime object is correct without it.33+ total: s.int().default(0).merge("counter") as unknown as IntColumn<false, true>,34+ }),35+ gated: s.table({ visible: s.boolean(), title: s.string() }),36+});37+38+// CONFORMANCE FINDING (alpha.53): a `g-set` column anywhere in a schema makes39+// writes to *other* tables in the same app hang forever (bisected via a40+// cumulative table-by-table probe; boots fine, first cross-table write never41+// resolves). The g-set coverage therefore lives in its own single-table app,42+// and the hang itself is pinned by the subprocess canary test below.43+const gsetApp = s.defineApp({44+ tagged: s.table({45+ name: s.string(),46+ tags: s.array(s.string()).merge("g-set") as unknown as ArrayColumn<"TEXT">,47+ }),48+});49+const gsetPermissions = s.definePermissions(gsetApp, ({ policy }) => {50+ policy.tagged.allowInsert.always();51+ policy.tagged.allowRead.always();52+ policy.tagged.allowUpdate.always();53+ policy.tagged.allowDelete.always();54+});55+56+const allowAllTables = [57+ "parents",58+ "strings",59+ "booleans",60+ "ints",61+ "floats",62+ "timestamps",63+ "blobs",64+ "jsons",65+ "enums",66+ "refs",67+ "arrays",68+ "modifiers",69+] as const;70+71+type RuleSet = {72+ always(): void;73+ where(condition: Record<string, unknown>): void;74+};75+type TableRules = Record<"allowInsert" | "allowRead" | "allowUpdate" | "allowDelete", RuleSet>;76+77+const permissions = s.definePermissions(app, ({ policy }) => {78+ const rules = policy as unknown as Record<string, TableRules>;79+ for (const table of allowAllTables) {80+ rules[table].allowInsert.always();81+ rules[table].allowRead.always();82+ rules[table].allowUpdate.always();83+ rules[table].allowDelete.always();84+ }85+ // Policy conditions must match over a typed column, not only magic columns.86+ policy.gated.allowInsert.always();87+ policy.gated.allowRead.where({ visible: true });88+ policy.gated.allowUpdate.always();89+ policy.gated.allowDelete.always();90+});91+92+type ThrowExpectation = {93+ toThrow(expected?: unknown): void;94+ not: { toThrow(expected?: unknown): void };95+};96+97+function expectLike(value: unknown): ThrowExpectation {98+ const run = (): unknown => {99+ if (typeof value !== "function") throw new Error("expectLike requires a callback");100+ try {101+ value();102+ return undefined;103+ } catch (error) {104+ return error ?? new Error("threw undefined");105+ }106+ };107+ const matches = (error: unknown, expected: unknown): boolean => {108+ if (expected === undefined) return error !== undefined;109+ const message = error instanceof Error ? error.message : String(error);110+ return typeof expected === "string"111+ ? message.includes(expected)112+ : error instanceof (expected as typeof Error);113+ };114+ return {115+ toThrow(expected?: unknown) {116+ const error = run();117+ if (!matches(error, expected)) {118+ throw new Error(`expected callback to throw ${String(expected)}`);119+ }120+ },121+ not: {122+ toThrow(expected?: unknown) {123+ const error = run();124+ if (error !== undefined && (expected === undefined || matches(error, expected))) {125+ const message = error instanceof Error ? error.message : String(error);126+ throw new Error(`expected callback not to throw, received: ${message}`);127+ }128+ },129+ },130+ };131+}132+133+const session = (user_id: string) => ({ user_id, claims: {}, authMode: "local-first" as const });134+135+function sameValue(actual: unknown, expected: unknown): boolean {136+ if (expected instanceof Date) {137+ return actual instanceof Date138+ ? actual.getTime() === expected.getTime()139+ : actual === expected.getTime();140+ }141+ if (expected instanceof Uint8Array) {142+ return actual instanceof Uint8Array &&143+ actual.length === expected.length &&144+ expected.every((byte, index) => actual[index] === byte);145+ }146+ if (Array.isArray(expected)) {147+ return Array.isArray(actual) &&148+ actual.length === expected.length &&149+ expected.every((item, index) => sameValue(actual[index], item));150+ }151+ if (expected !== null && typeof expected === "object") {152+ if (actual === null || typeof actual !== "object") return false;153+ const expectedEntries = Object.entries(expected);154+ return expectedEntries.length === Object.keys(actual).length &&155+ expectedEntries.every(([key, item]) =>156+ sameValue((actual as Record<string, unknown>)[key], item)157+ );158+ }159+ return actual === expected;160+}161+162+function describeValue(value: unknown): string {163+ if (value instanceof Uint8Array) return `Uint8Array(${value.length})[${value.join(",")}]`;164+ if (value instanceof Date) return `Date(${value.toISOString()})`;165+ return JSON.stringify(value);166+}167+168+// One representative and one contrasting value per scalar column family. The169+// contrasting value proves where-equality actually filters instead of170+// matching everything.171+const scalarCases = [172+ { table: "strings", value: "héllo wörld", other: "" },173+ { table: "booleans", value: true, other: false },174+ // CONFORMANCE FINDING (alpha.53): int columns are i32 at runtime despite175+ // the `number` static type — see the i64 rejection pin below.176+ { table: "ints", value: 2_147_483_640, other: -1 },177+ { table: "floats", value: 3.141592653589793, other: -0.5 },178+ // PINNED (alpha.53): where-equality on a timestamp column matches every179+ // row (verified with distinct non-epoch dates). When the pinned count180+ // below reads 1 again, upstream fixed the comparison — clear the flag.181+ {182+ table: "timestamps",183+ value: new Date("2026-07-18T12:34:56.789Z"),184+ other: new Date("2001-02-03T04:05:06.007Z"),185+ brokenWhereEquality: true,186+ },187+ { table: "enums", value: "published", other: "draft" },188+] as const;189+190+// The scalar sweep iterates tables dynamically, which steps outside the typed191+// query surface; these loose structural views cover only the sweep. The192+// structured-columns test below stays fully typed.193+type Row = { id: string } & Record<string, unknown>;194+type LooseHandle = { where(condition: Record<string, unknown>): unknown };195+type LooseDb = {196+ insert(table: unknown, values: unknown): { wait(opts: { tier: "global" }): Promise<Row> };197+ update(table: unknown, id: string, patch: unknown): { wait(opts: { tier: "global" }): unknown };198+ all(query: unknown): Promise<Row[]>;199+};200+201+Deno.test("facade structured columns (bytes, json, array, ref) round-trip through the real engine", async () => {202+ const testApp = await createPolicyTestApp(app, permissions, expectLike);203+ try {204+ const db = testApp.as(session("author"));205+206+ const payload = new Uint8Array(32).map((_, index) => (index * 37) % 256);207+ const blob = await db.insert(app.blobs, { value: payload }).wait({ tier: "global" });208+ const blobRows = await db.all(app.blobs.where({ id: blob.id }));209+ assert(210+ blobRows.length === 1 && sameValue(blobRows[0].value, payload),211+ `bytes: read back ${describeValue(blobRows[0]?.value)}`,212+ );213+214+ const document = { nested: { list: [1, "two", null], flag: true }, empty: {} };215+ const json = await db.insert(app.jsons, { value: document }).wait({ tier: "global" });216+ const jsonRows = await db.all(app.jsons.where({ id: json.id }));217+ assert(218+ jsonRows.length === 1 && sameValue(jsonRows[0].value, document),219+ `json: read back ${describeValue(jsonRows[0]?.value)}`,220+ );221+222+ const items = ["alpha", "beta", "beta"];223+ const array = await db.insert(app.arrays, { value: items }).wait({ tier: "global" });224+ const arrayRows = await db.all(app.arrays.where({ id: array.id }));225+ assert(226+ arrayRows.length === 1 && sameValue(arrayRows[0].value, items),227+ `array: read back ${describeValue(arrayRows[0]?.value)}`,228+ );229+230+ const parent = await db.insert(app.parents, { name: "parent" }).wait({ tier: "global" });231+ const decoy = await db.insert(app.parents, { name: "decoy" }).wait({ tier: "global" });232+ await db.insert(app.refs, { parentId: parent.id, label: "child" }).wait({ tier: "global" });233+ await db.insert(app.refs, { parentId: decoy.id, label: "other" }).wait({ tier: "global" });234+ const children = await db.all(app.refs.where({ parentId: parent.id }));235+ assert(236+ children.length === 1 && children[0].label === "child",237+ `ref: where on the foreign key returned ${children.length} rows`,238+ );239+ } finally {240+ await testApp.shutdown();241+ }242+});243+244+Deno.test("facade column modifiers (optional, default, counter, g-set) behave through the real engine", async () => {245+ const testApp = await createPolicyTestApp(app, permissions, expectLike);246+ try {247+ const db = testApp.as(session("author"));248+249+ const row = await db.insert(app.modifiers, { required: "present" })250+ .wait({ tier: "global" });251+ const readBack = (await db.all(app.modifiers.where({ id: row.id })))[0];252+ assert(readBack !== undefined, "modifiers: row lost after insert");253+ assert(254+ readBack.note === null || readBack.note === undefined,255+ `optional: omitted column read back as ${describeValue(readBack.note)}`,256+ );257+ assert(258+ readBack.label === "untitled",259+ `default: omitted column read back as ${describeValue(readBack.label)}`,260+ );261+ assert(262+ readBack.total === 0,263+ `counter default: read back ${describeValue(readBack.total)}`,264+ );265+266+ // Single-session semantics only; concurrent-writer merge behavior needs267+ // two synced clients and stays a follow-up (see the schema facade268+ // decision record).269+ await db.update(app.modifiers, row.id, { total: 5 }).wait({ tier: "global" });270+ const bumped = (await db.all(app.modifiers.where({ id: row.id })))[0];271+ assert(bumped !== undefined, "modifiers: row lost after update");272+ assert(bumped.total === 5, `counter: update read back ${describeValue(bumped.total)}`);273+274+ await db.update(app.modifiers, row.id, { note: "written" }).wait({ tier: "global" });275+ const cleared = (await db.all(app.modifiers.where({ id: row.id })))[0];276+ assert(cleared?.note === "written", "optional: column did not accept a value");277+ } finally {278+ await testApp.shutdown();279+ }280+});281+282+Deno.test("g-set columns round-trip inside their own single-table app", async () => {283+ const testApp = await createPolicyTestApp(gsetApp, gsetPermissions, expectLike);284+ try {285+ const db = testApp.as(session("author"));286+ const row = await db.insert(gsetApp.tagged, { name: "first", tags: ["a"] })287+ .wait({ tier: "global" });288+ await db.update(gsetApp.tagged, row.id, { tags: ["a", "b"] }).wait({ tier: "global" });289+ const readBack = (await db.all(gsetApp.tagged.where({ id: row.id })))[0];290+ assert(readBack !== undefined, "g-set: row lost after update");291+ assert(292+ sameValue(readBack.tags, ["a", "b"]),293+ `g-set: update read back ${describeValue(readBack.tags)}`,294+ );295+ } finally {296+ await testApp.shutdown();297+ }298+});299+300+async function runGsetCanary(mode: "sibling-first" | "gset-first"): Promise<string> {301+ const script = new URL("../testdata/gset_hang_canary.ts", import.meta.url);302+ const repoRoot = new URL("../../", import.meta.url);303+ const command = new Deno.Command(Deno.execPath(), {304+ args: [305+ "run",306+ "--allow-env",307+ "--allow-net=127.0.0.1",308+ "--allow-read",309+ "--allow-write",310+ "--allow-run",311+ "--allow-ffi",312+ script.pathname,313+ mode,314+ ],315+ cwd: repoRoot.pathname,316+ stdout: "piped",317+ stderr: "piped",318+ });319+ const output = await command.output();320+ return new TextDecoder().decode(output.stdout);321+}322+323+Deno.test("CANARY alpha.53: short byte payloads are unreliable", async () => {324+ // A 2-byte insert has been observed succeeding, failing with the325+ // flat-history encoder error ("data too short for column value"), and326+ // hanging — context-dependent, so it runs behind a race in a child process327+ // and every known mode is accepted. An unknown error message is the only328+ // failure: it means the encoder changed and the bytes coverage should be329+ // revisited (ideally widened to short payloads).330+ const script = new URL("../testdata/bytes_short_payload_canary.ts", import.meta.url);331+ const repoRoot = new URL("../../", import.meta.url);332+ const command = new Deno.Command(Deno.execPath(), {333+ args: [334+ "run",335+ "--allow-env",336+ "--allow-net=127.0.0.1",337+ "--allow-read",338+ "--allow-write",339+ "--allow-run",340+ "--allow-ffi",341+ script.pathname,342+ ],343+ cwd: repoRoot.pathname,344+ stdout: "piped",345+ stderr: "piped",346+ });347+ const output = await command.output();348+ const stdout = new TextDecoder().decode(output.stdout);349+ const known = stdout.includes("short-bytes:OK") ||350+ stdout.includes("short-bytes:HUNG") ||351+ (stdout.includes("short-bytes:ERROR") && stdout.includes("data too short"));352+ assert(known, `bytes canary: unknown short-payload behavior: ${stdout}`);353+ console.log(`[conformance] ${stdout.trim()}`);354+});355+356+Deno.test("CANARY alpha.53: g-set cross-table writes are unreliable", async () => {357+ // Writing a sibling table before a g-set table's first write hangs358+ // reproducibly inside a process that has already booted policy test359+ // harnesses (five independent bisection runs), but not always in a fresh360+ // process — the wedge depends on process state, like the timestamp-query361+ // wedge documented on the scalar test. Both modes therefore run in child362+ // processes that force-exit, and either outcome is accepted and reported;363+ // only an outright error is a failure. The g-set coverage stays isolated in364+ // its own single-table app until upstream stabilizes this.365+ const siblingFirst = await runGsetCanary("sibling-first");366+ assert(367+ siblingFirst.includes("sibling-table-write:OK") ||368+ siblingFirst.includes("sibling-table-write:HUNG"),369+ `g-set canary: unexpected sibling-first outcome: ${siblingFirst}`,370+ );371+ console.log(`[conformance] g-set sibling-first: ${siblingFirst.trim()}`);372+373+ const gsetFirst = await runGsetCanary("gset-first");374+ assert(375+ gsetFirst.includes("gset-table-write:OK") && gsetFirst.includes("sibling-table-write:OK"),376+ `g-set canary: seeding the g-set table first no longer unblocks sibling writes: ${gsetFirst}`,377+ );378+});379+380+Deno.test("policies filter on typed columns, not only magic columns", async () => {381+ const testApp = await createPolicyTestApp(app, permissions, expectLike);382+ try {383+ const db = testApp.as(session("author"));384+ await db.insert(app.gated, { visible: true, title: "shown" }).wait({ tier: "global" });385+ await db.insert(app.gated, { visible: false, title: "hidden" }).wait({ tier: "global" });386+ const readable = await db.all(app.gated.where({}));387+ assert(388+ readable.length === 1 && readable[0].title === "shown",389+ `gated: policy on a boolean column exposed ${readable.length} rows`,390+ );391+ } finally {392+ await testApp.shutdown();393+ }394+});395+396+// CONFORMANCE FINDING (alpha.53): this test runs LAST deliberately. Running397+// the broken timestamp where-equality query (pinned below) leaves the FFI398+// driver wedged: the next createPolicyTestApp boot in the same process hangs399+// on its first write. Observed across three orderings; tests that run before400+// this one are unaffected.401+Deno.test("facade scalar columns round-trip, filter, and update through the real engine", async () => {402+ const testApp = await createPolicyTestApp(app, permissions, expectLike);403+ try {404+ const db = testApp.as(session("author")) as unknown as LooseDb;405+ const handles = app as unknown as Record<string, LooseHandle>;406+ for (const { table, value, other } of scalarCases) {407+ const handle = handles[table];408+ const inserted = await db.insert(handle, { value }).wait({ tier: "global" });409+ await db.insert(handle, { value: other }).wait({ tier: "global" });410+411+ const byId = await db.all(handle.where({ id: inserted.id }));412+ assert(byId.length === 1, `${table}: row lost after insert`);413+ assert(414+ sameValue(byId[0].value, value),415+ `${table}: read back ${describeValue(byId[0].value)}, inserted ${describeValue(value)}`,416+ );417+418+ const byValue = await db.all(handle.where({ value }));419+ if ("brokenWhereEquality" in scalarCases.find((c) => c.table === table)!) {420+ assert(421+ byValue.length === 2,422+ `${table}: pinned broken where-equality changed — returned ${byValue.length} rows; ` +423+ `if 1, upstream fixed the filter: remove the pin`,424+ );425+ } else {426+ assert(427+ byValue.length === 1 && byValue[0].id === inserted.id,428+ `${table}: where-equality on the typed column returned ${byValue.length} rows`,429+ );430+ }431+432+ await db.update(handle, inserted.id, { value: other }).wait({ tier: "global" });433+ const afterUpdate = await db.all(handle.where({ id: inserted.id }));434+ assert(435+ afterUpdate.length === 1 && sameValue(afterUpdate[0].value, other),436+ `${table}: update read back ${describeValue(afterUpdate[0]?.value)}`,437+ );438+ }439+440+ // PINNED (alpha.53): despite the `number` static type, int columns reject441+ // values outside i32 with InvalidArg. When this pin fails, upstream442+ // widened the runtime type — update the docs and the scalar case above.443+ let i64Error = "";444+ try {445+ await db.insert(handles.ints, { value: 2 ** 40 + 7 }).wait({ tier: "global" });446+ } catch (error) {447+ i64Error = error instanceof Error ? error.message : String(error);448+ }449+ assert(450+ i64Error.includes("expected i32"),451+ `ints: pinned i32 limit changed: ${i64Error || "no error thrown"}`,452+ );453+ } finally {454+ await testApp.shutdown();455+ }456+});@@ -0,0 +1,106 @@
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()` on a column loses the typed builder87+ * (the untyped `merge(): this` signature shadows the typed overload88+ * upstream), so cast the result back to the intended column type. The89+ * 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+ * ```94+ */95+export type {96+ ArrayColumn,97+ BooleanColumn,98+ BytesColumn,99+ EnumColumn,100+ FloatColumn,101+ IntColumn,102+ JsonColumn,103+ RefColumn,104+ StringColumn,105+ TimestampColumn,106+} from "jazz-tools";@@ -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+});@@ -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);@@ -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);@@ -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",