Every JSR entry now exports every locally-declared type its public signatures reference, enforced by a new type-closure lint wired into the check task. Runtime installers, PoP wire internals, shared-field boot wiring, and test seams leave the author entries; test-facing seams are re-exported from ./testing instead. The shared-field journey is anchored across schema/access/runtime with both error families catchable from ./access. WriteHandle's lifecycle mutators move behind a module-internal controller so app code cannot forge settlement. Naming consistency: expiresAfterMs everywhere, TableStore.remove (delete deprecated), InstalledApp* recipe prefixes, shared-field template kind, completed is*Error guard family. The schema entries join the entrypoint catalog so the site's generated API reference covers them.
- commit
- 6cdf1ab412769dd8d5d299730317f7d9053767fd
- parent
- 591e3f2497cb781e21cbd1e2ceecc87708f22dd2
- author
- FelineStateMachine <dami.etoile@gmail.com>
@@ -61,8 +61,9 @@
6161 },6262 "tasks": {6363 "build": "deno task --cwd apps/reference build",64- "check": "deno fmt --check . && deno lint && deno task doc && deno task docs:links && deno task test:unit && deno task test:access && deno task test:effects && deno task test:conformance",64+ "check": "deno fmt --check . && deno lint && deno task doc && deno task doc:closure && deno task docs:links && deno task test:unit && deno task test:access && deno task test:effects && deno task test:conformance",6565 "doc": "deno run --allow-run tools/doc_lint.ts",66+ "doc:closure": "deno run --allow-run --allow-read tools/type_closure_lint.ts",6667 "docs:links": "deno run --allow-read tools/docs_links.ts",6768 "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/compat_test.ts package/schema/effects_test.ts package/schema/encrypted_test.ts package/schema/encrypted_types_test.ts package/schema/mod_test.ts package/schema/nested_test.ts package/schema/private_table_test.ts package/schema/shared_crypto_test.ts package/schema/shared_encrypted_test.ts package/testing package/tooling apps/reference/tests",6869 "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",@@ -105,15 +105,15 @@ fate. Repeated calls with one label share one unit.
105105 106106 Obligations, intents, and residue each have a lifespan and a defined afterlife.107107 108-- **Obligations** — `s.effect(name, table, handlers, { expiresAfter })` declares a delivery window108+- **Obligations** — `s.effect(name, table, handlers, { expiresAfterMs })` declares a delivery window109109 in milliseconds, measured from the write. When the write synced but the obligation could not be110110 delivered inside the window (device offline, handler failing), the obligation retires as expired:111111 no handler fires — the write happened, so compensation would be wrong — diagnostics count it, and112112 the entry becomes prunable. External-tier handlers whose receivers keep finite idempotency windows113113 should always set this.114-- **Intents** — `s.mutation(name, op, { expires })` declares the intent's lifespan. A write still115- pending past it is surfaced: the pending-writes set marks it `expired` and diagnostics count it.116- The runtime cannot withdraw a locally accepted write — the engine re-proposes pending batches114+- **Intents** — `s.mutation(name, op, { expiresAfterMs })` declares the intent's lifespan. A write115+ still pending past it is surfaced: the pending-writes set marks it `expired` and diagnostics count116+ it. The runtime cannot withdraw a locally accepted write — the engine re-proposes pending batches117117 until the store answers, and a rolled-back batch primitive exists only for uncommitted batches —118118 so an overdue intent is reported, never retired: retiring it as rejected could fire compensation119119 for a write that later syncs anyway. When store-side expiry enforcement lands, an overdue intent@@ -28,9 +28,12 @@ Import from `@nzip/lofi/access`:
2828 - Policy templates: `privateAccess`, `sharedAccess`, `groupAccess`, `sharedFieldAccess`,2929 `defineAccessPolicies`.3030 - Identities: `sharingIdentity`, `encodeSharingIdentity`, `decodeSharingIdentity`.31-- Operations: `createSharingOperations`, `createGroupOperations`.32-- Shared-field key lifecycle: `bootstrapGroupFieldKey`, `rotateGroupFieldKey`,33- `wrapHeldKeysForMember`, `reconcileSharedFieldKeys`.31+- Operations: `createSharingOperations`, `createGroupOperations` (configured with32+ `SharingOperationsConfig` and `GroupOperationsConfig`).33+- Errors: `AccessError`/`isAccessError` and `SharedFieldError`/`isSharedFieldError`, the two34+ catchable families of group and sharing operations.35+- Pin remediation: `trustPeerKey`, the explicit re-trust that resolves a `peer-key-changed` refusal36+ (also on the root `@nzip/lofi` export, next to `pinnedFingerprint`).3437 - Fixed roles: `reader`, `contributor`, `writer`, `admin` (exposed as `groupRoles`, with the3538 role-to-capability mapping in `groupRoleCapabilities`).3639 @@ -45,9 +48,12 @@ Columns declared with `s.sharedEncryptedText` and `s.sharedEncryptedJson` (from
4548 hold ciphertext readable by every current group member. The access side hosts the key material:4649 `sharedFieldDirectoryTable` and `sharedFieldKeyTable` store the directory and per-member wrapped4750 keys, `sharedFieldAccess` (or the `fieldKeys` option of `groupAccess`) compiles their policies, and48-the lifecycle helpers create, wrap, and rotate group field keys. Rotation is lazy: removing a member49-re-keys future writes, while content encrypted under generations the member already holds remains50-readable to them. See [Permission templates](../permissions.md) for the hosting walkthrough.51+group operations created with the same `fieldKeys`/`directory` tables mint, wrap, and rotate group52+field keys automatically: `createGroup` bootstraps the first key, `addMember` delivers held53+generations, `removeMember` rotates, and `reconcileSharedFieldKeys` repairs missing wraps. Key54+failures raise `SharedFieldError` (narrow with `isSharedFieldError`). Rotation is lazy: removing a55+member re-keys future writes, while content encrypted under generations the member already holds56+remains readable to them. See [Permission templates](../permissions.md) for the hosting walkthrough.5157 5258 In pinned Jazz alpha.53, the permission authority does not expose a group inserted earlier in the5359 same transaction to the first membership's `allowedTo.update("groupId")` check. `createGroup`@@ -9,7 +9,8 @@ import {
99 sharedAccess,1010 sharedGrantTable,1111 } from "./mod.ts";12-import { clearEncryptedColumnRegistry, s as lofiSchema } from "../schema/mod.ts";12+import { s as lofiSchema } from "../schema/mod.ts";13+import { clearEncryptedColumnRegistry } from "../schema/encrypted.ts";1314 import { assert } from "../runtime/test-assert.ts";1415 1516 const schema = {@@ -1,16 +1,38 @@
11 /**2- * Narrow private, direct-share, and fixed-role group access templates over raw3- * Jazz schemas.2+ * Narrow private, direct-share, fixed-role group, and shared-field access3+ * templates over raw Jazz schemas.44 *5- * Use {@link privateAccess}, {@link sharedAccess}, or {@link groupAccess} to6- * declare common authorization models. Runtime operations require managed sync7- * for collaboration and throw {@link AccessError} when that precondition is not8- * met. Raw Jazz policy callbacks remain available through9- * {@link defineAccessPolicies}.5+ * Use {@link privateAccess}, {@link sharedAccess}, {@link groupAccess}, or6+ * {@link sharedFieldAccess} to declare common authorization models. Runtime7+ * operations require managed sync for collaboration and throw8+ * {@link AccessError} when that precondition is not met. Raw Jazz policy9+ * callbacks remain available through {@link defineAccessPolicies}.10+ *11+ * Group and sharing operations raise two catchable error families:12+ * {@link AccessError} (narrow with {@link isAccessError}) for configuration,13+ * identity, sync, and mutation failures, and {@link SharedFieldError} (narrow14+ * with {@link isSharedFieldError}) when shared-field key material cannot be15+ * established or reconciled.16+ *17+ * Shared-field hosting spans three entries. `@nzip/lofi/schema` declares the18+ * encrypted columns (`s.sharedEncryptedText`, `s.sharedEncryptedJson`). This19+ * entry compiles the directory and wrapped-key policies and runs the key20+ * lifecycle through {@link createGroupOperations}: creation bootstraps the21+ * first key and `reconcileSharedFieldKeys` repairs missing wraps. Pin22+ * remediation for changed peer keys ({@link trustPeerKey}, with23+ * `pinnedFingerprint` for inspection) lives on the main `@nzip/lofi` entry;24+ * `trustPeerKey` is re-exported here because re-trusting a peer is an25+ * authorization decision.1026 *1127 * @module1228 */1329 export { AccessError, type AccessErrorCode, isAccessError } from "./errors.ts";30+export {31+ isSharedFieldError,32+ SharedFieldError,33+ type SharedFieldErrorCode,34+} from "../schema/shared-crypto.ts";35+export { trustPeerKey } from "../runtime/shared-field-keys.ts";1436 export {1537 decodeSharingIdentity,1638 decodeSharingIdentityDetails,@@ -19,24 +41,18 @@ export {
1941 sharingIdentity,2042 type SharingIdentityDetails,2143 } from "./identity.ts";22-export {23- bootstrapGroupFieldKey,24- reconcileSharedFieldKeys,25- rotateGroupFieldKey,26- type SharedFieldLifecycleContext,27- wrapHeldKeysForMember,28- type WrapSkip,29-} from "./shared-field-lifecycle.ts";3044 export {3145 type AccessRuntimeTable,3246 createGroupOperations,3347 createSharingOperations,3448 type GroupMembershipRow,3549 type GroupOperations,50+ type GroupOperationsConfig,3651 type Identified,3752 type SharedGrantRow,3853 type ShareLevel,3954 type SharingOperations,55+ type SharingOperationsConfig,4056 } from "./operations.ts";4157 export {4258 type AccessTable,@@ -98,16 +98,29 @@ export type SharingOperations<Resource, Grant> = {
9898 sharedWithMe(): Promise<Resource[]>;9999 };100100 101-/** Creates direct-share operations that wait for local and global durability. */102-export function createSharingOperations<101+/** Table pair consumed by {@link createSharingOperations}. */102+export type SharingOperationsConfig<103103 Resource extends Identified,104104 ResourceInit,105105 Grant extends SharedGrantRow,106106 GrantInit,107->(config: {107+> = {108+ /** The resource table whose rows are shared with other accounts. */108109 resource: AccessRuntimeTable<Resource, ResourceInit>;110+ /** The resource's grant table, declared with `sharedGrantTable` (or matching111+ * its column shape) and covered by a `sharedAccess` policy template. */109112 grants: AccessRuntimeTable<Grant, GrantInit>;110-}): SharingOperations<Resource, Grant> {113+};114+115+/** Creates direct-share operations that wait for local and global durability. */116+export function createSharingOperations<117+ Resource extends Identified,118+ ResourceInit,119+ Grant extends SharedGrantRow,120+ GrantInit,121+>(122+ config: SharingOperationsConfig<Resource, ResourceInit, Grant, GrantInit>,123+): SharingOperations<Resource, Grant> {111124 const findGrant = async (resourceId: string, userId: string): Promise<Grant | null> => {112125 const { db } = await getRuntime();113126 return await db.one(config.grants.where({ resourceId, user_id: userId } as never));@@ -169,21 +182,38 @@ export function createSharingOperations<
169182 };170183 }171184 172-/** Creates fixed-role group membership operations for a declared table pair. */173-export function createGroupOperations<185+/** Tables consumed by {@link createGroupOperations}. */186+export type GroupOperationsConfig<174187 Group extends Identified,175188 GroupInit,176189 Member extends GroupMembershipRow,177190 MemberInit,178->(config: {191+> = {192+ /** The group table whose rows anchor membership and group-owned resources. */179193 groups: AccessRuntimeTable<Group, GroupInit>;194+ /** The group's membership table, declared with `groupMembershipTable` (or195+ * matching its column shape) and covered by a `groupAccess` policy template. */180196 members: AccessRuntimeTable<Member, MemberInit>;181- /** Wrapped-field-key and directory tables, for groups hosting shared182- * encrypted columns; the group lifecycle then mints, wraps, and rotates183- * field keys automatically. */197+ /** The group's wrapped-field-key table (declared with `sharedFieldKeyTable`),198+ * for groups hosting shared encrypted columns. Provide it together with199+ * `directory`; the operations then mint, wrap, and rotate field keys200+ * automatically. */184201 fieldKeys?: AccessRuntimeTable<Identified, unknown>;202+ /** The app's shared-field key directory table (declared with203+ * `sharedFieldDirectoryTable`), for groups hosting shared encrypted columns.204+ * Provide it together with `fieldKeys`. */185205 directory?: AccessRuntimeTable<Identified, unknown>;186-}): GroupOperations<Group, GroupInit, Member> {206+};207+208+/** Creates fixed-role group membership operations for a declared table pair. */209+export function createGroupOperations<210+ Group extends Identified,211+ GroupInit,212+ Member extends GroupMembershipRow,213+ MemberInit,214+>(215+ config: GroupOperationsConfig<Group, GroupInit, Member, MemberInit>,216+): GroupOperations<Group, GroupInit, Member> {187217 const findMember = async (groupId: string, userId: string): Promise<Member | null> => {188218 const { db } = await getRuntime();189219 return await db.one(config.members.where({ groupId, user_id: userId } as never));@@ -29,7 +29,7 @@ export type GroupAccessTemplate = {
2929 };3030 /** Shared-field key-directory policy template. */3131 export type SharedFieldAccessTemplate = {32- readonly kind: "shared-field-directory";32+ readonly kind: "shared-field";3333 readonly directory: AccessTable;3434 };3535 /** Any built-in access policy template accepted by {@link defineAccessPolicies}. */@@ -147,7 +147,7 @@ export function groupAccess(config: {
147147 * A template for { defineAccessPolicies}.148148 */149149 export function sharedFieldAccess(config: { directory: AccessTable }): SharedFieldAccessTemplate {150- return { kind: "shared-field-directory", ...config };150+ return { kind: "shared-field", ...config };151151 }152152 153153 type Column = {@@ -191,7 +191,7 @@ function validateTemplate(template: AccessTemplate): void {
191191 requireColumn(template.grants, "user_id", "Text");192192 requireColumn(template.grants, "can_edit", "Boolean");193193 }194- if (template.kind === "shared-field-directory") {194+ if (template.kind === "shared-field") {195195 requireColumn(template.directory, "user_id", "Text");196196 requireColumn(template.directory, "algo", "Text");197197 requireColumn(template.directory, "public_key", "Text");@@ -310,7 +310,7 @@ function guardPolicies(policy: Record<string, TablePolicy>): Record<string, Tabl
310310 }311311 312312 /**313- * Compiles the three narrow templates through Jazz's own policy builder. The313+ * Compiles the four narrow templates through Jazz's own policy builder. The314314 * optional callback is the raw Jazz-policy escape hatch for app-specific rules.315315 *316316 * @example@@ -330,8 +330,13 @@ function guardPolicies(policy: Record<string, TablePolicy>): Record<string, Tabl
330330 * ```331331 *332332 * @param app The raw Jazz app returned by `s.defineApp`.333- * @param templates At least one {@link privateAccess}, {@link sharedAccess}, or334- * {@link groupAccess} template; every table needs a policy.333+ * @param templates At least one {@link privateAccess}, {@link sharedAccess},334+ * {@link groupAccess}, or {@link sharedFieldAccess} template; every table needs335+ * a policy. `sharedFieldAccess` compiles the key directory behind shared336+ * encrypted columns; pair it with the `fieldKeys` option of337+ * {@link groupAccess}, and group operations created with the same tables run338+ * the key lifecycle (bootstrap on creation, wrap delivery, lazy rotation, and339+ * `reconcileSharedFieldKeys` repair).335340 * @param raw Optional raw-policy callback for app-specific rules beyond the templates.336341 * @returns Compiled permissions suitable as the app's default policy export.337342 */@@ -361,7 +366,7 @@ export function defineAccessPolicies<TApp extends object>(
361366 resource.allowDelete.where({ $createdBy: session.user_id });362367 continue;363368 }364- if (template.kind === "shared-field-directory") {369+ if (template.kind === "shared-field") {365370 const directory = policy[template.directory._table];366371 directory.allowRead.always();367372 directory.allowInsert.where({ user_id: session.user_id });@@ -1,9 +1,11 @@
11 import { schema as s } from "jazz-tools";22 import type { BooleanColumn, DefinedTable, IntColumn, RefColumn, StringColumn } from "jazz-tools";3+import { AccessError } from "./errors.ts";34 45 function requireTableName(name: string, helper: string): void {56 if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) {6- throw new TypeError(7+ throw new AccessError(8+ "configuration",79 `${helper} requires a declared Jazz table name, received ${JSON.stringify(name)}`,810 );911 }@@ -18,6 +18,7 @@ import {
1818 generateFieldKey,1919 publicKeyFingerprint,2020 SharedFieldError,21+ type SharedFieldErrorCode,2122 wrapFieldKey,2223 } from "../schema/shared-crypto.ts";2324 import {@@ -66,7 +67,7 @@ type MemberRow = { user_id: string };
6667 type DirectoryEntry = { user_id: string; algo: string; public_key: string };6768 6869 /** Why a member could not receive a wrap; the pending state persists. */69-export type WrapSkip = "no-directory-entry" | "peer-key-changed";70+export type WrapSkip = Extract<SharedFieldErrorCode, "no-directory-entry" | "peer-key-changed">;7071 7172 async function wrapTo(7273 context: SharedFieldLifecycleContext,@@ -144,10 +145,7 @@ export async function bootstrapGroupFieldKey(
144145 installSharedFieldKey(sharedKeyScope(context.groupTable, groupId), 1, fieldKey);145146 const skip = await wrapTo(context, groupId, 1, fieldKey, context.userId);146147 if (skip !== null) {147- throw new SharedFieldError(148- skip === "no-directory-entry" ? "no-directory-entry" : "peer-key-changed",149- "the group key could not be wrapped to its creator",150- );148+ throw new SharedFieldError(skip, "the group key could not be wrapped to its creator");151149 }152150 }153151 @@ -8,27 +8,30 @@
88 import { createDb, schema as jazz } from "jazz-tools";99 import { deploy, startLocalJazzServer } from "jazz-tools/testing";1010 import {11- bootstrapGroupFieldKey,1211 defineAccessPolicies,1312 groupAccess,1413 groupMembershipTable,1514 groupRoleCapabilities,16- reconcileSharedFieldKeys,17- rotateGroupFieldKey,1815 sharedFieldAccess,1916 sharedFieldDirectoryTable,2017 sharedFieldKeyTable,18+} from "./mod.ts";19+import {20+ bootstrapGroupFieldKey,21+ reconcileSharedFieldKeys,22+ rotateGroupFieldKey,2123 type SharedFieldLifecycleContext,2224 wrapHeldKeysForMember,23-} from "./mod.ts";25+} from "./shared-field-lifecycle.ts";2426 import { s } from "../schema/mod.ts";27+import { type SharedFieldValue } from "../schema/mod.ts";28+import { clearSharedColumnRegistry } from "../schema/shared-registry.ts";29+import { clearEncryptedColumnRegistry } from "../schema/encrypted.ts";2530 import {26- clearSharedColumnRegistry,31+ clearSharedFieldIdentity,2732 clearSharedFieldKeys,28- type SharedFieldValue,29-} from "../schema/mod.ts";30-import { clearEncryptedColumnRegistry } from "../schema/encrypted.ts";31-import { clearSharedFieldIdentity, installSharedFieldIdentity } from "../schema/shared-keyring.ts";33+ installSharedFieldIdentity,34+} from "../schema/shared-keyring.ts";3235 import { sealSharedColumnValuesSync } from "../runtime/shared-field-write.ts";3336 import {3437 clearFingerprintPins,@@ -16,15 +16,15 @@ import {
1616 sharedFieldKeyTable,1717 } from "./mod.ts";1818 import { s } from "../schema/mod.ts";19-import {20- clearSharedColumnRegistry,21- clearSharedFieldKeys,22- sharedFieldReady,23- type SharedFieldValue,24-} from "../schema/mod.ts";19+import { sharedFieldReady, type SharedFieldValue } from "../schema/mod.ts";20+import { clearSharedColumnRegistry } from "../schema/shared-registry.ts";2521 import { generateFieldKey, sealSharedValue, wrapFieldKey } from "../schema/shared-crypto.ts";2622 import { clearEncryptedColumnRegistry } from "../schema/encrypted.ts";27-import { clearSharedFieldIdentity, installSharedFieldIdentity } from "../schema/shared-keyring.ts";23+import {24+ clearSharedFieldIdentity,25+ clearSharedFieldKeys,26+ installSharedFieldIdentity,27+} from "../schema/shared-keyring.ts";2828 import {2929 clearFingerprintPins,3030 deriveSharedFieldIdentity,@@ -0,0 +1,106 @@
1+/**2+ * Static manifests of the package modules vendored into a project's `.lofi/`3+ * directory. The lists are static because the published package cannot4+ * enumerate directories over JSR; the manifest test fails when a list and its5+ * package directory drift apart.6+ *7+ * @module8+ */9+10+/** Runtime modules vendored into `.lofi/`; kept in lockstep by the manifest test. */11+export const runtimeFiles = [12+ "app.ts",13+ "auth.ts",14+ "boot-progress.ts",15+ "boot.ts",16+ "config.ts",17+ "data-sink.ts",18+ "device-capabilities.ts",19+ "diagnostics.ts",20+ "durability.ts",21+ "env.d.ts",22+ "envelope.ts",23+ "foreground-recovery.ts",24+ "inspector.ts",25+ "lifecycle.ts",26+ "live-query-store.ts",27+ "mod.ts",28+ "mutation-taxonomy.ts",29+ "namespace-state.ts",30+ "passkey-recovery.ts",31+ "pop.ts",32+ "probe.ts",33+ "provision.ts",34+ "pwa.ts",35+ "recovery.ts",36+ "resource-lifecycle.ts",37+ "runtime.ts",38+ "schema-compat.ts",39+ "session.ts",40+ "shared-field-keys.ts",41+ "shared-field-write.ts",42+ "startup-recovery.ts",43+ "storage-fork.ts",44+ "store-status.ts",45+ "table-mutations.ts",46+ "table-store.ts",47+ "transport-gate.ts",48+ "ui-mutation.ts",49+ "upgrade-coordination.ts",50+ "write-handle.ts",51+ "write-journal.ts",52+ "write-ledger.ts",53+] as const;54+55+/** Access modules vendored into `.lofi/`; kept in lockstep by the manifest test. */56+export const accessFiles = [57+ "errors.ts",58+ "identity.ts",59+ "mod.ts",60+ "operations.ts",61+ "policies.ts",62+ "schema.ts",63+ "shared-field-lifecycle.ts",64+] as const;65+66+/** Schema facade modules vendored into `.lofi/`; kept in lockstep by the manifest test. */67+export const schemaFiles = [68+ "compat.ts",69+ "effects.ts",70+ "encrypted.ts",71+ "mod.ts",72+ "nested.ts",73+ "padding.ts",74+ "private-table.ts",75+ "shared-crypto.ts",76+ "shared-encrypted.ts",77+ "shared-keyring.ts",78+ "shared-registry.ts",79+ "store.ts",80+] as const;81+82+/** Preact modules vendored into `.lofi/`; kept in lockstep by the manifest test. */83+export const preactFiles = [84+ "DeviceStatus.tsx",85+ "live-data.ts",86+ "mod.ts",87+ "PwaActions.tsx",88+ "RuntimeRecovery.tsx",89+ "TicketEnrollForm.tsx",90+ "use-boot-progress.ts",91+ "use-device-capabilities.ts",92+ "use-schema-compat.ts",93+ "use-storage-fork.ts",94+ "write-hooks.ts",95+] as const;96+97+/** Recipe modules vendored into `.lofi/`; kept in lockstep by the manifest test. */98+export const recipeFiles = [99+ "file-handler.ts",100+ "launch-handler.ts",101+ "protocol-handler.ts",102+ "related-app-discovery.ts",103+ "scope-extension.ts",104+ "web-share.ts",105+ "window-controls-overlay.ts",106+] as const;@@ -4,125 +4,45 @@
44 * { prepareLofiAstroConfig} materializes version-matched runtime aliases55 * and Jazz configuration into the ignored `.lofi/` directory.66 *7+ * The generated configuration reads these environment variables at Astro8+ * config-load time:9+ *10+ * - `LOFI_BASE_PATH` — deployment base path for the built site (default `/`).11+ * - `LOFI_CSP` — set to `off` to disable the Content-Security-Policy meta tags12+ * Astro emits per page (on by default).13+ * - `LOFI_CSP_SCRIPT_SRC` — space-separated sources appended to the14+ * `script-src` directive.15+ * - `LOFI_CSP_STYLE_SRC` — space-separated sources appended to the16+ * `style-src` directive.17+ * - `LOFI_CSP_CONNECT_SRC` — sources for a `connect-src` directive; absent by18+ * default because the sync location is user data enrolled at runtime.19+ * - `LOFI_CSP_DIRECTIVES` — semicolon-separated additional CSP directives.20+ * - `JAZZ_APP_ID` — cloud-mode Jazz application id; requires21+ * `JAZZ_SERVER_URL`.22+ * - `JAZZ_SERVER_URL` — cloud-mode Jazz sync server URL; requires23+ * `JAZZ_APP_ID`.24+ * - `LOFI_SKIP_JAZZ_MANAGED` — set to `1` to skip starting the managed local25+ * Jazz server during development.26+ *727 * 828 */929 1030 import { join, resolve } from "node:path";31+import { accessFiles, preactFiles, recipeFiles, runtimeFiles, schemaFiles } from "./manifest.ts";1132 1233 /** Options for materializing the package-owned Astro configuration. */1334 export type LofiAstroOptions = {1435 /** Project root. Defaults to the directory from which Astro was invoked. */1536 root?: string;1637 /**17- * Reserved. The generated integration always reads the Jazz schema and18- * permissions from `src`; any other value throws.38+ * The directory the generated integration reads the Jazz schema and39+ * permissions from. `src` is the contract: the type admits nothing else,40+ * and the runtime check rejects any other value.1941 * @default "src"2042 */21- schemaDir?: string;43+ schemaDir?: "src";2244 };2345 24-/**25- * Runtime modules vendored into a project's `.lofi/` directory. Static because26- * the published package cannot list directories over JSR; the manifest test27- * fails when this list and `package/runtime/` drift apart.28- */29-export const runtimeFiles = [30- "app.ts",31- "auth.ts",32- "boot-progress.ts",33- "boot.ts",34- "config.ts",35- "data-sink.ts",36- "device-capabilities.ts",37- "diagnostics.ts",38- "durability.ts",39- "env.d.ts",40- "envelope.ts",41- "foreground-recovery.ts",42- "inspector.ts",43- "lifecycle.ts",44- "live-query-store.ts",45- "mod.ts",46- "mutation-taxonomy.ts",47- "namespace-state.ts",48- "passkey-recovery.ts",49- "pop.ts",50- "probe.ts",51- "provision.ts",52- "pwa.ts",53- "recovery.ts",54- "resource-lifecycle.ts",55- "runtime.ts",56- "schema-compat.ts",57- "session.ts",58- "shared-field-keys.ts",59- "shared-field-write.ts",60- "startup-recovery.ts",61- "storage-fork.ts",62- "store-status.ts",63- "table-mutations.ts",64- "table-store.ts",65- "transport-gate.ts",66- "ui-mutation.ts",67- "upgrade-coordination.ts",68- "write-handle.ts",69- "write-journal.ts",70- "write-ledger.ts",71-] as const;72-73-/** Access modules vendored into `.lofi/`; kept in lockstep by the manifest test. */74-export const accessFiles = [75- "errors.ts",76- "identity.ts",77- "mod.ts",78- "operations.ts",79- "policies.ts",80- "schema.ts",81- "shared-field-lifecycle.ts",82-] as const;83-84-/** Schema facade modules vendored into `.lofi/`; kept in lockstep by the manifest test. */85-export const schemaFiles = [86- "compat.ts",87- "effects.ts",88- "encrypted.ts",89- "mod.ts",90- "nested.ts",91- "padding.ts",92- "private-table.ts",93- "shared-crypto.ts",94- "shared-encrypted.ts",95- "shared-keyring.ts",96- "shared-registry.ts",97- "store.ts",98-] as const;99-100-/** Preact modules vendored into `.lofi/`; kept in lockstep by the manifest test. */101-export const preactFiles = [102- "DeviceStatus.tsx",103- "live-data.ts",104- "mod.ts",105- "PwaActions.tsx",106- "RuntimeRecovery.tsx",107- "TicketEnrollForm.tsx",108- "use-boot-progress.ts",109- "use-device-capabilities.ts",110- "use-schema-compat.ts",111- "use-storage-fork.ts",112- "write-hooks.ts",113-] as const;114-115-/** Recipe modules vendored into `.lofi/`; kept in lockstep by the manifest test. */116-export const recipeFiles = [117- "file-handler.ts",118- "launch-handler.ts",119- "protocol-handler.ts",120- "related-app-discovery.ts",121- "scope-extension.ts",122- "web-share.ts",123- "window-controls-overlay.ts",124-] as const;125-12646 async function readPackageFile(path: string): Promise<string> {12747 const url = new URL(path, import.meta.url);12848 if (url.protocol === "file:") return await Deno.readTextFile(url);@@ -331,10 +251,18 @@ export default defineConfig({
331251 /**332252 * Materializes the package-owned Astro/Jazz integration into an ignored tooling333253 * directory so Astro's Node-compatible config loader can consume it.254+ *255+ * Vendors the runtime, access, preact, recipe, and schema modules into256+ * `.lofi/package/` and writes `.lofi/astro.config.ts`, rewriting the config257+ * only when its content changes.258+ *259+ * @param options The project root and schema directory; both optional.260+ * @returns The absolute path of the generated `.lofi/astro.config.ts`.334261 */335262 export async function prepareLofiAstroConfig(options: LofiAstroOptions = {}): Promise<string> {336263 const projectRoot = resolve(options.root ?? Deno.cwd());337- if (options.schemaDir && options.schemaDir !== "src") {264+ // Type-level contract; kept at runtime against unchecked JavaScript callers.265+ if (options.schemaDir !== undefined && options.schemaDir !== "src") {338266 throw new Error("custom schemaDir is not supported by the generated lofi Astro integration");339267 }340268 const toolingDir = join(projectRoot, ".lofi");@@ -1,10 +1,9 @@
11 // Repo contract test: the static `.lofi/` vendoring manifests must track the22 // actual package directories. The lists are static because the published33 // package cannot enumerate directories over JSR — this test is what fails4-// when a new runtime module lands without being added to the manifest5-// (namespace-state.ts and transport-gate.ts shipped exactly that way and6-// broke every generated project's build until vendoring caught up).7-import { accessFiles, preactFiles, recipeFiles, runtimeFiles, schemaFiles } from "./mod.ts";4+// when a package module lands without being added to the manifest, which5+// would otherwise surface only as a broken build in generated projects.6+import { accessFiles, preactFiles, recipeFiles, runtimeFiles, schemaFiles } from "./manifest.ts";87 98 function assert(condition: unknown, message: string): asserts condition {109 if (!condition) throw new Error(message);@@ -30,6 +30,7 @@ import {
3030 screenshotAssetPaths,3131 } from "../tooling/pwa-validation.ts";3232 import { generateSchemaCompatManifest } from "../tooling/schema-manifest.ts";33+import type { LofiBuildInfo } from "../tooling/build-info.ts";3334 import {3435 cspPolicyWarnings,3536 mergeCspPolicies,@@ -74,17 +75,13 @@ exitOnFailure(
7475 "production build",7576 );7677 77-await Deno.writeTextFile(78- "dist/lofi-build.json",79- `${80- JSON.stringify({81- lofiVersion: LOFI_VERSION,82- sourceHash,83- basePath: environment.LOFI_BASE_PATH,84- builtAt: new Date().toISOString(),85- })86- }\n`,87-);78+const buildInfo: LofiBuildInfo = {79+ lofiVersion: LOFI_VERSION,80+ sourceHash,81+ basePath: environment.LOFI_BASE_PATH,82+ builtAt: new Date().toISOString(),83+};84+await Deno.writeTextFile("dist/lofi-build.json", `${JSON.stringify(buildInfo)}\n`);8885 const serviceWorkerPath = "dist/sw.js";8986 const serviceWorker = await readPackageAsset("../runtime/sw.js");9087 await Deno.writeTextFile(@@ -139,7 +136,6 @@ for (const path of distFiles.filter((file) => extname(file) === ".html")) {
139136 // host-header snippet. Reported, never gated.140137 const cspUnion = cspPolicies.length > 0 ? mergeCspPolicies(cspPolicies) : null;141138 if (cspUnion !== null) {142- const buildInfo = JSON.parse(await Deno.readTextFile("dist/lofi-build.json"));143139 buildInfo.csp = cspUnion;144140 await Deno.writeTextFile("dist/lofi-build.json", `${JSON.stringify(buildInfo)}\n`);145141 await Deno.writeTextFile("dist/_headers.example", renderHeadersSnippet(cspUnion));@@ -9,16 +9,21 @@
99 1010 import { join, normalize } from "node:path";1111 import { normalizeDeploymentBase, pathWithinDeploymentBase } from "../tooling/base-path.ts";12+import type { LofiBuildInfo } from "../tooling/build-info.ts";1213 import { productionContentType } from "../tooling/pwa-validation.ts";1314 15+// Preview serves the already-built static dist/ and reads no environment, so16+// the command environment preflight does not apply here.17+1418 const portFlag = Deno.args.findIndex((argument) => argument === "--port");1519 const port = portFlag >= 0 ? Number(Deno.args[portFlag + 1]) : 4321;1620 if (!Number.isInteger(port) || port < 1 || port > 65_535) {1721 console.error("error: preview --port must be an integer from 1 to 65535");22+ console.error("usage: deno task preview [--port <1-65535>]");1823 Deno.exit(2);1924 }2025 21-let identity: { lofiVersion?: string; sourceHash?: string; basePath?: string; csp?: string };26+let identity: Partial<LofiBuildInfo>;2227 try {2328 identity = JSON.parse(await Deno.readTextFile("dist/lofi-build.json"));2429 } catch {@@ -1,6 +1,20 @@
1+/**2+ * Helpers shared by the lofi command entrypoints: the doctor-gated command3+ * environment and a fail-fast exit for child-process results.4+ *5+ * @module6+ */7+18 import { doctorReport, printDoctorReport } from "../tooling/diagnostics.ts";29 import { childEnvironment } from "../tooling/environment.ts";310 11+/**12+ * Run the doctor preflight and return the validated environment for child13+ * processes. Prints the doctor report and exits the process when a blocker is14+ * found; warnings are printed and the command proceeds.15+ *16+ * @returns The environment variables the command's child processes receive.17+ */418 export async function validatedCommandEnvironment(): Promise<Record<string, string>> {519 const report = await doctorReport();620 if (report.blocked || !report.validation.ok) {@@ -14,6 +28,13 @@ export async function validatedCommandEnvironment(): Promise<Record<string, stri
1428 return childEnvironment(report.validation);1529 }1630 31+/**32+ * Return when `exitCode` is zero; otherwise point at the failed action and33+ * exit the process with the same code.34+ *35+ * @param exitCode The child process's exit code.36+ * @param action The name of the step, used in the failure message.37+ */1738 export function exitOnFailure(exitCode: number, action: string): void {1839 if (exitCode === 0) return;1940 console.error(`error: ${action} failed; fix the first error above and rerun the same Deno task`);@@ -1,4 +1,4 @@
1-import type { JSX, VNode } from "preact";1+import type { VNode } from "preact";22 import { useEffect, useState } from "preact/hooks";33 // Package-owned optional diagnostics UI.44 import { useDeviceCapabilities } from "./use-device-capabilities.ts";@@ -19,7 +19,7 @@ import { describeStoreStatus } from "../runtime/store-status.ts";
1919 import { RuntimeRecovery } from "./RuntimeRecovery.tsx";2020 2121 // One label/value row inside a category's definition list.22-function Row({ label, value }: { label: string; value: string }): JSX.Element {22+function Row({ label, value }: { label: string; value: string }): VNode {2323 return (2424 <div>2525 <dt>{label}</dt>@@ -49,7 +49,7 @@ const available = (present: boolean) => (present ? "available" : "missing");
4949 *5050 * The device diagnostics panel, grouped by owning subsystem.5151 */52-export default function DeviceStatus(): VNode {52+export function DeviceStatus(): VNode {5353 const { report, requestPersistence } = useDeviceCapabilities();5454 const fork = useStorageFork();5555 const [pwa, setPwa] = useState<PwaState>(getPwaState());@@ -9,7 +9,14 @@ import {
99 import { type StorageForkSurface, useStorageFork } from "./use-storage-fork.ts";1010 1111 export { pwaFailureMessage } from "../runtime/pwa.ts";12-export type { PwaController, PwaFailureCode, PwaState, PwaUpdateState } from "../runtime/pwa.ts";12+export type {13+ PwaController,14+ PwaFailureCode,15+ PwaInstallState,16+ PwaState,17+ PwaUpdateState,18+ PwaWorkerState,19+} from "../runtime/pwa.ts";1320 1421 /**1522 * Subscribes a Preact component to an isolated or shared PWA controller.@@ -1,14 +1,16 @@
1-import type { JSX } from "preact";1+import type { VNode } from "preact";22 import {33 reloadAfterRuntimeStartupFailure,44 type RuntimeStartupFailure,55 } from "../runtime/startup-recovery.ts";66 77 /** Inputs for Lofi's explicit persistent-runtime recovery action. */8-export type RuntimeRecoveryProps = {9- failure: RuntimeStartupFailure | null;10- reload?: () => void;11-};8+export interface RuntimeRecoveryProps {9+ /** The startup failure to inspect, or `null` when the runtime opened. */10+ readonly failure: RuntimeStartupFailure | null;11+ /** Reload implementation; defaults to a full page reload. */12+ readonly reload?: () => void;13+}1214 1315 /**1416 * Renders recovery only when another tab is running an incompatible broker version.@@ -23,7 +25,7 @@ export type RuntimeRecoveryProps = {
2325 * props The startup failure to inspect and an optional reload override.2426 * The recovery prompt, or `null` when no incompatible-broker failure is present.2527 */26-export function RuntimeRecovery({ failure, reload }: RuntimeRecoveryProps): JSX.Element | null {28+export function RuntimeRecovery({ failure, reload }: RuntimeRecoveryProps): VNode | null {2729 if (failure?.code !== "broker-incompatible") return null;2830 return (2931 <div class="runtime-recovery" role="alert">@@ -2,7 +2,11 @@ import type { VNode } from "preact";
22 import { useState } from "preact/hooks";33 import { AuthError } from "../runtime/auth.ts";44 import { isDataSinkError, parseSyncTicket } from "../runtime/data-sink.ts";5-import { provisionCapabilityStatus, sealProvisionCapability } from "../runtime/provision.ts";5+import {6+ provisionCapabilityStatus,7+ type SealOutcome,8+ sealProvisionCapability,9+} from "../runtime/provision.ts";610 import { enrollSyncTicket, type Session } from "../runtime/session.ts";711 812 /** Dependencies {@link TicketEnrollForm} accepts for testing and composition. */@@ -14,7 +18,7 @@ export interface TicketEnrollFormProps {
1418 /** Enrollment implementation; defaults to the runtime's `enrollSyncTicket`. */1519 readonly enroll?: (ticket: string) => Promise<Session>;1620 /** Sealing implementation; defaults to the runtime's `sealProvisionCapability`. */17- readonly seal?: () => Promise<{ portable: boolean }>;21+ readonly seal?: () => Promise<SealOutcome>;1822 }1923 2024 type Phase =@@ -2,8 +2,9 @@
22 * Optional Preact bindings for the lofi runtime: live typed queries33 * ({ useLiveQuery}), table mutations ({ useTableMutations}), the44 * per-write sync lifecycle ({ useWrite}, { usePendingWrites},5- * { useSyncStatus}), first-load progress ({ useBootProgress}), and6- * the schema-compatibility gate5+ * { useSyncStatus}), first-load progress ({ useBootProgress}),6+ * PWA install/update state ({ usePwaState}), device capabilities7+ * ({ useDeviceCapabilities}), the schema-compatibility gate78 * ({ useSchemaCompat}), and the storage-container fork guard89 * ({ useStorageFork}), plus package-owned example components910 * ({ DeviceStatus}, { PwaActions}, { RuntimeRecovery},@@ -12,7 +13,7 @@
1213 *1314 * 1415 */15-export { default as DeviceStatus } from "./DeviceStatus.tsx";16+export { DeviceStatus } from "./DeviceStatus.tsx";1617 export { RuntimeRecovery, type RuntimeRecoveryProps } from "./RuntimeRecovery.tsx";1718 export {1819 type LiveQuerySnapshot,@@ -21,17 +22,21 @@ export {
2122 useLiveQuery,2223 useTableMutations,2324 } from "./live-data.ts";25+export type { TableRow } from "../runtime/table-store.ts";2426 export {2527 PwaActions,2628 type PwaActionsProps,2729 type PwaController,2830 type PwaFailureCode,2931 pwaFailureMessage,32+ type PwaInstallState,3033 type PwaState,3134 type PwaUpdateState,35+ type PwaWorkerState,3236 usePwaState,3337 } from "./PwaActions.tsx";3438 export { TicketEnrollForm, type TicketEnrollFormProps } from "./TicketEnrollForm.tsx";39+export type { SealOutcome } from "../runtime/provision.ts";3540 export { usePendingWrites, useSyncStatus, useWrite, type WriteState } from "./write-hooks.ts";3641 export type { WriteHandle, WriteRejection, WriteStage } from "../runtime/write-handle.ts";3742 export type {@@ -49,9 +54,15 @@ export {
4954 type StorageForkSurface,5055 useStorageFork,5156 } from "./use-storage-fork.ts";57+export type { StorageForkGuard } from "../runtime/storage-fork.ts";5258 export { type BootProgress, type BootProgressPhase, useBootProgress } from "./use-boot-progress.ts";5359 export {54- type DeviceCapabilitiesHook,60+ type DeviceCapabilitiesState,5561 type DeviceCapabilityReport,5662 useDeviceCapabilities,5763 } from "./use-device-capabilities.ts";64+export type { Session, SessionSink } from "../runtime/session.ts";65+export type {66+ RuntimeStartupFailure,67+ RuntimeStartupFailureCode,68+} from "../runtime/startup-recovery.ts";@@ -8,13 +8,29 @@ import {
88 export type { DeviceCapabilityReport } from "../runtime/device-capabilities.ts";99 1010 /** State returned by {@link useDeviceCapabilities}. */11-export type DeviceCapabilitiesHook = {11+export type DeviceCapabilitiesState = {12+ /** The capability report, or `null` while the initial read is in flight. */1213 report: DeviceCapabilityReport | null;14+ /** Requests eviction protection from the browser, then refreshes the report. */1315 requestPersistence(): Promise<void>;1416 };1517 16-/** Reads browser capabilities and exposes an explicit persistence request. */17-export function useDeviceCapabilities(): DeviceCapabilitiesHook {18+/**19+ * Reads the browser's device capabilities once on mount and exposes an20+ * explicit persistence request that refreshes the report with the browser's21+ * verdict.22+ *23+ * @example24+ * ```tsx25+ * import { useDeviceCapabilities } from "@nzip/lofi/preact";26+ *27+ * const { report, requestPersistence } = useDeviceCapabilities();28+ * if (!report) return <p>Checking device capabilities…</p>;29+ * ```30+ *31+ * @returns The capability report and the persistence request action.32+ */33+export function useDeviceCapabilities(): DeviceCapabilitiesState {1834 const [report, setReport] = useState<DeviceCapabilityReport | null>(null);1935 useEffect(() => {2036 let active = true;@@ -6,11 +6,13 @@
66 */77 88 import {9+ type InstalledAppLaunchConsumer,910 type InstalledAppLaunchQueue,10- type InstalledLaunchConsumer,1111 installInstalledAppLaunchQueueConsumer,1212 } from "./launch-handler.ts";1313 14+export type { InstalledAppLaunchConsumer, InstalledAppLaunchQueue } from "./launch-handler.ts";15+1416 /** A minimal read-only file handle delivered by an installed-app launch. */1517 export type InstalledAppFileHandle = {1618 readonly kind: "file";@@ -198,7 +200,7 @@ export async function prepareFileImportDrafts<Parsed>(
198200 /** Feature-detect `launchQueue` and turn file launches into validated drafts. */199201 export function installFileLaunchConsumer<Parsed>(200202 options: InstallFileLaunchConsumerOptions<Parsed>,201-): InstalledLaunchConsumer {203+): InstalledAppLaunchConsumer {202204 validateOptions(options);203205 let generation = 0;204206 const consumer = installInstalledAppLaunchQueueConsumer({@@ -17,7 +17,7 @@ export type InstalledAppLaunchQueue = {
1717 1818 /** A parsed launch URL proven to stay inside the configured application scope. */1919 export type InstalledAppLaunchTarget = {20- url: string;20+ readonly url: string;2121 };2222 2323 /** Stable rejection that never contains the received launch URL. */@@ -34,7 +34,7 @@ export type InstalledAppLaunchResult =
3434 | { ok: false; issue: InstalledAppLaunchIssue };3535 3636 /** Options for registering one launch-queue consumer. */37-export type InstallLaunchConsumerOptions = {37+export type InstalledAppLaunchConsumerOptions = {3838 /** Absolute application scope URL, normally derived from `import.meta.env.BASE_URL`. */3939 scope: string | URL;4040 /** Override the browser queue for tests. */@@ -46,13 +46,13 @@ export type InstallLaunchConsumerOptions = {
4646 };4747 4848 /** Result of feature-detecting and registering the launch consumer. */49-export type InstalledLaunchConsumer = {50- supported: boolean;49+export type InstalledAppLaunchConsumer = {50+ readonly supported: boolean;5151 dispose(): void;5252 };5353 5454 /** Options for registering one raw browser launch-queue consumer. */55-export type InstallLaunchQueueConsumerOptions = {55+export type InstalledAppLaunchQueueConsumerOptions = {5656 /** Override the browser queue for tests. */5757 queue?: InstalledAppLaunchQueue;5858 /** Receives browser launch parameters without trusting or interpreting them. */@@ -108,8 +108,8 @@ export function parseInstalledAppLaunchTarget(
108108 * launches.109109 */110110 export function installInstalledAppLaunchConsumer(111- options: InstallLaunchConsumerOptions,112-): InstalledLaunchConsumer {111+ options: InstalledAppLaunchConsumerOptions,112+): InstalledAppLaunchConsumer {113113 const scope = configuredScope(options.scope);114114 return installInstalledAppLaunchQueueConsumer({115115 queue: options.queue,@@ -129,8 +129,8 @@ export function installInstalledAppLaunchConsumer(
129129 * launch recipes. Treat every delivered parameter as untrusted input.130130 */131131 export function installInstalledAppLaunchQueueConsumer(132- options: InstallLaunchQueueConsumerOptions,133-): InstalledLaunchConsumer {132+ options: InstalledAppLaunchQueueConsumerOptions,133+): InstalledAppLaunchConsumer {134134 const queue = options.queue ?? browserLaunchQueue();135135 if (!queue) return { supported: false, dispose() {} };136136 const owner = Symbol("lofi-launch-consumer");@@ -4,9 +4,22 @@
44 * 55 */66 7+const platformList = [8+ "amazon",9+ "chrome_web_store",10+ "chromeos_play",11+ "f-droid",12+ "play",13+ "webapp",14+ "windows",15+] as const;16+17+/** Store platform identifier accepted in a `related_applications` declaration. */18+export type RelatedApplicationPlatform = (typeof platformList)[number];19+720 /** A product-owned related application declaration. */821 export type VerifiedRelatedApplication = {9- readonly platform: string;22+ readonly platform: RelatedApplicationPlatform;1023 readonly id?: string;1124 readonly url?: string;1225 };@@ -16,9 +29,19 @@ export type RelatedApplicationClient = {
1629 getInstalledRelatedApps(): Promise<readonly unknown[]>;1730 };1831 32+/**33+ * Presentation-only discovery outcome. `installed` is the only status that34+ * carries matches; `unsupported`, `none`, and `failed` are equally normal35+ * states that keep the ordinary PWA experience.36+ */37+export type RelatedApplicationDiscoveryStatus = "unsupported" | "none" | "failed" | "installed";38+1939 /** Presentation-only discovery result. */2040 export type RelatedApplicationDiscovery =21- | { status: "unsupported" | "none" | "failed"; installed: readonly [] }41+ | {42+ status: Exclude<RelatedApplicationDiscoveryStatus, "installed">;43+ installed: readonly [];44+ }2245 | { status: "installed"; installed: readonly VerifiedRelatedApplication[] };2346 2447 /** Options for matching browser results against product-owned declarations. */@@ -33,15 +56,7 @@ export type DiscoverRelatedApplicationsOptions = {
3356 * The one shared `related_applications` grammar: manifest validation imports3457 * these so the validator can never accept a declaration this recipe rejects.3558 */36-export const relatedApplicationPlatforms: ReadonlySet<string> = new Set([37- "amazon",38- "chrome_web_store",39- "chromeos_play",40- "f-droid",41- "play",42- "webapp",43- "windows",44-]);59+export const relatedApplicationPlatforms: ReadonlySet<string> = new Set(platformList);4560 4661 /** Bounded, trimmed, control-character-free manifest string field. */4762 export function isBoundedManifestString(value: unknown): value is string {@@ -1,4 +1,7 @@
1-import { discoverRelatedApplications } from "./related-app-discovery.ts";1+import {2+ discoverRelatedApplications,3+ type VerifiedRelatedApplication,4+} from "./related-app-discovery.ts";25 36 function assert(condition: unknown, message: string): asserts condition {47 if (!condition) throw new Error(message);@@ -49,7 +52,7 @@ Deno.test("related app discovery rejects invented declarations", async () => {
4952 { platform: "invented", id: "app" },5053 { platform: "play" },5154 { platform: "play", id: "app", url: "http://store.example/app" },52- ]55+ ] as readonly VerifiedRelatedApplication[]5356 ) {5457 let rejected = false;5558 try {@@ -10,10 +10,10 @@
1010 1111 /** Data accepted by the browser Web Share API. */1212 export type WebShareData = {13- title?: string;14- text?: string;15- url?: string;16- files?: readonly File[];13+ readonly title?: string;14+ readonly text?: string;15+ readonly url?: string;16+ readonly files?: readonly File[];1717 };1818 1919 /** Minimal browser seam used by {@link shareOrFallback}. */@@ -103,9 +103,9 @@ export type TextShareTargetNames = {
103103 104104 /** Parsed text/link values awaiting explicit user confirmation. */105105 export type TextShareTargetDraft = {106- title?: string;107- text?: string;108- url?: string;106+ readonly title?: string;107+ readonly text?: string;108+ readonly url?: string;109109 };110110 111111 /** Stable validation issue that never contains received share values. */@@ -120,9 +120,16 @@ export type TextShareTargetIssue =
120120 | "unsupported-url-protocol"121121 | "empty-share";122122 123-/** Accepted draft or value-free rejection from an inbound share target. */123+/**124+ * Accepted draft or value-free rejection from an inbound share target.125+ *126+ * Unlike the singular-`issue` results of the other recipes, the rejection arm127+ * carries a plural `issues` list: the title, text, and URL fields are validated128+ * independently, and every field that fails is reported together so a receiver129+ * page can explain the whole rejection at once.130+ */124131 export type TextShareTargetResult =125- | { ok: true; draft: TextShareTargetDraft; issues: readonly [] }132+ | { ok: true; draft: TextShareTargetDraft }126133 | { ok: false; issues: readonly TextShareTargetIssue[] };127134 128135 /** Optional manifest-name and input-limit overrides for the inbound parser. */@@ -152,7 +159,6 @@ export function parseTextShareTarget(
152159 const names = { ...defaultNames, ...options.names };153160 const limits = { ...TEXT_SHARE_LIMITS, ...options.limits };154161 const issues: TextShareTargetIssue[] = [];155- const draft: TextShareTargetDraft = {};156162 157163 const read = (field: keyof TextShareTargetNames): string | undefined => {158164 const values = parameters.getAll(names[field]);@@ -169,24 +175,25 @@ export function parseTextShareTarget(
169175 return value;170176 };171177 172- draft.title = read("title");173- draft.text = read("text");178+ const title = read("title");179+ const text = read("text");174180 const rawUrl = read("url");181+ let url: string | undefined;175182 if (rawUrl) {176183 try {177184 const parsed = new URL(rawUrl);178185 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {179186 issues.push("unsupported-url-protocol");180187 } else {181- draft.url = parsed.href;188+ url = parsed.href;182189 }183190 } catch {184191 issues.push("invalid-url");185192 }186193 }187- if (!draft.title && !draft.text && !draft.url && issues.length === 0) {194+ if (!title && !text && !url && issues.length === 0) {188195 issues.push("empty-share");189196 }190197 if (issues.length > 0) return { ok: false, issues };191- return { ok: true, draft, issues: [] };198+ return { ok: true, draft: { title, text, url } };192199 }@@ -4,15 +4,22 @@
44 * 55 */66 7+/** Value-only titlebar area rectangle in CSS pixels, clamped to finite non-negative numbers. */8+export type TitlebarAreaRect = {9+ /** Left edge of the usable titlebar area, relative to the viewport. */10+ readonly x: number;11+ /** Top edge of the usable titlebar area, relative to the viewport. */12+ readonly y: number;13+ /** Width available to application content between window controls. */14+ readonly width: number;15+ /** Height of the titlebar area. */16+ readonly height: number;17+};18+719 /** A value-only copy of the titlebar area available to application content. */820 export type WindowControlsOverlayGeometry = {921 readonly visible: boolean;10- readonly titlebarArea: {11- readonly x: number;12- readonly y: number;13- readonly width: number;14- readonly height: number;15- };22+ readonly titlebarArea: TitlebarAreaRect;1623 };1724 1825 /** Minimal browser surface consumed by this recipe. */@@ -84,6 +84,11 @@ export class LofiConfigurationError extends Error {
8484 readonly code = "configuration-error";8585 }8686 87+/** True when an error is invalid or missing author-owned app configuration. */88+export function isLofiConfigurationError(error: unknown): error is LofiConfigurationError {89+ return error instanceof LofiConfigurationError;90+}91+8792 /**8893 * Defines the application-facing values the versioned runtime needs.8994 *@@ -263,7 +263,12 @@ function requireStableRpId(dependencies: AuthDependencies): string {
263263 264264 // --- Capability ---------------------------------------------------------------265265 266-async function readPrfSupport(webAuthn: boolean): Promise<PrfSupport> {266+/**267+ * Reads whether the WebAuthn PRF extension can be used on this client:268+ * `unavailable` without WebAuthn, `unknown` when the client cannot report269+ * capabilities, otherwise the client's own answer.270+ */271+export async function readPrfSupport(webAuthn: boolean): Promise<PrfSupport> {267272 if (!webAuthn) return "unavailable";268273 const publicKeyCredential = PublicKeyCredential as ClientCapabilities;269274 if (typeof publicKeyCredential.getClientCapabilities !== "function") return "unknown";@@ -76,7 +76,13 @@ function declaredEngineAsset(): EngineAssetReference | null {
7676 return { url: link.href, bytes: Number.isFinite(bytes) && bytes > 0 ? bytes : null };7777 }7878 79-/** Creates an isolated tracker, primarily for tests; the runtime shares one. */79+/**80+ * Creates an isolated progress tracker over injectable surfaces. The81+ * package-wide wrappers ({@link getBootProgress},82+ * {@link subscribeBootProgress}) read the shared tracker the runtime drives;83+ * this factory exists for isolated instances — deterministic tests and custom84+ * hosts that manage their own tracker.85+ */8086 export function createBootProgressTracker(8187 dependencies: BootProgressTrackerDependencies = {},8288 ): BootProgressTracker {@@ -1,3 +1,5 @@
1+import { type PrfSupport, readPrfSupport } from "./auth.ts";2+13 /** Browser capabilities that determine whether lofi can provide its runtime guarantees. */24 export type DeviceCapabilityReport = {35 // Package-owned device capability checks.@@ -8,7 +10,7 @@ export type DeviceCapabilityReport = {
810 messageChannel: boolean;911 durableDriverSupported: boolean;1012 webAuthn: boolean;11- prf: "available" | "not-reported" | "unknown" | "unavailable";13+ prf: PrfSupport;1214 persistentPermission: "granted" | "not-granted" | "unavailable" | "error";1315 displayMode: "standalone" | "browser";1416 };@@ -26,24 +28,6 @@ function browserStorage(): StorageManagerWithOpfs | undefined {
2628 : navigator.storage as StorageManagerWithOpfs | undefined;2729 }2830 29-type PublicKeyCredentialCapabilities = typeof PublicKeyCredential & {30- getClientCapabilities?: () => Promise<Record<string, boolean>>;31-};32-33-async function readPrfCapability(34- webAuthn: boolean,35-): Promise<DeviceCapabilityReport["prf"]> {36- if (!webAuthn) return "unavailable";37- const publicKeyCredential = PublicKeyCredential as PublicKeyCredentialCapabilities;38- if (typeof publicKeyCredential.getClientCapabilities !== "function") return "unknown";39- try {40- const capabilities = await publicKeyCredential.getClientCapabilities();41- return capabilities["extension:prf"] === true ? "available" : "not-reported";42- } catch {43- return "unknown";44- }45-}46-4731 /** Reads synchronous browser capabilities needed by the durable Jazz driver. */4832 export function durableCapabilityReport(): DurableCapabilityReport {4933 const storage = browserStorage();@@ -73,7 +57,7 @@ export function durableCapabilityReport(): DurableCapabilityReport {
7357 export async function readDeviceCapabilityReport(): Promise<DeviceCapabilityReport> {7458 const base = durableCapabilityReport();7559 const storage = browserStorage();76- const prf = await readPrfCapability(base.webAuthn);60+ const prf = await readPrfSupport(base.webAuthn);7761 if (typeof storage?.persisted !== "function") {7862 return { ...base, prf, persistentPermission: "unavailable" };7963 }@@ -125,6 +109,13 @@ export class DurableStorageUnsupportedError extends Error {
125109 }126110 }127111 112+/** True when an error is a browser unable to provide the durable-storage contract. */113+export function isDurableStorageUnsupportedError(114+ error: unknown,115+): error is DurableStorageUnsupportedError {116+ return error instanceof DurableStorageUnsupportedError;117+}118+128119 /** Throws unless the current browser can open lofi's persistent local driver. */129120 export function assertDurableBrowser(): void {130121 const report = durableCapabilityReport();@@ -1,6 +1,7 @@
11 import type { SchemaCompatState } from "./schema-compat.ts";22 import type { RuntimeStartupFailure } from "./startup-recovery.ts";33 import type { RuntimeStoreStatus } from "./store-status.ts";4+import type { WriteDurability } from "./table-store.ts";45 56 /** One structured entry recorded by the built-in `s.log` effect unit. */67 export type EffectLogEntry = {@@ -72,9 +73,9 @@ export type RuntimeDiagnostics = {
7273 pendingGlobalWrites: number;7374 /**7475 * The most recent write's deepest durability: `local` is saved on this75- * device, `global` is confirmed by the store.76+ * device, `global` is confirmed by the store. See {@link WriteDurability}.7677 */77- lastWriteDurability: "none" | "local" | "global" | "failed";78+ lastWriteDurability: WriteDurability;7879 /** Writes refused or rejected since boot, including guard refusals. */7980 mutationErrors: number;8081 /** Journaled writes that have not yet settled as synced or rejected. */@@ -97,10 +98,19 @@ export type RuntimeDiagnostics = {
9798 9899 /** One detected shared-field key anomaly. */99100 export type SharedFieldAlert = {101+ /**102+ * The anomaly kind: `peer-key-changed` — a sender's published key no longer103+ * matches its pin, so its wraps are refused until the user re-trusts the104+ * key; `self-key-conflict` — the directory holds a different key for this105+ * account than the one derived locally; `wrap-invalid` — a wrapped key106+ * failed authentication or validation and was not installed.107+ */100108 code: "peer-key-changed" | "self-key-conflict" | "wrap-invalid";101109 /** The account the anomaly concerns (the sender or self). */102110 userId: string;111+ /** Human-readable description for the alert surface. */103112 detail: string;113+ /** ISO-8601 timestamp of when the anomaly was detected. */104114 at: string;105115 };106116 @@ -100,6 +100,14 @@ export class EnvelopeError extends Error {
100100 }101101 }102102 103+/**104+ * True when an error is an envelope failure: `locked` (no available protector105+ * opens the record) or `corrupt` (the record failed authentication).106+ */107+export function isEnvelopeError(error: unknown): error is EnvelopeError {108+ return error instanceof EnvelopeError;109+}110+103111 /** Encodes bytes as unpadded base64url, the envelope's storage encoding. */104112 export function toBase64Url(bytes: Uint8Array): string {105113 let binary = "";@@ -11,12 +11,9 @@ export type LiveQuerySnapshot<T extends TableRow> = {
1111 error: string | null;1212 };1313 14-type Listener = () => void;15-type QuerySubscriptionDb = Pick<Db, "subscribeAll">;16-1714 /** Runtime seams used by a live-query registry. Exported for deterministic framework tests. */1815 export type LiveQueryEnvironment = {19- getDb(): Promise<QuerySubscriptionDb>;16+ getDb(): Promise<Pick<Db, "subscribeAll">>;2017 subscribeRuntimeRecreation(listener: () => void): () => void;2118 /** Fires when a shared field key is installed; stores resubscribe so rows2219 * previously surfaced as key-pending re-materialize into plaintext. Key@@ -47,7 +44,7 @@ export class LiveQueryStore<T extends TableRow> {
4744 readonly #query: QueryBuilder<T>;4845 readonly #environment: LiveQueryEnvironment;4946 readonly #onIdle: () => void;50- readonly #listeners = new Set<Listener>();47+ readonly #listeners = new Set<() => void>();5148 #snapshot: LiveQuerySnapshot<T> = { status: "loading", rows: [], error: null };5249 #vendorUnsubscribe: (() => void) | null = null;5350 #stopRuntimeRecreation: (() => void) | null = null;@@ -70,7 +67,7 @@ export class LiveQueryStore<T extends TableRow> {
7067 getSnapshot = (): LiveQuerySnapshot<T> => this.#snapshot;7168 7269 /** Subscribes one consumer and returns an idempotent cleanup function. */73- subscribe = (listener: Listener): () => void => {70+ subscribe = (listener: () => void): () => void => {7471 if (this.#disposed) throw new Error("live query store has been released");7572 const subscriber = () => listener();7673 this.#listeners.add(subscriber);@@ -18,10 +18,21 @@
1818 * until { applyPwaUpdate} lands a newer bundle — and install/update1919 * state through { getPwaState}.2020 *21+ * Shared encrypted fields span three entry points: declaring an encrypted22+ * column lives in `@nzip/lofi/schema`, group policy and key lifecycle live in23+ * `@nzip/lofi/access`, and pin remediation lives here — a `peer-key-changed`24+ * alert in { RuntimeDiagnostics.sharedFieldAlerts} is resolved with25+ * { trustPeerKey} after out-of-band verification.26+ *2127 * 2228 */2329 24-export { defineLofiApp, type LofiAppConfig, LofiConfigurationError } from "./app.ts";30+export {31+ defineLofiApp,32+ isLofiConfigurationError,33+ type LofiAppConfig,34+ LofiConfigurationError,35+} from "./app.ts";2536 export {2637 type AuthCapability,2738 type AuthDependencies,@@ -45,6 +56,10 @@ export { bootLofi } from "./boot.ts";
4556 export {4657 type BootProgress,4758 type BootProgressPhase,59+ type BootProgressTracker,60+ type BootProgressTrackerDependencies,61+ createBootProgressTracker,62+ type EngineAssetReference,4863 getBootProgress,4964 subscribeBootProgress,5065 } from "./boot-progress.ts";@@ -55,6 +70,7 @@ export {
5570 lockProvisionCapability,5671 type ProvisionCapabilityStatus,5772 provisionCapabilityStatus,73+ type SealOutcome,5874 sealProvisionCapability,5975 unlockProvisionCapability,6076 } from "./provision.ts";@@ -64,9 +80,11 @@ export {
6480 type DurableCapabilityReport,6581 durableCapabilityReport,6682 DurableStorageUnsupportedError,83+ isDurableStorageUnsupportedError,6784 readDeviceCapabilityReport,6885 requestPersistentStorage,6986 } from "./device-capabilities.ts";87+export { type DeviceKeyStore, EnvelopeError, isEnvelopeError } from "./envelope.ts";7088 export {7189 applyPwaUpdate,7290 checkPwaUpdate,@@ -86,13 +104,18 @@ export {
86104 requestPwaInstall,87105 subscribePwaState,88106 } from "./pwa.ts";89-export { type RuntimeDiagnostics } from "./diagnostics.ts";107+export { type RuntimeDiagnostics, type SharedFieldAlert } from "./diagnostics.ts";90108 export {109+ createSchemaCompatGate,91110 describeSchemaCompat,92111 getSchemaCompatState,112+ isSchemaCompatibilityError,113+ type SchemaCompatGate,114+ type SchemaCompatGateDependencies,93115 SchemaCompatibilityError,94116 type SchemaCompatReason,95117 type SchemaCompatState,118+ type SchemaCompatUpdateSurface,96119 subscribeSchemaCompat,97120 } from "./schema-compat.ts";98121 export {@@ -100,6 +123,8 @@ export {
100123 describeStorageFork,101124 dismissStorageFork,102125 getStorageForkState,126+ type StorageForkDiagnostics,127+ type StorageForkDiagnosticsSurface,103128 type StorageForkGuard,104129 storageForkGuard,105130 type StorageForkGuardDependencies,@@ -108,6 +133,7 @@ export {
108133 } from "./storage-fork.ts";109134 export {110135 acquireLiveQuery,136+ type LiveQueryEnvironment,111137 type LiveQueryLease,112138 type LiveQuerySnapshot,113139 type LiveQueryStore,@@ -124,6 +150,7 @@ export {
124150 subscribeRuntimeDiagnostics,125151 } from "./runtime.ts";126152 export {153+ isRuntimeStartupError,127154 reloadAfterRuntimeStartupFailure,128155 RuntimeStartupError,129156 type RuntimeStartupFailure,@@ -132,6 +159,7 @@ export {
132159 export { describeStoreStatus, type RuntimeStoreStatus } from "./store-status.ts";133160 export {134161 acquireTableMutations,162+ type TableMutationEnvironment,135163 type TableMutationLease,136164 type TableMutationSnapshot,137165 type TableMutationStore,@@ -170,9 +198,7 @@ export {
170198 readSinkRestoreOutcome,171199 restoreDeclaredSink,172200 type SinkRestoreOutcome,173- splitTicketForEnrollment,174201 type SyncTicket,175- type TicketSplit,176202 } from "./data-sink.ts";177203 export { RecoveryError } from "./recovery.ts";178204 export { RecoverablePasskeyError, type RecoverablePasskeyErrorCode } from "./passkey-recovery.ts";@@ -183,10 +209,12 @@ export {
183209 type TableSnapshot,184210 type TableStore,185211 type TableStoreOptions,212+ type WriteDurability,186213 } from "./table-store.ts";187214 export { settleUiMutation } from "./ui-mutation.ts";188215 export { classifyMutationError, type MutationErrorClass } from "./mutation-taxonomy.ts";189216 export {217+ isWriteRejectedError,190218 WriteHandle,191219 WriteRejectedError,192220 type WriteRejection,@@ -199,6 +227,7 @@ export {
199227 type LedgerWriteRequest,200228 type PendingWritesSnapshot,201229 type PendingWriteSummary,230+ type ProbeTable,202231 type RowSyncStatus,203232 WriteLedger,204233 type WriteLedgerEnvironment,@@ -208,30 +237,20 @@ export {
208237 createMemoryJournalStorage,209238 type JournalDocument,210239 type JournalEffectState,240+ type JournalEffectStatus,211241 journalIdFor,212242 type JournalStorage,213243 type JournalWriteRecord,214244 type JournalWriteStage,215245 } from "./write-journal.ts";216246 export type { EffectLogEntry } from "./diagnostics.ts";217-export {218- clearFingerprintPins,219- deriveSharedFieldIdentity,220- type DirectoryPublication,221- directoryPublicKey,222- ensureDirectoryEntry,223- installSharedFieldIdentityFromSecret,224- pinnedFingerprint,225- trustPeerKey,226- verifyAndPinFingerprint,227-} from "./shared-field-keys.ts";228-export {229- completePopExchange,230- type DevicePublicKey,231- exportDevicePublicKey,232- getOrCreatePopKeyPair,233- memoryPopKeyStore,234- type PopKeyStore,235- popMessage,236- signPopMessage,237-} from "./pop.ts";247+export { pinnedFingerprint, trustPeerKey, verifyAndPinFingerprint } from "./shared-field-keys.ts";248+export type {249+ EffectContext,250+ EffectHandlers,251+ EffectRow,252+ EffectUnit,253+ MutationDescriptor,254+ MutationOp,255+ MutationOpKind,256+} from "../schema/effects.ts";@@ -112,6 +112,16 @@ export function clearProvisionCapability(): void {
112112 }113113 }114114 115+/** What a completed sealing ceremony reports about the sealed record. */116+export type SealOutcome = {117+ /**118+ * Whether the sealing passkey roams across the user's devices (a119+ * password-manager or platform-synced credential) rather than being bound120+ * to this one device — and with it, where the sealed record can unlock.121+ */122+ portable: boolean;123+};124+115125 /**116126 * Seals the held provision capability under a passkey-PRF slot in one117127 * user-verifying ceremony. The PRF evaluation is attempted, never@@ -122,7 +132,7 @@ export function clearProvisionCapability(): void {
122132 */123133 export async function sealProvisionCapability(124134 options: AuthenticateOptions = {},125-): Promise<{ portable: boolean }> {135+): Promise<SealOutcome> {126136 if (heldUrl === null) {127137 throw new EnvelopeError("locked", "no provision capability is held to seal");128138 }@@ -247,7 +247,13 @@ export function waitForActivation(worker: ServiceWorker): Promise<void> {
247247 });248248 }249249 250-/** Creates an isolated PWA controller, primarily for custom integration and tests. */250+/**251+ * Creates an isolated PWA controller over injectable browser surfaces. The252+ * package-wide wrappers (`getPwaState`, `subscribePwaState`,253+ * `requestPwaInstall`, `checkPwaUpdate`, `applyPwaUpdate`) drive the shared254+ * {@link pwaController}; this factory exists for isolated instances —255+ * deterministic tests and custom hosts that manage their own controller.256+ */251257 export function createPwaController(dependencies: PwaControllerDependencies = {}): PwaController {252258 const subscribers = new Set<(state: PwaState) => void>();253259 const observedUpdateWorkers = new WeakSet<ServiceWorker>();@@ -42,7 +42,7 @@ import {
4242 type RuntimeStartupFailure,4343 } from "./startup-recovery.ts";4444 import { completeLocalRowMigration, readNamespaceState } from "./namespace-state.ts";45-import { nestedAppTables } from "../schema/nested.ts";45+import { type NestedAppRoot, nestedAppTables } from "../schema/nested.ts";4646 4747 /** The shared, lazily opened Jazz client and its application-facing adapters. */4848 export type LofiRuntime = {@@ -126,8 +126,10 @@ function runtimeTables(): RuntimeTable[] {
126126 const appSchema = getLofiApp().schema;127127 // A nested app groups its table handles inside namespace objects, which a128128 // one-level walk would silently skip; its flattened registry is129- // authoritative. A flat `defineApp` schema keeps the one-level walk.130- const values = nestedAppTables(appSchema) ??129+ // authoritative. A flat `defineApp` schema keeps the one-level walk. The130+ // author's schema arrives untyped here, so the brand is asserted and the131+ // null defense classifies flat apps.132+ const values = nestedAppTables(appSchema as NestedAppRoot) ??131133 Object.values(appSchema as Record<string, unknown>);132134 return values.filter((value) =>133135 value && typeof value === "object" && "_table" in value && "where" in value@@ -59,7 +59,7 @@ const updatingMessage = "Updating the app… editing resumes when the update fin
5959 * Raised when a mutation is refused because the local data is ahead of the6060 * running code. It surfaces through the ordinary error path of whatever6161 * performed the write — a verb call's handle fails, a table62- * `insert`/`update`/`delete` rejects — with no journal entry or effect;62+ * `insert`/`update`/`remove` rejects — with no journal entry or effect;6363 * reads keep working. The remediation is updating the app: watch6464 * {@link SchemaCompatState} via `useSchemaCompat` or6565 * {@link subscribeSchemaCompat}, and offer `applyPwaUpdate` when the update@@ -72,6 +72,11 @@ export class SchemaCompatibilityError extends Error {
7272 readonly code = "schema-data-ahead";7373 }7474 75+/** True when an error is a write refused because local data is ahead of this code. */76+export function isSchemaCompatibilityError(error: unknown): error is SchemaCompatibilityError {77+ return error instanceof SchemaCompatibilityError;78+}79+7580 /** The update surface the gate drives as its remediation. */7681 export type SchemaCompatUpdateSurface = Pick<7782 PwaController,@@ -126,7 +131,13 @@ function defaultLoadManifest(): Promise<unknown> {
126131 );127132 }128133 129-/** Creates an isolated gate, primarily for tests; boot uses the shared one. */134+/**135+ * Creates an isolated compatibility gate over injectable browser surfaces.136+ * The package-wide wrappers ({@link getSchemaCompatState},137+ * {@link subscribeSchemaCompat}, {@link assertSchemaWritable}) all read the138+ * shared gate that boot starts; this factory exists for isolated instances —139+ * deterministic tests and custom hosts that manage their own gate.140+ */130141 export function createSchemaCompatGate(131142 dependencies: SchemaCompatGateDependencies = {},132143 ): SchemaCompatGate {@@ -61,7 +61,11 @@ export type SessionSink = {
6161 6262 /** A snapshot of the account: what is possible and what the user has chosen. */6363 export type Session = {64- /** Stable, app-scoped Jazz sharing identity for the active account. */64+ /**65+ * Stable, app-scoped Jazz sharing identity for the active account. Named66+ * `user_id` because it relays the sync principal's vocabulary verbatim —67+ * the same identifier permission rows and directory tables carry.68+ */6569 user_id: string | null;6670 /** Whether a sync location exists (declared or compiled), so backup + sync is possible. */6771 syncAvailable: boolean;@@ -220,7 +224,11 @@ export async function createBackupPasskey(label?: string): Promise<boolean> {
220224 }221225 }222226 223-/** Non-secret confirmation that a recoverable passkey was created for an account and RP-ID. */227+/**228+ * Non-secret confirmation that a recoverable passkey was created for an229+ * account and RP-ID. `user_id` relays the sync principal's vocabulary230+ * verbatim, matching {@link Session.user_id}.231+ */224232 export type PasskeyBackupReceipt = { user_id: string; rpId: string };225233 226234 /**@@ -47,6 +47,11 @@ export class RuntimeStartupError extends Error {
4747 }4848 }4949 50+/** True when an error is a classified persistent-runtime startup failure. */51+export function isRuntimeStartupError(error: unknown): error is RuntimeStartupError {52+ return error instanceof RuntimeStartupError;53+}54+5055 /** Maps vendor, capability, configuration, and fallback failures onto stable Lofi categories. */5156 export function classifyRuntimeStartupFailure(5257 error: unknown,@@ -140,7 +140,13 @@ function defaultEnvironment(): Pick<
140140 }141141 }142142 143-/** Creates an isolated guard, primarily for tests; boot uses the shared one. */143+/**144+ * Creates an isolated storage-fork guard over injectable browser surfaces.145+ * The package-wide wrappers (`getStorageForkState`, `subscribeStorageFork`,146+ * `dismissStorageFork`) read the shared {@link storageForkGuard} that boot147+ * arms; this factory exists for isolated instances — deterministic tests and148+ * custom hosts that manage their own guard.149+ */144150 export function createStorageForkGuard(145151 dependencies: StorageForkGuardDependencies = {},146152 ): StorageForkGuard {@@ -3,7 +3,7 @@ import type { Db, MutationErrorEvent, TableProxy } from "jazz-tools";
33 import { syncing } from "./config.ts";44 import type { RuntimeDiagnostics } from "./diagnostics.ts";55 import { getRuntime, runtimeRecreatedEvent, updateRuntimeDiagnostics } from "./runtime.ts";6-import type { TableRow } from "./table-store.ts";6+import type { TableRow, WriteDurability } from "./table-store.ts";77 import type { WriteHandle } from "./write-handle.ts";88 import { getWriteLedger, type WriteLedger } from "./write-ledger.ts";99 @@ -11,19 +11,12 @@ import { getWriteLedger, type WriteLedger } from "./write-ledger.ts";
1111 export type TableMutationSnapshot = {1212 /** Writes on this table still awaiting their sync fate. */1313 pending: number;14- /**15- * How far the latest write has travelled: `none` before any write settles,16- * `local` once saved on this device (the resting state when sync is not17- * configured), `global` once the store confirms it, `failed` when it was18- * denied.19- */20- durability: "none" | "local" | "global" | "failed";14+ /** The latest write's deepest tier; see {@link WriteDurability}. */15+ durability: WriteDurability;2116 /** The latest table-scoped rejection message, or `null`. */2217 error: string | null;2318 };2419 25-type Listener = () => void;26-2720 // Own-batch attribution keeps a bounded window of recent writes; late2821 // rejections older than this are diagnostics-only.2922 const MAX_TRACKED_BATCHES = 128;@@ -43,7 +36,7 @@ export class TableMutationStore<T extends TableRow, Init> {
4336 readonly #table: TableProxy<T, Init>;4437 readonly #environment: TableMutationEnvironment;4538 readonly #onIdle: () => void;46- readonly #listeners = new Set<Listener>();39+ readonly #listeners = new Set<() => void>();4740 readonly #ownBatches = new Set<unknown>();4841 #batchTracking = false;4942 #snapshot: TableMutationSnapshot = { pending: 0, durability: "none", error: null };@@ -68,7 +61,7 @@ export class TableMutationStore<T extends TableRow, Init> {
6861 getSnapshot = (): TableMutationSnapshot => this.#snapshot;6962 7063 /** Retains the table-scoped error listener for one consumer. */71- subscribe = (listener: Listener): () => void => {64+ subscribe = (listener: () => void): () => void => {7265 if (this.#disposed) throw new Error("table mutation store has been released");7366 const subscriber = () => listener();7467 this.#listeners.add(subscriber);@@ -15,30 +15,32 @@ export type TableRow = { id: string };
1515 export type RowOf<Table> = Table extends { readonly _rowType: infer Row } ? Row : never;1616 1717 /**18- * A declared schema table (`schema.<name>`). It is both an insert/update/delete18+ * A declared schema table (`schema.<name>`). It is both an insert/update/remove1919 * target and a query source, so the store accepts the intersection Jazz expects.2020 * `T` is the row type and `Init` the insert type (row minus server-owned fields).2121 */2222 export type TableHandle<T extends TableRow, Init> = TableProxy<T, Init> & QueryBuilder<T>;2323 24+/**25+ * How far the latest write has travelled: `none` before any write settles,26+ * `local` once saved on this device (the resting state when sync is not27+ * configured), `global` once the store confirms it, `failed` when it was28+ * denied.29+ */30+export type WriteDurability = "none" | "local" | "global" | "failed";31+2432 /** Reactive table state including rows and the last observed durability tier. */2533 export type TableSnapshot<T extends TableRow> = {2634 /** `ready` once the first result arrived; an empty `rows` is not `loading`. */2735 status: "loading" | "ready" | "error";2836 /** The current typed rows of the table. */2937 rows: T[];30- /**31- * How far the latest write has travelled: `none` before any write settles,32- * `local` once saved on this device (the resting state when sync is not33- * configured), `global` once the store confirms it, `failed` when it was34- * denied.35- */36- durability: "none" | "local" | "global" | "failed";38+ /** The latest write's deepest tier; see {@link WriteDurability}. */39+ durability: WriteDurability;3740 /** The latest subscription or write error message, or `null`. */3841 error: string | null;3942 };4043 41-type Listener = () => void;4244 type MutationHandle = {4345 /** The vendor batch id, used to attribute asynchronous rejections. */4446 batchId?: unknown;@@ -79,7 +81,7 @@ export class TableStore<T extends TableRow, Init> {
7981 readonly #syncConfigured: () => boolean;8082 readonly #diagnosticsChanged: () => void;8183 readonly #guardWrite?: () => Promise<(() => void) | void>;82- readonly #listeners = new Set<Listener>();84+ readonly #listeners = new Set<() => void>();8385 readonly #stopMutationErrors: () => void;8486 readonly #ownBatches = new Set<unknown>();8587 #batchTracking = false;@@ -122,7 +124,7 @@ export class TableStore<T extends TableRow, Init> {
122124 getSnapshot = (): TableSnapshot<T> => this.#snapshot;123125 124126 /** Subscribes to snapshot changes and opens the vendor subscription on first use. */125- subscribe = (listener: Listener): () => void => {127+ subscribe = (listener: () => void): () => void => {126128 const subscriber = () => listener();127129 this.#listeners.add(subscriber);128130 this.#diagnostics.activeConsumers += 1;@@ -179,11 +181,21 @@ export class TableStore<T extends TableRow, Init> {
179181 })();180182 }181183 182- /** Deletes a row and waits for local durability before resolving. */183- async delete(id: string): Promise<void> {184+ /** Removes a row and waits for local durability before resolving. */185+ async remove(id: string): Promise<void> {184186 await this.#perform(() => this.#db.delete(this.#table, id));185187 }186188 189+ /**190+ * Removes a row and waits for local durability before resolving.191+ *192+ * @deprecated Use {@link TableStore.remove} — the removal verb every other193+ * mutation surface uses. This alias remains for one release.194+ */195+ delete(id: string): Promise<void> {196+ return this.remove(id);197+ }198+187199 // The guard runs before the mutation reaches the vendor: a refusal (the188200 // schema-compatibility gate) surfaces like any write failure, and the189201 // release ends this write's hold on the cross-tab write lock once the@@ -198,12 +198,14 @@ test("every mutation exposes pending work and waits for local durability", async
198198 assertCount(counts.pendingLocalWrites, 0, "a retained local write must leave no pending work");199199 await store.update("row-1", { text: "edited" });200200 await store.update("row-1", { completed: true });201+ await store.remove("row-1");202+ // The deprecated alias must keep routing through the same removal path.201203 await store.delete("row-1");202204 203- assertCount(counts.localWaitCalls, 4, "each accepted mutation must await local durability");205+ assertCount(counts.localWaitCalls, 5, "each accepted mutation must await local durability");204206 assert(205- db.writes.map((write) => write.operation).join(",") === "insert,update,update,delete",206- "insert, edit, complete, and delete must reach the Jazz boundary",207+ db.writes.map((write) => write.operation).join(",") === "insert,update,update,delete,delete",208+ "insert, edit, complete, and both removal spellings must reach the Jazz boundary",207209 );208210 assert(store.getSnapshot().durability === "local", "the UI may claim local after the wait");209211 assert(@@ -66,6 +66,11 @@ export class WriteRejectedError extends Error {
6666 }6767 }6868 69+/** True when an error is a write the store adjudicated and denied permanently. */70+export function isWriteRejectedError(error: unknown): error is WriteRejectedError {71+ return error instanceof WriteRejectedError;72+}73+6974 const stageOrder: Record<WriteStage, number> = {7075 saving: 0,7176 saved: 1,@@ -88,6 +93,47 @@ function exposedPromise<T>(): { promise: Promise<T>; resolver: Resolver<T> } {
8893 return { promise, resolver: resolver! };8994 }9095 96+/**97+ * The runtime-only mutator half of a write handle. Only the write ledger98+ * holds a handle's controller — the handle itself exposes no way to advance99+ * or settle, so application code can observe a write's fate but never forge100+ * it.101+ */102+export type WriteHandleController<T> = {103+ /** Records the vendor batch id once the write is accepted. */104+ setBatchId(batchId: string | null): void;105+ /**106+ * Advances the monotonic stage machine; later stages never regress. The107+ * value supplied at (or before) `saved` is the one both stage promises108+ * resolve with.109+ */110+ advance(stage: Exclude<WriteStage, "rejected">, value?: T): void;111+ /** Settles the write as `rejected` with the adjudicated reason. */112+ reject(rejection: WriteRejection): void;113+ /** Fails every stage promise without entering the `rejected` stage. */114+ fail(error: unknown): void;115+};116+117+// Controllers are handed out exactly once, at construction, through118+// createWriteHandle. The map is module-private, so no code outside this119+// module can reach a handle's mutators through its public surface.120+const controllers = new WeakMap<WriteHandle<unknown>, WriteHandleController<unknown>>();121+122+/**123+ * Creates a handle in `saving` together with its controller — the only path124+ * to a handle's lifecycle mutators. The ledger keeps the controller and hands125+ * the observe-only handle to application code.126+ */127+export function createWriteHandle<T>(128+ writeId: string,129+): { handle: WriteHandle<T>; controller: WriteHandleController<T> } {130+ const handle = new WriteHandle<T>(writeId);131+ return {132+ handle,133+ controller: controllers.get(handle as WriteHandle<unknown>)! as WriteHandleController<T>,134+ };135+}136+91137 /**92138 * A single write observed through the author-facing lifecycle.93139 *@@ -102,6 +148,10 @@ function exposedPromise<T>(): { promise: Promise<T>; resolver: Resolver<T> } {
102148 * it is `saved`. In Preact components, render a handle with `useWrite` and103149 * the app-wide pending set with `usePendingWrites`.104150 *151+ * Handles are issued by the runtime and are observe-only: the lifecycle152+ * mutators live on a controller the ledger keeps at construction, so no153+ * consumer of a handle can advance or settle it.154+ *105155 * 106156 * ```ts107157 * const write = placeOrder({ sku, quantity }); // a verb returns a WriteHandle@@ -124,7 +174,11 @@ export class WriteHandle<T> implements PromiseLike<T> {
124174 #valueSet = false;125175 #batchId: string | null = null;126176 127- /** Creates a handle in `saving`; the runtime advances it via its controller. */177+ /**178+ * Creates a handle in `saving`. The lifecycle mutators are handed to the179+ * module-internal factory at construction; a directly constructed handle180+ * has no reachable controller and therefore never advances.181+ */128182 constructor(writeId: string) {129183 this.#writeId = writeId;130184 const saved = exposedPromise<T>();@@ -133,6 +187,14 @@ export class WriteHandle<T> implements PromiseLike<T> {
133187 this.#savedResolver = saved.resolver;134188 this.#synced = synced.promise;135189 this.#syncedResolver = synced.resolver;190+ controllers.set(this as WriteHandle<unknown>, {191+ setBatchId: (batchId) => {192+ this.#batchId = batchId;193+ },194+ advance: (stage, value) => this.#advance(stage, value as T | undefined),195+ reject: (rejection) => this.#reject(rejection),196+ fail: (error) => this.#fail(error),197+ });136198 }137199 138200 /** The stable journal id of this write. */@@ -145,11 +207,6 @@ export class WriteHandle<T> implements PromiseLike<T> {
145207 return this.#batchId;146208 }147209 148- /** @internal Records the vendor batch id once the write is accepted. */149- setBatchId(batchId: string | null): void {150- this.#batchId = batchId;151- }152-153210 /** The current lifecycle stage. Monotonic; never moves backwards. */154211 get stage(): WriteStage {155212 return this.#stage;@@ -200,12 +257,10 @@ export class WriteHandle<T> implements PromiseLike<T> {
200257 };201258 }202259 203- /**204- * @internal Advances the monotonic stage machine; later stages never205- * regress. The value supplied at (or before) `saved` is the one both stage206- * promises resolve with.207- */208- advance(stage: Exclude<WriteStage, "rejected">, value?: T): void {260+ // Advances the monotonic stage machine; later stages never regress. The261+ // value supplied at (or before) `saved` is the one both stage promises262+ // resolve with. Reachable only through the construction-time controller.263+ #advance(stage: Exclude<WriteStage, "rejected">, value?: T): void {209264 if (this.#stage === "synced" || this.#stage === "rejected") return;210265 if (!this.#valueSet && value !== undefined) {211266 this.#value = value;@@ -218,8 +273,8 @@ export class WriteHandle<T> implements PromiseLike<T> {
218273 this.#emit();219274 }220275 221- /** @internal Settles the write as `rejected` with the adjudicated reason. */222- reject(rejection: WriteRejection): void {276+ // Settles the write as `rejected` with the adjudicated reason.277+ #reject(rejection: WriteRejection): void {223278 if (this.#stage === "synced" || this.#stage === "rejected") return;224279 this.#stage = "rejected";225280 this.#reason = rejection;@@ -231,8 +286,8 @@ export class WriteHandle<T> implements PromiseLike<T> {
231286 this.#emit();232287 }233288 234- /** @internal Fails every stage promise without entering the `rejected` stage. */235- fail(error: unknown): void {289+ // Fails every stage promise without entering the `rejected` stage.290+ #fail(error: unknown): void {236291 if (this.#stage === "synced" || this.#stage === "rejected") return;237292 this.#savedResolver.reject(error);238293 this.#syncedResolver.reject(error);@@ -1,36 +1,44 @@
11 import { assert, assertCount } from "./test-assert.ts";2-import { WriteHandle, WriteRejectedError } from "./write-handle.ts";2+import { createWriteHandle, WriteRejectedError } from "./write-handle.ts";33 44 Deno.test("await resolves at saved with the write's value", async () => {5- const handle = new WriteHandle<{ id: string }>("w1");6- handle.advance("saved", { id: "row-1" });5+ const { handle, controller } = createWriteHandle<{ id: string }>("w1");6+ controller.advance("saved", { id: "row-1" });77 const row = await handle;88 assert(row.id === "row-1", "the thenable must resolve with the saved value");99 assert(handle.stage === "saved", "stage must be saved after local durability");1010 });1111 1212 Deno.test("stages are monotonic and never regress", () => {13- const handle = new WriteHandle<void>("w1");14- handle.advance("synced");15- handle.advance("saved");13+ const { handle, controller } = createWriteHandle<void>("w1");14+ controller.advance("synced");15+ controller.advance("saved");1616 assert(handle.stage === "synced", "a later stage must not regress to an earlier one");17- handle.reject({ cause: "denied", code: "permission_denied", reason: "late" });17+ controller.reject({ cause: "denied", code: "permission_denied", reason: "late" });1818 assert(handle.stage === "synced", "a settled write must ignore late verdicts");1919 assert(handle.reason === null, "a settled write must not carry a rejection");2020 });2121 22+Deno.test("the public handle surface exposes no lifecycle mutators", () => {23+ const { handle } = createWriteHandle<void>("w1");24+ const exposed = handle as unknown as Record<string, unknown>;25+ for (const name of ["advance", "reject", "fail", "setBatchId"]) {26+ assert(typeof exposed[name] !== "function", `${name} must not be reachable on the handle`);27+ }28+});29+2230 Deno.test("synced resolves with the value staged before confirmation", async () => {23- const handle = new WriteHandle<{ id: string }>("w1");24- handle.advance("saving", { id: "row-9" });25- handle.advance("synced");31+ const { handle, controller } = createWriteHandle<{ id: string }>("w1");32+ controller.advance("saving", { id: "row-9" });33+ controller.advance("synced");2634 const row = await handle.synced;2735 assert(row.id === "row-9", "synced must resolve with the staged value");2836 });2937 3038 Deno.test("rejection settles both promises with WriteRejectedError and sets reason", async () => {31- const handle = new WriteHandle<void>("w1");32- handle.advance("saved");33- handle.reject({ cause: "denied", code: "permission_denied", reason: "denied by store" });39+ const { handle, controller } = createWriteHandle<void>("w1");40+ controller.advance("saved");41+ controller.reject({ cause: "denied", code: "permission_denied", reason: "denied by store" });3442 assert(handle.stage === "rejected", "the verdict must reach the stage");3543 assert(handle.reason?.code === "permission_denied", "the rejection code must be readable");3644 const error = await handle.synced.then(() => null, (thrown) => thrown as WriteRejectedError);@@ -39,8 +47,8 @@ Deno.test("rejection settles both promises with WriteRejectedError and sets reas
3947 });4048 4149 Deno.test("an unobserved rejected handle leaks no unhandled rejection", async () => {42- const handle = new WriteHandle<void>("w1");43- handle.reject({ cause: "denied", code: null, reason: "refused" });50+ const { handle, controller } = createWriteHandle<void>("w1");51+ controller.reject({ cause: "denied", code: null, reason: "refused" });4452 // Nothing awaits the handle; settling the microtask queue must not surface4553 // an unhandled rejection (the test runner fails the test if one escapes).4654 await new Promise((resolve) => setTimeout(resolve, 0));@@ -48,15 +56,15 @@ Deno.test("an unobserved rejected handle leaks no unhandled rejection", async ()
4856 });4957 5058 Deno.test("subscribers are level-triggered: late subscribers see current truth", () => {51- const handle = new WriteHandle<void>("w1");52- handle.advance("saved");59+ const { handle, controller } = createWriteHandle<void>("w1");60+ controller.advance("saved");5361 const seen: string[] = [];5462 const stop = handle.subscribe(() => seen.push(handle.stage));5563 assert(seen[0] === "saved", "a late subscriber must immediately observe the current stage");56- handle.advance("synced");64+ controller.advance("synced");5765 assert(seen[1] === "synced", "subscribers must observe later transitions");5866 stop();5967 stop();60- handle.reject({ cause: "denied", code: null, reason: "ignored" });68+ controller.reject({ cause: "denied", code: null, reason: "ignored" });6169 assertCount(seen.length, 2, "an unsubscribed listener must not be notified");6270 });@@ -40,7 +40,7 @@ import {
4040 } from "./runtime.ts";4141 import { assertSchemaWritable } from "./schema-compat.ts";4242 import { acquireUpgradeWriteLock } from "./upgrade-coordination.ts";43-import { WriteHandle } from "./write-handle.ts";43+import { createWriteHandle, type WriteHandle, type WriteHandleController } from "./write-handle.ts";4444 import {4545 createDefaultJournalStorage,4646 hashJournalValue,@@ -102,7 +102,7 @@ export type LedgerWriteOptions = {
102102 /** The effect units journaled with this write. */103103 units?: readonly EffectUnit<{ id: string }>[];104104 /** The intent's lifespan in milliseconds, or `null` for none. */105- expiresMs?: number | null;105+ expiresAfterMs?: number | null;106106 };107107 108108 type VendorWrite = {@@ -118,7 +118,8 @@ type LedgerDb = Pick<Db, "onMutationError"> & {
118118 all(query: never, options?: { tier: "local" | "global" }): Promise<unknown[]>;119119 };120120 121-type ProbeTable = { where(input: Record<string, unknown>): unknown };121+/** The narrow table surface boot-reconciliation probes query by row id. */122+export type ProbeTable = { where(input: Record<string, unknown>): unknown };122123 123124 /** Runtime seams used by the ledger; tests inject deterministic values. */124125 export type WriteLedgerEnvironment = {@@ -204,7 +205,9 @@ export class WriteLedger {
204205 readonly #probing = new Set<string>();205206 readonly #timers = new Set<ReturnType<typeof setTimeout>>();206207 readonly #sessionFates = new Map<string, { stage: "synced" | "rejected"; at: number }>();207- #handles = new Map<string, WriteHandle<unknown>>();208+ // Controllers are the mutator half of issued handles; the ledger is the209+ // only holder, which is what keeps handle settlement unforgeable.210+ #controllers = new Map<string, WriteHandleController<unknown>>();208211 readonly #liveRows = new Map<string, Record<string, unknown>>();209212 #sweepTimer: ReturnType<typeof setInterval> | null = null;210213 #db: LedgerDb | null = null;@@ -269,11 +272,17 @@ export class WriteLedger {
269272 */270273 perform<T>(request: LedgerWriteRequest, options: LedgerWriteOptions = {}): WriteHandle<T> {271274 if (this.#disposed) throw new Error("write ledger has been disposed");272- const handle = new WriteHandle<T>(crypto.randomUUID());275+ const { handle, controller } = createWriteHandle<T>(crypto.randomUUID());273276 const db = this.#db;274277 const guard = this.#environment.guardWrite;275278 if (db && !guard) {276- this.#execute(db, request, options, handle as WriteHandle<unknown>);279+ this.#execute(280+ db,281+ request,282+ options,283+ handle as WriteHandle<unknown>,284+ controller as WriteHandleController<unknown>,285+ );277286 return handle;278287 }279288 void (async () => {@@ -284,14 +293,20 @@ export class WriteLedger {
284293 // A guard refusal is not a verdict: nothing is journaled and no285294 // effect fires; the failure surfaces like any refused mutation.286295 this.#environment.updateDiagnostics((diagnostics) => diagnostics.mutationErrors += 1);287- handle.fail(error);296+ controller.fail(error);288297 return;289298 }290299 try {291300 const openDb = await this.#withDb();292- this.#execute(openDb, request, options, handle as WriteHandle<unknown>);301+ this.#execute(302+ openDb,303+ request,304+ options,305+ handle as WriteHandle<unknown>,306+ controller as WriteHandleController<unknown>,307+ );293308 } catch (error) {294- handle.fail(error);309+ controller.fail(error);295310 } finally {296311 if (release) {297312 // The lock covers the local-durability window: it releases when@@ -311,7 +326,7 @@ export class WriteLedger {
311326 const options: LedgerWriteOptions = {312327 verb: descriptor.verbName,313328 units: descriptor.units,314- expiresMs: descriptor.expiresMs,329+ expiresAfterMs: descriptor.expiresAfterMs,315330 };316331 if (descriptor.op.kind === "insert") {317332 return this.perform({ kind: "insert", table, values: args[0] }, options);@@ -504,6 +519,7 @@ export class WriteLedger {
504519 request: LedgerWriteRequest,505520 options: LedgerWriteOptions,506521 handle: WriteHandle<unknown>,522+ controller: WriteHandleController<unknown>,507523 ): void {508524 let vendor: VendorWrite;509525 let rowId: string;@@ -552,7 +568,7 @@ export class WriteLedger {
552568 code: null,553569 reason: null,554570 createdAt,555- expiresAt: options.expiresMs != null ? createdAt + options.expiresMs : null,571+ expiresAt: options.expiresAfterMs != null ? createdAt + options.expiresAfterMs : null,556572 effects: Object.fromEntries(557573 (options.units ?? []).map((unit) => [558574 unit.effectName,@@ -569,19 +585,19 @@ export class WriteLedger {
569585 document.writes[entry.writeId] = entry;570586 });571587 this.#liveRows.set(entry.writeId, rowSnapshot);572- this.#handles.set(entry.writeId, handle);573- handle.setBatchId(entry.batchId);588+ this.#controllers.set(entry.writeId, controller);589+ controller.setBatchId(entry.batchId);574590 // Pre-stage the insert's row value so a fast global confirmation can575591 // never resolve the stage promises before the value is known.576- if (request.kind === "insert") handle.advance("saving", vendor.value);592+ if (request.kind === "insert") controller.advance("saving", vendor.value);577593 this.#afterChange();578- void this.#settle(vendor, entry, handle);594+ void this.#settle(vendor, entry, controller);579595 }580596 581597 async #settle(582598 vendor: VendorWrite,583599 entry: JournalWriteRecord,584- handle: WriteHandle<unknown>,600+ controller: WriteHandleController<unknown>,585601 ): Promise<void> {586602 const syncConfigured = this.#environment.syncConfigured();587603 let value: unknown;@@ -608,12 +624,12 @@ export class WriteLedger {
608624 this.#journal.update((document) => {609625 delete document.writes[entry.writeId];610626 });611- this.#handles.delete(entry.writeId);612- handle.fail(error);627+ this.#controllers.delete(entry.writeId);628+ controller.fail(error);613629 this.#afterChange();614630 return;615631 }616- handle.advance("saved", value);632+ controller.advance("saved", value);617633 // Without a sync location there is no store to adjudicate: local618634 // durability is settlement, and effects run now.619635 if (!syncConfigured) this.#settleSynced(entry);@@ -659,8 +675,7 @@ export class WriteLedger {
659675 this.#journal.update(() => {660676 current.stage = "synced";661677 });662- const handle = this.#handles.get(current.writeId);663- handle?.advance("synced");678+ this.#controllers.get(current.writeId)?.advance("synced");664679 this.#fireUnits(current);665680 this.#afterChange();666681 }@@ -675,8 +690,7 @@ export class WriteLedger {
675690 current.code = code;676691 current.reason = reason;677692 });678- const handle = this.#handles.get(current.writeId);679- handle?.reject({ cause: "denied", code, reason });693+ this.#controllers.get(current.writeId)?.reject({ cause: "denied", code, reason });680694 this.#fireUnits(current);681695 this.#afterChange();682696 }@@ -810,7 +824,7 @@ export class WriteLedger {
810824 this.#journal.update(() => {811825 delete document.writes[entry.writeId];812826 });813- this.#handles.delete(entry.writeId);827+ this.#controllers.delete(entry.writeId);814828 this.#liveRows.delete(entry.writeId);815829 }816830 }@@ -135,7 +135,7 @@ function unit(
135135 options: {136136 failFirst?: { onSynced?: boolean };137137 failAlways?: boolean;138- expiresAfter?: number;138+ expiresAfterMs?: number;139139 maxAttempts?: number;140140 } = {},141141 ): EffectUnit<{ id: string }> {@@ -155,7 +155,7 @@ function unit(
155155 calls.push({ handler: "onRejected", row, context });156156 },157157 },158- expiresAfterMs: options.expiresAfter ?? null,158+ expiresAfterMs: options.expiresAfterMs ?? null,159159 ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),160160 };161161 }@@ -699,7 +699,7 @@ Deno.test("an overdue intent surfaces in pending state and diagnostics without c
699699 await fixture.ledger.arm();700700 const handle = fixture.ledger.perform<Row>(701701 { kind: "insert", table: table as TableProxy<unknown, unknown>, values: { title: "slow" } },702- { verb: "placeOrder", units: [units.get("chargeCard")!], expiresMs: 50 },702+ { verb: "placeOrder", units: [units.get("chargeCard")!], expiresAfterMs: 50 },703703 );704704 await handle;705705 assert(@@ -724,7 +724,7 @@ Deno.test("a closed delivery window retires the obligation: no handler, diagnosa
724724 const calls: Array<725725 { handler: "onSynced" | "onRejected"; row: { id: string }; context: EffectContext }726726 > = [];727- const units = new Map([["notifyOnce", unit("notifyOnce", calls, { expiresAfter: 30 })]]);727+ const units = new Map([["notifyOnce", unit("notifyOnce", calls, { expiresAfterMs: 30 })]]);728728 const fixture = harness({ units });729729 await fixture.ledger.arm();730730 const handle = fixture.ledger.perform<Row>(@@ -15,7 +15,7 @@
1515 * 1616 */1717 18-import { nestedAppDeployTarget, nestedAppTables } from "./nested.ts";18+import { nestedAppDeployTarget, type NestedAppRoot, nestedAppTables } from "./nested.ts";1919 2020 /** The schema range one build understands: its head and every known ancestor. */2121 export type SchemaVersionRange = {@@ -99,8 +99,12 @@ export async function computeSchemaFingerprint(
9999 */100100 export function resolveAppWasmSchema(app: unknown): Record<string, unknown> {101101 if (app && typeof app === "object") {102- if (nestedAppTables(app) !== null) {103- const target = nestedAppDeployTarget(app) as { wasmSchema?: Record<string, unknown> };102+ // The app value arrives untyped from a loaded module, so the brand is103+ // asserted and the null defense classifies non-nested values.104+ if (nestedAppTables(app as NestedAppRoot) !== null) {105+ const target = nestedAppDeployTarget(app as NestedAppRoot) as {106+ wasmSchema?: Record<string, unknown>;107+ };104108 if (target.wasmSchema) return target.wasmSchema;105109 }106110 const wasmSchema = (app as { wasmSchema?: unknown }).wasmSchema;@@ -10,13 +10,12 @@ import { createPolicyTestApp } from "jazz-tools/testing";
1010 import { assert } from "../runtime/test-assert.ts";1111 import {1212 type ArrayColumn,13- clearEncryptedColumnKey,1413 EncryptedColumnError,1514 type IntColumn,1615 s,17- setEncryptedColumnKey,1816 type StringColumn,1917 } from "./mod.ts";18+import { clearEncryptedColumnKey, setEncryptedColumnKey } from "./encrypted.ts";2019 2120 const app = s.defineApp({2221 parents: s.table({ name: s.string() }),@@ -57,7 +57,8 @@ export type EffectContext = {
5757 * Why a `rejected` write settled: `denied` for a store verdict; `null` on5858 * the `synced` fate. `expired` is reserved for store-side expiry5959 * enforcement — at the pinned runtime an overdue intent is surfaced, never60- * retired ({ MutationOptions.expires}), so rejections today carry60+ * retired ({ MutationOptions.expiresAfterMs}), so rejections today61+ * carry6162 * `denied`.6263 */6364 cause: "denied" | "expired" | null;@@ -94,7 +95,7 @@ export type EffectUnitOptions = {
9495 * write happened, so compensation would be wrong. External-tier handlers9596 * whose receivers keep finite idempotency windows should set this.9697 */97- expiresAfter?: number;98+ expiresAfterMs?: number;9899 /**99100 * Failing handler attempts before quarantine: past this count the100101 * obligation retires as failed-permanent instead of re-arming at every@@ -190,7 +191,7 @@ export type MutationOptions<Row> = {
190191 * retiring it as rejected could fire compensation for a write that later191192 * syncs anyway.192193 */193- expires?: number;194+ expiresAfterMs?: number;194195 };195196 196197 /** The registered declaration the runtime dispatches for one verb. */@@ -202,7 +203,7 @@ export type MutationDescriptor = {
202203 /** The verb's effect units, implicit unit included. */203204 readonly units: readonly EffectUnit<{ id: string }>[];204205 /** The intent's lifespan in milliseconds, or `null` for none. */205- readonly expiresMs: number | null;206+ readonly expiresAfterMs: number | null;206207 };207208 208209 /** The runtime half installed by the package runtime before verbs are called. */@@ -235,7 +236,11 @@ function slot(): EffectsSlot {
235236 return effectsGlobal[slotName];236237 }237238 238-/** Installed by the package runtime; author code never calls this. */239+/**240+ * Installs the runtime half verbs dispatch through. The package runtime241+ * installs it at boot; application code never calls this. Tests install a242+ * deterministic fake to unit-test verbs without a booted runtime.243+ */239244 export function setMutationRuntime(runtime: MutationRuntime): void {240245 slot().runtime = runtime;241246 }@@ -312,7 +317,7 @@ export function effect<T extends { id: string }, Init>(
312317 const unit: EffectUnit<T> = {313318 effectName: name,314319 handlers,315- expiresAfterMs: options.expiresAfter ?? null,320+ expiresAfterMs: options.expiresAfterMs ?? null,316321 ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),317322 };318323 registerUnit(unit as EffectUnit<{ id: string }>);@@ -428,7 +433,7 @@ export function mutation<T extends { id: string }, Init, Kind extends MutationOp
428433 verbName: name,429434 op: op as MutationOp<unknown, unknown>,430435 units,431- expiresMs: options.expires ?? null,436+ expiresAfterMs: options.expiresAfterMs ?? null,432437 };433438 state.verbs.set(name, descriptor);434439 const verb = (...args: readonly unknown[]) => requireRuntime().dispatch(descriptor, args);@@ -1,13 +1,8 @@
11 import { schema } from "jazz-tools";22 import { assert, assertCount } from "../runtime/test-assert.ts";33 import type { WriteHandle } from "../runtime/write-handle.ts";4-import {5- clearEffectDeclarations,6- type EffectContext,7- type MutationDescriptor,8- s,9- setMutationRuntime,10-} from "./mod.ts";4+import { type EffectContext, s } from "./mod.ts";5+import { clearEffectDeclarations, type MutationDescriptor, setMutationRuntime } from "./effects.ts";116 127 const app = schema.defineApp({138 orders: schema.table({ item: schema.string(), qty: schema.int() }),@@ -39,7 +34,7 @@ Deno.test("verbs carry their declared units and dispatch call-site arguments", (
3934 });4035 const placeOrder = s.mutation("placeOrder", s.insert(app.orders), {4136 effects: [chargeCard, s.log("order-placed")],42- expires: 60_000,37+ expiresAfterMs: 60_000,4338 });4439 placeOrder({ item: "tea", qty: 2 });4540 assertCount(recorder.dispatched.length, 1, "the verb call must reach the dispatcher");@@ -54,7 +49,7 @@ Deno.test("verbs carry their declared units and dispatch call-site arguments", (
5449 (args[0] as { item: string }).item === "tea",5550 "call-site values must be forwarded untouched",5651 );57- assert(descriptor.expiresMs === 60_000, "the intent lifespan must ride the descriptor");52+ assert(descriptor.expiresAfterMs === 60_000, "the intent lifespan must ride the descriptor");5853 clearEffectDeclarations();5954 });6055 @@ -45,11 +45,13 @@ import { padPayload, unpadPayload } from "./padding.ts";
4545 4646 declare const encryptedColumnBrand: unique symbol;4747 48-// The phantom stored type is a union on purpose: the engine's where-input49-// mapping branches on the single stored sql type, so a union matches no50-// branch and every filter position for the column collapses to `never`.51-// Row and insert types read the view type and are unaffected.52-type EncryptedStoredSql = "TEXT" | "BYTEA";48+/**49+ * The phantom stored SQL type of encrypted columns. The union is deliberate:50+ * the engine's where-input mapping branches on a single stored sql type, so a51+ * union matches no branch and every filter position for the column collapses52+ * to `never`. Row and insert types read the view type and are unaffected.53+ */54+export type EncryptedStoredSql = "TEXT" | "BYTEA";5355 5456 /**5557 * The column type of every `s.encrypted*` constructor. Structurally a valid@@ -20,6 +20,12 @@
2020 * });2121 * ```2222 *23+ * Shared-field columns ({@link sharedEncryptedText}, {@link sharedEncryptedJson})24+ * declare only the sealed data shape here; the policy and key-lifecycle half —25+ * group templates, key bootstrap and rotation, member reconciliation — lives in26+ * `/lofi/access`, and pin remediation for changed peer keys is surfaced27+ * through the main `/lofi` entry.28+ *2329 * @module2430 */2531 import { schema } from "jazz-tools";@@ -150,6 +156,7 @@ export const s: SchemaDsl = {
150156 };151157 152158 export {159+ type AnyColumnBuilder,153160 plain,154161 type PlainColumn,155162 privateTable,@@ -164,34 +171,24 @@ export {
164171 type SharedFieldValue,165172 unwrapSharedField,166173 } from "./shared-encrypted.ts";167-export { SharedFieldError } from "./shared-crypto.ts";168-export {169- clearSharedFieldKeys,170- getSharedFieldKey,171- installSharedFieldKey,172- latestSharedFieldGeneration,173- sharedKeyScope,174- subscribeSharedKeyring,175-} from "./shared-keyring.ts";176174 export {177- clearSharedColumnRegistry,178- type SharedColumnConfig,179- sharedColumnConfigs,180-} from "./shared-registry.ts";175+ isSharedFieldError,176+ SharedFieldError,177+ type SharedFieldErrorCode,178+} from "./shared-crypto.ts";179+export { type SharedColumnConfig } from "./shared-registry.ts";181180 182181 export {183- clearEncryptedColumnKey,184- clearEncryptedColumnRegistry,185182 type EncryptedColumn,186183 EncryptedColumnError,187184 encryptedColumnsOf,188185 encryptedDate,189186 encryptedJson,190187 encryptedNumber,188+ type EncryptedStoredSql,191189 encryptedText,192190 isEncryptedColumn,193191 matchDecrypted,194- setEncryptedColumnKey,195192 } from "./encrypted.ts";196193 export {197194 defineNestedApp,@@ -201,33 +198,34 @@ export {
201198 NESTED_SEPARATOR,202199 type NestedApp,203200 nestedAppDeployTarget,201+ type NestedAppRoot,204202 nestedAppTables,205203 type NestedSchemaDefinition,206204 } from "./nested.ts";207205 export {208- clearEffectDeclarations,209206 effect,210207 type EffectContext,211208 type EffectHandlers,212209 type EffectRow,213210 type EffectUnit,211+ type EffectUnitOptions,214212 insert,215213 type InsertVerb,216214 log,217215 mutation,218- type MutationDescriptor,219216 type MutationOp,220217 type MutationOpKind,221218 type MutationOptions,222- type MutationRuntime,223219 type MutationVerb,224220 remove,225221 type RemoveVerb,226- resolveEffectUnit,227- setMutationRuntime,228222 update,229223 type UpdateVerb,230224 } from "./effects.ts";225+// Type-only, so the authoring-only module graph stays free of runtime code:226+// verb calls settle through the runtime write handle, and these names let227+// schema-graph modules annotate that contract without importing the runtime.228+export type { WriteHandle, WriteRejection, WriteStage } from "../runtime/write-handle.ts";231229 232230 // Store provisioning (./store.ts) is deliberately NOT re-exported here: the233231 // Jazz schema loader bundles the author's schema module graph — this facade@@ -236,16 +234,23 @@ export {
236234 237235 /**238236 * Schema-authoring types re-exported from the pinned Jazz 2 DSL for use in239- * application signatures. Row types for UI code come from `@nzip/lofi`240- * (`RowOf`), not from this module.237+ * application signatures: the app and table shapes, the table handle type238+ * (`TableProxy`, what a declared table is at runtime and what verb239+ * operations accept), and the permission types (`PolicyContext` for240+ * `definePermissions` callbacks, `CompiledPermissions` for the bundles they241+ * compile to and `mergeNestedPermissions` combines). Row types for UI code242+ * come from `@nzip/lofi` (`RowOf`), not from this module.241243 */242244 export type {243245 App,246+ CompiledPermissions,244247 DefinedTable,245248 InsertOf,249+ PolicyContext,246250 Schema,247251 SchemaDefinition,248252 TableDefinition,253+ TableProxy,249254 WhereOf,250255 } from "jazz-tools";251256 @@ -1,6 +1,5 @@
11 import { schema } from "jazz-tools";22 import {3- clearEncryptedColumnRegistry,43 defineNestedApp,54 defineNestedPermissions,65 flattenNestedSchema,@@ -8,6 +7,7 @@ import {
87 mergeNestedPermissions,98 s,109 } from "./mod.ts";10+import { clearEncryptedColumnRegistry } from "./encrypted.ts";1111 1212 // The facade re-exports, never reimplements: every curated member must be the1313 // pinned Jazz 2 original by identity, so semantics cannot drift. The define@@ -40,6 +40,19 @@ export type NestedApp<TDef extends NestedSchemaDefinition> = {
4040 [NS in keyof TDef & string]: App<Schema<TDef[NS]>>;4141 };4242 43+declare const nestedAppRootBrand: unique symbol;44+45+/**46+ * The opaque marker of a nested-app root. Only {@link defineNestedApp}47+ * produces values of this type, so APIs that require a root — store48+ * provisioning and the nested-table introspection helpers — state that49+ * precondition in their signatures instead of failing at runtime. The marker50+ * is type-level only; no property is added to the returned object.51+ */52+export type NestedAppRoot = {53+ readonly [nestedAppRootBrand]: true;54+};55+4356 const nestedAppMetaKey = Symbol.for("@nzip/lofi/nested-app");4457 const namespaceMetaKey = Symbol.for("@nzip/lofi/nested-namespace");4558 @@ -234,7 +247,7 @@ export function flattenNestedSchema(definition: NestedSchemaDefinition): SchemaD
234247 */235248 export function defineNestedApp<const TDef extends NestedSchemaDefinition>(236249 definition: TDef,237-): NestedApp<TDef> {250+): NestedApp<TDef> & NestedAppRoot {238251 const flat = flattenNestedSchema(definition);239252 registerEncryptedColumns(flat);240253 const sliceable = schema.defineSliceableApp(flat as never);@@ -271,7 +284,7 @@ export function defineNestedApp<const TDef extends NestedSchemaDefinition>(
271284 ),272285 } satisfies NestedAppMeta,273286 });274- return root as NestedApp<TDef>;287+ return root as NestedApp<TDef> & NestedAppRoot;275288 }276289 277290 /**@@ -337,24 +350,28 @@ export function mergeNestedPermissions(
337350 }338351 339352 /**340- * The flattened table-handle registry of a nested app, or `null` when the341- * value is not a nested app root. The runtime consumes this so nested tables353+ * The flattened table-handle registry of a nested app root from354+ * {@link defineNestedApp}. The runtime consumes this so nested tables342355 * participate in boot readiness and local-to-managed row migration; the343356 * handles are the same objects the namespaces expose, so store identity is344- * preserved.357+ * preserved. As runtime defense for callers that cast past the358+ * {@link NestedAppRoot} requirement, a value without the root marker returns359+ * `null`.345360 */346-export function nestedAppTables(app: unknown): readonly object[] | null {361+export function nestedAppTables(app: NestedAppRoot): readonly object[] | null {347362 if (!app || typeof app !== "object") return null;348363 const meta = (app as { [nestedAppMetaKey]?: NestedAppMeta })[nestedAppMetaKey];349364 return meta?.tables ?? null;350365 }351366 352367 /**353- * An app-like value over every table of a nested app, keyed by the mangled354- * global table names — the shape schema deploy tooling and the policy test355- * harness expect. Throws when the value is not a nested app root.368+ * An app-like value over every table of a nested app root from369+ * {@link defineNestedApp}, keyed by the mangled global table names — the370+ * shape schema deploy tooling and the policy test harness expect. As runtime371+ * defense for callers that cast past the {@link NestedAppRoot} requirement,372+ * a value without the root marker throws.356373 */357-export function nestedAppDeployTarget(app: unknown): object {374+export function nestedAppDeployTarget(app: NestedAppRoot): object {358375 const meta = app && typeof app === "object"359376 ? (app as { [nestedAppMetaKey]?: NestedAppMeta })[nestedAppMetaKey]360377 : undefined;@@ -7,6 +7,7 @@ import {
77 flattenNestedSchema,88 NESTED_SEPARATOR,99 nestedAppDeployTarget,10+ type NestedAppRoot,1011 nestedAppTables,1112 s,1213 } from "./mod.ts";@@ -71,9 +72,14 @@ Deno.test("nested table handles are constructed once and shared with the registr
7172 });7273 7374 Deno.test("nestedAppTables returns null for flat apps so the runtime keeps its one-level walk", () => {75+ // The casts model dynamic callers that bypass the NestedAppRoot brand; the76+ // runtime defense must still answer null instead of misdetecting.7477 const flat = s.defineApp({ tasks: s.table({ text: s.string() }) });75- assert(nestedAppTables(flat) === null, "flat app misdetected as nested");76- assert(nestedAppTables(undefined) === null, "undefined misdetected as nested");78+ assert(nestedAppTables(flat as unknown as NestedAppRoot) === null, "flat app misdetected");79+ assert(80+ nestedAppTables(undefined as unknown as NestedAppRoot) === null,81+ "undefined misdetected as nested",82+ );7783 });7884 7985 Deno.test("the deploy target exposes every table under its mangled global name", () => {@@ -83,7 +89,9 @@ Deno.test("the deploy target exposes every table under its mangled global name",
8389 }8490 assert("wasmSchema" in target, "deploy target is missing wasmSchema");8591 throws(86- () => nestedAppDeployTarget(root.taskapp),92+ // A namespace is not the root; the cast models a dynamic caller and the93+ // runtime defense must refuse it.94+ () => nestedAppDeployTarget(root.taskapp as unknown as NestedAppRoot),8795 "requires a root value",8896 "deploy target on a namespace",8997 );@@ -42,7 +42,14 @@ export type PlainColumn<T> = T & { readonly [plainColumnBrand]: true };
4242 4343 const PLAIN_COLUMN = Symbol("lofi.plainColumn");4444 45-type AnyBuilder = TypedColumnBuilder<SqlType, boolean, string | undefined, boolean, unknown>;45+/** Any pinned-Jazz column builder accepted as a {@link privateTable} column. */46+export type AnyColumnBuilder = TypedColumnBuilder<47+ SqlType,48+ boolean,49+ string | undefined,50+ boolean,51+ unknown52+>;4653 4754 /**4855 * Marks a column of a {@link privateTable} as deliberately plaintext —@@ -135,7 +142,7 @@ function sealColumn(labelPrefix: string, name: string, builder: unknown): unknow
135142 * or wrapped in { plain}; optionals, defaults, non-lww merge136143 * strategies, and transforms on sealed columns are configuration errors.137144 */138-export function privateTable<const TCols extends Record<string, AnyBuilder>>(145+export function privateTable<const TCols extends Record<string, AnyColumnBuilder>>(139146 labelPrefix: string,140147 columns: TCols,141148 ): DefinedTable<@@ -1,9 +1,10 @@
11 // privateTable contract: every column seals by default with a derived label,22 // refs and plain-marked columns stay plaintext, bytes stay plaintext with a33 // report, and configurations the seal boundary cannot honor are refused.4-import { clearEncryptedColumnRegistry, encryptedColumnsOf, s } from "./mod.ts";4+import { encryptedColumnsOf, s } from "./mod.ts";55 import {66 clearEncryptedColumnKey,7+ clearEncryptedColumnRegistry,78 encryptedColumnLabelOf,89 setEncryptedColumnKey,910 } from "./encrypted.ts";@@ -27,37 +27,45 @@ import { padPayload, unpadPayload } from "./padding.ts";
2727 const textEncoder = new TextEncoder();2828 const textDecoder = new TextDecoder();2929 30+/**31+ * Stable categories for shared-field failures. `identity-missing` — a shared32+ * column was touched before the runtime installed the account's x2551933+ * identity. `key-pending` — no field key is installed for the value's scope34+ * and generation yet; a normal state for a freshly added member.35+ * `unscoped-write` — a write reached the column transform without the36+ * mutation layer sealing it first. `corrupt` — a sealed value failed37+ * authentication. `peer-key-changed` — a peer's published public key no38+ * longer matches its pinned fingerprint. `wrap-invalid` — a wrapped key39+ * failed authentication or shape checks. `no-directory-entry` — a wrap was40+ * requested for an account that has not published a public key.41+ */42+export type SharedFieldErrorCode =43+ | "identity-missing"44+ | "key-pending"45+ | "unscoped-write"46+ | "corrupt"47+ | "peer-key-changed"48+ | "wrap-invalid"49+ | "no-directory-entry";50+3051 /** Raised when shared-field material cannot be derived, wrapped, or opened. */3152 export class SharedFieldError extends Error {3253 /** Stable error class name for diagnostics and error boundaries. */3354 override readonly name = "SharedFieldError";34- /**35- * `identity-missing` — a shared column was touched before the runtime36- * installed the account's x25519 identity. `key-pending` — no field key is37- * installed for the value's scope and generation yet; a normal state for a38- * freshly added member. `unscoped-write` — a write reached the column39- * transform without the mutation layer sealing it first. `corrupt` — a40- * sealed value failed authentication. `peer-key-changed` — a peer's41- * published public key no longer matches its pinned fingerprint.42- * `wrap-invalid` — a wrapped key failed authentication or shape checks.43- * `no-directory-entry` — a wrap was requested for an account that has not44- * published a public key.45- */46- readonly code:47- | "identity-missing"48- | "key-pending"49- | "unscoped-write"50- | "corrupt"51- | "peer-key-changed"52- | "wrap-invalid"53- | "no-directory-entry";55+ /** The failure's stable category; see {@link SharedFieldErrorCode}. */56+ readonly code: SharedFieldErrorCode;5457 /** Creates a shared-field failure with a stable category. */55- constructor(code: SharedFieldError["code"], message: string) {58+ constructor(code: SharedFieldErrorCode, message: string) {5659 super(message);5760 this.code = code;5861 }5962 }6063 64+/** True when an error came from the lofi shared-field surface. */65+export function isSharedFieldError(error: unknown): error is SharedFieldError {66+ return error instanceof SharedFieldError;67+}68+6169 function toBase64Url(bytes: Uint8Array): string {6270 let binary = "";6371 for (const byte of bytes) binary += String.fromCharCode(byte);@@ -21,7 +21,14 @@
2121 * 2222 */2323 import type { CompiledPermissions } from "jazz-tools";24-import { NESTED_SEPARATOR, nestedAppDeployTarget, nestedAppTables } from "./nested.ts";24+import {25+ NESTED_SEPARATOR,26+ nestedAppDeployTarget,27+ type NestedAppRoot,28+ nestedAppTables,29+} from "./nested.ts";30+31+export type { NestedAppRoot } from "./nested.ts";2532 2633 /** The store to provision: where it is, which app it hosts, and the admin opt-in. */2734 export type StoreTarget = {@@ -39,7 +46,11 @@ export type StoreTarget = {
3946 adminSecret?: string;4047 };4148 42-/** How a store's deployed schema relates to this app's slice. */49+/**50+ * How a store's deployed schema relates to this app's slice. State literals51+ * are snake_case because they relay the sync node's status vocabulary52+ * verbatim.53+ */4354 export type StoreStatus =4455 /** Nothing is deployed; writes against the store would hang. Remedy: create. */4556 | { state: "no_schema" }@@ -68,11 +79,19 @@ export type StoreProvisionResult = {
6879 export class StoreProvisionError extends Error {6980 /** Stable error class name for diagnostics and error boundaries. */7081 override readonly name = "StoreProvisionError";71- /** Stable category for provisioning flows. */72- readonly code: "http" | "schema-drift" | "not-nested" | "outside-namespace";82+ /**83+ * Stable category for provisioning flows. `http` — the store answered a84+ * request with a failure or unparseable body. `schema-drift` — the store's85+ * copy of this app's namespaces differs from the declaration; surfaced,86+ * never auto-repaired. `not-nested` — the supplied app value is not a87+ * nested-app root. `outside-namespace` — the permissions bundle names a88+ * table outside this app's declared namespaces. `policy-literal` — a policy89+ * condition carries a literal value the wire encoding does not support.90+ */91+ readonly code: "http" | "schema-drift" | "not-nested" | "outside-namespace" | "policy-literal";7392 /** Creates a provisioning rejection with a stable category. */7493 constructor(75- code: "http" | "schema-drift" | "not-nested" | "outside-namespace",94+ code: StoreProvisionError["code"],7695 message: string,7796 ) {7897 super(message);@@ -121,14 +140,17 @@ function encodeLiteral(value: unknown): unknown {
121140 if (value instanceof Date) return { type: "Timestamp", value: value.getTime() };122141 if (value instanceof Uint8Array) {123142 throw new StoreProvisionError(124- "outside-namespace",143+ "policy-literal",125144 "bytes literals in policy conditions are not supported by store provisioning",126145 );127146 }128147 if (typeof value === "boolean") return { type: "Boolean", value };129148 if (typeof value === "number") {130149 if (!Number.isFinite(value)) {131- throw new StoreProvisionError("http", "policy literals only support finite numbers");150+ throw new StoreProvisionError(151+ "policy-literal",152+ "policy literals only support finite numbers",153+ );132154 }133155 if (!Number.isInteger(value)) return { type: "Double", value };134156 if (value >= -2147483648 && value <= 2147483647) return { type: "Integer", value };@@ -141,7 +163,7 @@ function encodeLiteral(value: unknown): unknown {
141163 if (Array.isArray(value)) return { type: "Array", value: value.map(encodeLiteral) };142164 if (isRecord(value) && typeof value.type === "string") return value;143165 throw new StoreProvisionError(144- "http",166+ "policy-literal",145167 "policy literals must be scalars, arrays, Date, or tagged values",146168 );147169 }@@ -337,7 +359,7 @@ type AppSlice = {
337359 namespaces: ReadonlySet<string>;338360 };339361 340-function appSlice(app: unknown): AppSlice {362+function appSlice(app: NestedAppRoot): AppSlice {341363 if (nestedAppTables(app) === null) {342364 throw new StoreProvisionError(343365 "not-nested",@@ -402,11 +424,16 @@ function classify(slice: AppSlice, artifacts: StoreArtifacts): StoreStatus {
402424 403425 /**404426 * Classifies the store's deployed schema against this app's slice without405- * changing anything. `no_schema` matters beyond provisioning: against an406- * empty store, client writes hang rather than fail, so callers should reach407- * this state before ever attaching sync to a fresh store.427+ * changing anything. `app` must be a nested-app root from428+ * `s.defineNestedApp` — the namespace is what scopes an app's view of a429+ * shared store. `no_schema` matters beyond provisioning: against an empty430+ * store, client writes hang rather than fail, so callers should reach this431+ * state before ever attaching sync to a fresh store.408432 */409-export async function readStoreStatus(app: unknown, target: StoreTarget): Promise<StoreStatus> {433+export async function readStoreStatus(434+ app: NestedAppRoot,435+ target: StoreTarget,436+): Promise<StoreStatus> {410437 return classify(appSlice(app), await fetchStore(target));411438 }412439 @@ -462,6 +489,16 @@ async function ensureSliceConnectivity(
462489 });463490 }464491 492+/** What {@link provisionStore} deploys, and where. */493+export type ProvisionStoreOptions = {494+ /** The nested-app root (from `s.defineNestedApp`) whose slice is provisioned. */495+ app: NestedAppRoot;496+ /** The app's compiled permissions bundle for its own namespaces. */497+ permissions: CompiledPermissions;498+ /** The store to provision, with its admin opt-in. */499+ target: StoreTarget;500+};501+465502 /**466503 * Creates or updates this app's slice in the store. The stored head schema is467504 * fetched verbatim and only extended: missing tables of this app's namespaces@@ -474,11 +511,9 @@ async function ensureSliceConnectivity(
474511 * Drift is never repaired: a store whose copy of this app's namespaces475512 * differs from the declaration throws with the differing tables.476513 */477-export async function provisionStore(options: {478- app: unknown;479- permissions: CompiledPermissions;480- target: StoreTarget;481-}): Promise<StoreProvisionResult> {514+export async function provisionStore(515+ options: ProvisionStoreOptions,516+): Promise<StoreProvisionResult> {482517 const slice = appSlice(options.app);483518 assertOwnNamespacePermissions(slice, options.permissions);484519 const artifacts = await fetchStore(options.target);@@ -552,7 +587,10 @@ export async function provisionStore(options: {
552587 return { status: "updated", headHash: published.hash };553588 }554589 555-/** The result of the ticket-scoped store-status preflight. */590+/**591+ * The result of the ticket-scoped store-status preflight. State literals are592+ * snake_case because they relay the sync node's status vocabulary verbatim.593+ */556594 export type TicketStoreStatus =557595 /** A schema is deployed; `headHash` is the newest stored schema hash. */558596 | { state: "deployed"; appId: string; headHash: string }@@ -5,7 +5,7 @@
55 import { createDb } from "jazz-tools";66 import { startLocalJazzServer } from "jazz-tools/testing";77 import { assert } from "../runtime/test-assert.ts";8-import { nestedAppDeployTarget, s } from "./mod.ts";8+import { nestedAppDeployTarget, type NestedAppRoot, s } from "./mod.ts";99 import { provisionStore, readStoreStatus, StoreProvisionError, type StoreTarget } from "./store.ts";1010 1111 // The first app on the store.@@ -194,11 +194,13 @@ Deno.test("provisioning is confined to the app's own namespaces", async () => {
194194 adminSecret: "unused",195195 };196196 197- // A flat app has no namespace and therefore no tenant boundary.197+ // A flat app has no namespace and therefore no tenant boundary. The cast198+ // models a dynamic caller bypassing the NestedAppRoot brand; the runtime199+ // defense must still refuse it.198200 const flat = s.defineApp({ tasks: s.table({ text: s.string() }) });199201 let flatError = "";200202 try {201- await readStoreStatus(flat, target);203+ await readStoreStatus(flat as unknown as NestedAppRoot, target);202204 } catch (error) {203205 flatError = error instanceof StoreProvisionError ? error.code : String(error);204206 }@@ -1,3 +1,14 @@
1+/**2+ * Browser-availability failure handling: {@link BrowserUnavailableError} with3+ * actionable install guidance, and the launch-error classifier that raises it.4+ *5+ * @module6+ */7+8+// The one place the testing helpers spell the Chromium install command; the9+// version matches the `playwright` alias in the workspace import map.10+const PLAYWRIGHT_INSTALL_COMMAND = "deno run -A npm:playwright@1.61.1 install chromium";11+112 /** Thrown when Playwright's Chromium browser is not installed, with install guidance. */213 export class BrowserUnavailableError extends Error {314 /** Always `"BrowserUnavailableError"`. */@@ -9,7 +20,7 @@ export class BrowserUnavailableError extends Error {
920 */1021 constructor(options?: ErrorOptions) {1122 super(12- "Playwright Chromium is not installed. Run `deno run -A npm:playwright@1.61.1 install chromium` and retry.",23+ `Playwright Chromium is not installed. Run \`${PLAYWRIGHT_INSTALL_COMMAND}\` and retry.`,1324 options,1425 );1526 }@@ -68,18 +68,47 @@ export interface ConcurrentOfflineScenario<
6868 readonly failureLabel?: string;6969 }7070 71+/** The lifecycle stages the convergence runner moves through, in order. */72+export type ConvergenceStage =73+ | "readiness"74+ | "going offline"75+ | "concurrent edits"76+ | "local persistence"77+ | "pending-work hook"78+ | "reconnection"79+ | "convergence";80+81+/** Options for {@link ConvergenceScenarioError}, extending `ErrorOptions` with the capture outcome. */82+export interface ConvergenceScenarioErrorOptions extends ErrorOptions {83+ /** The failure raised while capturing artifacts, when capture itself failed. */84+ captureError?: unknown;85+}86+7187 /** Thrown when a convergence scenario fails, naming the stage that failed via {@link stage}. */7288 export class ConvergenceScenarioError extends Error {7389 /** Always `"ConvergenceScenarioError"`. */7490 override readonly name = "ConvergenceScenarioError";7591 92+ /**93+ * The failure raised by the fixture's `captureFailure` while this error was94+ * being produced — e.g. a `snapshot` callback that violates the value-free95+ * rule — or `undefined` when the artifacts were captured. The scenario96+ * failure itself stays in {@link Error.cause}.97+ */98+ readonly captureError?: unknown;99+76100 /**77101 * Builds the error, recording which lifecycle stage was in progress.78102 * @param stage the lifecycle stage that was in progress when the scenario failed79- * @param options standard error options, e.g. a `cause` from the failing hook103+ * @param options standard error options, e.g. a `cause` from the failing hook,104+ * plus the artifact-capture failure when capture also failed80105 */81- constructor(readonly stage: string, options?: ErrorOptions) {82- super(`Concurrent offline convergence failed during ${stage}`, options);106+ constructor(readonly stage: ConvergenceStage, options?: ConvergenceScenarioErrorOptions) {107+ const captureNote = options?.captureError !== undefined108+ ? "; failure-artifact capture also failed (see captureError)"109+ : "";110+ super(`Concurrent offline convergence failed during ${stage}${captureNote}`, options);111+ this.captureError = options?.captureError;83112 }84113 }85114 @@ -113,7 +142,7 @@ export async function runConcurrentOfflineConvergence<Edit, Client extends Offli
113142 controller.signal.removeEventListener("abort", onAbort);114143 }115144 };116- let stage = "readiness";145+ let stage: ConvergenceStage = "readiness";117146 try {118147 await withinDeadline(119148 Promise.all(fixture.clients.map((client) => scenario.ready(client, controller.signal))),@@ -143,11 +172,18 @@ export async function runConcurrentOfflineConvergence<Edit, Client extends Offli
143172 stage = "convergence";144173 await withinDeadline(scenario.converged(fixture, controller.signal));145174 } catch (error) {146- await fixture.captureFailure(147- scenario.failureLabel ?? `offline-convergence-${stage}`,148- scenario.snapshot,149- ).catch(() => undefined);150- throw new ConvergenceScenarioError(stage, { cause: error });175+ // A capture failure never masks the scenario failure: the scenario error176+ // stays the thrown error, and the capture failure rides along on it.177+ let captureError: unknown;178+ try {179+ await fixture.captureFailure(180+ scenario.failureLabel ?? `offline-convergence-${stage}`,181+ scenario.snapshot,182+ );183+ } catch (failure) {184+ captureError = failure ?? new Error("failure-artifact capture rejected without a reason");185+ }186+ throw new ConvergenceScenarioError(stage, { cause: error, captureError });151187 } finally {152188 clearTimeout(timeout);153189 await Promise.allSettled(@@ -109,6 +109,38 @@ Deno.test("offline convergence captures the failing stage and restores connectiv
109109 );110110 });111111 112+Deno.test("offline convergence surfaces a failed artifact capture on the scenario error", async () => {113+ class CaptureFailingFixture extends FakeFixture {114+ override captureFailure(): Promise<void> {115+ return Promise.reject(new TypeError("state contains a string value"));116+ }117+ }118+ const fixture = new CaptureFailingFixture();119+ const editFailure = new Error("edit failed");120+ try {121+ await runConcurrentOfflineConvergence(fixture, {122+ edits: [1, 2],123+ ready: () => Promise.resolve(),124+ apply: () => Promise.reject(editFailure),125+ locallyApplied: () => Promise.resolve(),126+ converged: () => Promise.resolve(),127+ });128+ throw new Error("expected scenario failure");129+ } catch (error) {130+ assert(error instanceof ConvergenceScenarioError, `unexpected error: ${error}`);131+ assert(error.cause === editFailure, "scenario failure was displaced from the cause");132+ assert(133+ error.captureError instanceof TypeError &&134+ error.captureError.message.includes("string value"),135+ `capture failure was not surfaced: ${error.captureError}`,136+ );137+ assert(138+ error.message.includes("failure-artifact capture also failed"),139+ `message hides the capture failure: ${error.message}`,140+ );141+ }142+});143+112144 Deno.test("offline convergence bounds a never-settling app predicate", async () => {113145 const fixture = new FakeFixture();114146 try {@@ -7,7 +7,7 @@ import {
77 type Locator,88 type Page,99 type Request,10-} from "npm:playwright@1.61.1";10+} from "playwright";1111 import { BrowserUnavailableError, rethrowBrowserLaunchError } from "./browser_error.ts";1212 import {1313 artifactName,@@ -17,7 +17,12 @@ import {
1717 } from "./safety.ts";1818 import { sanitizeTraceArchive } from "./trace.ts";1919 20-type MemoryStorageState = Awaited<ReturnType<BrowserContext["storageState"]>>;20+/**21+ * Playwright storage state held only in memory — cookies plus per-origin22+ * `localStorage` and IndexedDB — used to clone a prepared identity into a23+ * fresh browser context without an on-disk state file.24+ */25+export type MemoryStorageState = Awaited<ReturnType<BrowserContext["storageState"]>>;2126 /**2227 * Playwright context options with `storageState` forbidden, so identity never2328 * leaves memory via an on-disk state file.@@ -4,6 +4,18 @@
44 * convergence, app-owned readiness waits, value-free failure artifacts, and a55 * CDP virtual authenticator for headless WebAuthn flows.66 *7+ * The supported Playwright release is 1.61 — the `playwright` alias in the8+ * workspace import map. Install a matching Chromium with9+ * `deno run -A npm:playwright@1.61.1 install chromium`.10+ *11+ * Alongside the browser fixtures, this entry re-exports the deterministic12+ * seams of the schema and runtime registries — key installation, registry13+ * clears, and fake runtime installation — so a test can isolate schema14+ * declarations, encrypted and shared-field key state, write handles, and15+ * verb dispatch without booting a runtime. Production code never calls these:16+ * the runtime owns each seam at boot, and calling one in an application17+ * corrupts the state the runtime maintains.18+ *719 * 820 */921 @@ -16,6 +28,7 @@ export {
1628 type FailureArtifactOptions,1729 type FailureArtifacts,1830 type IdentityOptions,31+ type MemoryStorageState,1932 type SafeContextOptions,2033 TwoClientFixture,2134 type TwoClientFixtureOptions,@@ -23,15 +36,55 @@ export {
2336 export {2437 type ConcurrentOfflineScenario,2538 ConvergenceScenarioError,39+ type ConvergenceScenarioErrorOptions,40+ type ConvergenceStage,2641 type OfflineTestClient,2742 type OfflineTestFixture,2843 runConcurrentOfflineConvergence,2944 } from "./convergence.ts";3045 export { ReadinessError, type ReadinessOptions, waitForReady } from "./readiness.ts";31-export type { ValueFreeState } from "./safety.ts";46+export { assertValueFreeState, redactDiagnosticText, type ValueFreeState } from "./safety.ts";3247 export {3348 type VirtualAuthenticatorCredential,3449 type VirtualAuthenticatorHandle,3550 type VirtualAuthenticatorOptions,3651 withVirtualAuthenticator,3752 } from "./webauthn.ts";53+54+// Deterministic seams over the schema-side registries.55+export {56+ clearEffectDeclarations,57+ type EffectContext,58+ type MutationDescriptor,59+ type MutationRuntime,60+ setMutationRuntime,61+} from "../schema/effects.ts";62+export {63+ clearEncryptedColumnKey,64+ clearEncryptedColumnRegistry,65+ setEncryptedColumnKey,66+} from "../schema/encrypted.ts";67+export {68+ clearSharedFieldKeys,69+ getSharedFieldKey,70+ installSharedFieldKey,71+ latestSharedFieldGeneration,72+ sharedKeyScope,73+ subscribeSharedKeyring,74+} from "../schema/shared-keyring.ts";75+export {76+ clearSharedColumnRegistry,77+ type SharedColumnConfig,78+ sharedColumnConfigs,79+} from "../schema/shared-registry.ts";80+81+// Deterministic seams over the runtime.82+export { clearFingerprintPins } from "../runtime/shared-field-keys.ts";83+export {84+ createWriteHandle,85+ type WriteHandle,86+ type WriteHandleController,87+ type WriteRejection,88+ type WriteStage,89+} from "../runtime/write-handle.ts";90+export { type DevicePublicKey, memoryPopKeyStore, type PopKeyStore } from "../runtime/pop.ts";@@ -1,4 +1,4 @@
1-import type { Page } from "npm:playwright@1.61.1";1+import type { Page } from "playwright";22 33 /** Options controlling {@link waitForReady}'s description, timeout, and polling. */44 export interface ReadinessOptions {@@ -29,31 +29,65 @@ export class ReadinessError extends Error {
2929 }3030 }3131 32+function isReadinessOptions(value: unknown): value is ReadinessOptions | undefined {33+ if (value === undefined) return true;34+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;35+ return Object.keys(value).every(36+ (key) => key === "description" || key === "timeoutMs" || key === "polling",37+ );38+}39+3240 /**33- * Wait for an app-owned browser predicate. The predicate runs in the page and34- * Playwright owns the timeout/polling, so tests do not need arbitrary sleeps.41+ * Wait for an app-owned browser predicate that takes no argument. The predicate42+ * runs in the page and Playwright owns the timeout/polling, so tests do not43+ * need arbitrary sleeps.3544 *3645 * @example3746 * ```ts3847 * import { waitForReady } from "@nzip/lofi/testing";3948 *40- * await waitForReady(client.page, () => document.querySelector(".task-list") !== null, undefined, {49+ * await waitForReady(client.page, () => document.querySelector(".task-list") !== null, {4150 * description: "task list rendered",4251 * });4352 * ```4453 *4554 * @param page The Playwright page to poll.4655 * @param predicate An app-owned readiness check evaluated inside the page.56+ * @param options Optional description, timeout, and polling configuration.57+ * @returns Resolves when the predicate becomes true; rejects with {@link ReadinessError} on timeout.58+ */59+export function waitForReady(60+ page: Page,61+ predicate: () => boolean | Promise<boolean>,62+ options?: ReadinessOptions,63+): Promise<void>;64+/**65+ * Wait for an app-owned browser predicate, passing it a serialized argument.66+ *67+ * @param page The Playwright page to poll.68+ * @param predicate An app-owned readiness check evaluated inside the page.4769 * @param argument A serializable value passed to the predicate in the page.4870 * @param options Optional description, timeout, and polling configuration.4971 * @returns Resolves when the predicate becomes true; rejects with {@link ReadinessError} on timeout.5072 */51-export async function waitForReady<Argument>(73+export function waitForReady<Argument>(5274 page: Page,5375 predicate: (argument: Argument) => boolean | Promise<boolean>,5476 argument: Argument,55- options: ReadinessOptions = {},77+ options?: ReadinessOptions,78+): Promise<void>;79+export async function waitForReady<Argument>(80+ page: Page,81+ predicate: (argument: Argument) => boolean | Promise<boolean>,82+ argumentOrOptions?: Argument | ReadinessOptions,83+ maybeOptions?: ReadinessOptions,5684 ): Promise<void> {85+ // A three-argument call carries options in third position only for the86+ // zero-parameter overload; a predicate argument otherwise rides there.87+ const optionsInThird = maybeOptions === undefined && predicate.length === 0 &&88+ isReadinessOptions(argumentOrOptions);89+ const options = (optionsInThird ? argumentOrOptions as ReadinessOptions : maybeOptions) ?? {};90+ const argument = optionsInThird ? undefined : argumentOrOptions;5791 const description = options.description ?? "application readiness predicate";5892 try {5993 // Playwright internally maps serializable values to its Unboxed<T> type.@@ -1,4 +1,4 @@
1-import type { Page } from "npm:playwright@1.61.1";1+import type { Page } from "playwright";22 import { ReadinessError, waitForReady } from "./readiness.ts";33 44 function assert(condition: unknown, message: string): asserts condition {@@ -29,6 +29,32 @@ Deno.test("waitForReady delegates polling and timeout to Playwright", async () =
2929 );3030 });3131 32+Deno.test("waitForReady accepts options in third position for argument-free predicates", async () => {33+ let call: unknown[] | undefined;34+ const page = {35+ waitForFunction(...args: unknown[]) {36+ call = args;37+ return Promise.resolve({});38+ },39+ } as unknown as Page;40+ const predicate = () => document.body.dataset.ready === "yes";41+42+ await waitForReady(page, predicate, { timeoutMs: 42, polling: "raf" });43+44+ assert(call?.[0] === predicate, "predicate was replaced");45+ assert(call?.[1] === undefined, "an argument was invented for an argument-free predicate");46+ assert(47+ JSON.stringify(call?.[2]) === JSON.stringify({ polling: "raf", timeout: 42 }),48+ `unexpected Playwright options: ${JSON.stringify(call?.[2])}`,49+ );50+51+ await waitForReady(page, predicate);52+ assert(53+ JSON.stringify(call?.[2]) === JSON.stringify({ polling: "raf", timeout: 10_000 }),54+ `defaults were not applied without options: ${JSON.stringify(call?.[2])}`,55+ );56+});57+3258 Deno.test("waitForReady adds the app condition to timeout failures", async () => {3359 const cause = new Error("playwright timeout");3460 const page = {@@ -38,11 +64,20 @@ Deno.test("waitForReady adds the app condition to timeout failures", async () =>
3864 } as unknown as Page;3965 4066 try {41- await waitForReady(page, () => false, undefined, { description: "sync indicator" });67+ await waitForReady(page, () => false, { description: "sync indicator" });4268 throw new Error("expected readiness to fail");4369 } catch (error) {4470 assert(error instanceof ReadinessError, `unexpected error: ${error}`);4571 assert(error.message.includes("sync indicator"), "condition was missing from the error");4672 assert(error.cause === cause, "Playwright failure was not retained as the cause");4773 }74+75+ // The four-argument shape with an explicit undefined argument stays valid.76+ try {77+ await waitForReady(page, () => false, undefined, { description: "sync indicator" });78+ throw new Error("expected readiness to fail");79+ } catch (error) {80+ assert(error instanceof ReadinessError, `unexpected error: ${error}`);81+ assert(error.message.includes("sync indicator"), "condition was missing from the error");82+ }4883 });@@ -1,3 +1,12 @@
1+/**2+ * Value-free failure-artifact safety: the {@link ValueFreeState} shape rule for3+ * state snapshots, its {@link assertValueFreeState} validator, and the4+ * {@link redactDiagnosticText} credential redaction applied to retained5+ * diagnostics.6+ *7+ * @module8+ */9+110 /**211 * A JSON-like value restricted to booleans, finite numbers, arrays, and plain312 * objects. Strings are excluded so state snapshots record shape and counts but@@ -30,7 +39,16 @@ function safeUrl(value: string): string {
3039 }3140 }3241 33-/** Redacts common credential forms before diagnostics are retained in memory. */42+/**43+ * Redact common credential forms from diagnostic text: URL userinfo, query,44+ * and fragment components; JSON and `key=value` secret assignments; bearer45+ * tokens; and every literal in `secretValues`. The fixtures apply this to all46+ * retained diagnostics; apply it likewise to any custom capture path.47+ *48+ * @param value The diagnostic text to redact.49+ * @param secretValues Literal credential values to remove wherever they appear.50+ * @returns The text with credential forms replaced by `[redacted]` markers.51+ */3452 export function redactDiagnosticText(3553 value: string,3654 secretValues: readonly string[] = [],@@ -47,8 +65,15 @@ export function redactDiagnosticText(
4765 }4866 4967 /**50- * State artifacts intentionally reject strings and non-finite numbers. Callers51- * can record shape, counts and booleans, but not user values or credentials.68+ * Assert that `value` satisfies the value-free rule: strings and non-finite69+ * numbers are rejected, so state artifacts record shape, counts, and booleans70+ * but never user values or credentials. Snapshot callbacks passed to the71+ * fixtures are validated with this at capture time; call it directly to72+ * pre-validate a `snapshot` callback's output in a fast test.73+ *74+ * @param value The candidate snapshot value.75+ * @param path The label used for the offending location in error messages.76+ * @throws {TypeError} When `value` contains a string or an unsupported value.5277 */5378 export function assertValueFreeState(5479 value: unknown,@@ -1,3 +1,11 @@
1+/**2+ * Playwright trace-archive sanitization: a minimal ZIP reader/writer plus the3+ * pass that drops private resource entries and redacts the retained trace4+ * metadata before an archive is kept as a failure artifact.5+ *6+ * @module7+ */8+19 const LOCAL_FILE = 0x04034b50;210 const CENTRAL_FILE = 0x02014b50;311 const END_OF_CENTRAL_DIRECTORY = 0x06054b50;@@ -0,0 +1,21 @@
1+/**2+ * The `dist/lofi-build.json` contract: the build identity `deno task build`3+ * writes next to the static output and `deno task preview` and the PWA4+ * validation read back.5+ *6+ * @module7+ */8+9+/** The build identity recorded in `dist/lofi-build.json`. */10+export interface LofiBuildInfo {11+ /** The `@nzip/lofi` version that produced the build. */12+ lofiVersion: string;13+ /** The source fingerprint of the build, matching the service worker revision. */14+ sourceHash: string;15+ /** The deployment base path the output was built for. */16+ basePath: string;17+ /** ISO 8601 timestamp of when the build was written. */18+ builtAt: string;19+ /** The site-wide Content-Security-Policy union, present when built pages carry CSP meta tags. */20+ csp?: string;21+}@@ -18,11 +18,13 @@ import {
1818 resolveAppWasmSchema,1919 type SchemaCompatManifest,2020 } from "../schema/compat.ts";21-import { nestedAppTables } from "../schema/nested.ts";21+import { type NestedAppRoot, nestedAppTables } from "../schema/nested.ts";2222 2323 function isAppValue(value: unknown): boolean {2424 if (!value || typeof value !== "object") return false;25- if (nestedAppTables(value) !== null) return true;25+ // The probe inspects arbitrary module exports; nestedAppTables answers null26+ // for values that lack the nested-app root marker.27+ if (nestedAppTables(value as NestedAppRoot) !== null) return true;2628 const wasmSchema = (value as { wasmSchema?: unknown }).wasmSchema;2729 return Boolean(wasmSchema && typeof wasmSchema === "object");2830 }@@ -483,7 +483,7 @@ type PageGroup = {
483483 484484 const pageOrder = NODE_MODE485485 ? ["node", "testing"]486- : ["runtime", "astro", "access", "preact", "testing", "cli"];486+ : ["runtime", "schema", "schema-store", "astro", "access", "preact", "testing", "cli"];487487 const groups = new Map<string, PageGroup>();488488 for (const entry of activeEntrypoints) {489489 const group = groups.get(entry.page) ?? {@@ -17,6 +17,13 @@ export type Entrypoint = {
1717 1818 export const entrypoints: readonly Entrypoint[] = [1919 { jsrName: ".", file: "package/runtime/mod.ts", page: "runtime", title: "Runtime" },20+ { jsrName: "./schema", file: "package/schema/mod.ts", page: "schema", title: "Schema" },21+ {22+ jsrName: "./schema/store",23+ file: "package/schema/store.ts",24+ page: "schema-store",25+ title: "Store provisioning",26+ },2027 { jsrName: "./astro", file: "package/astro/mod.ts", page: "astro", title: "Astro integration" },2128 { jsrName: "./access", file: "package/access/mod.ts", page: "access", title: "Access" },2229 { jsrName: "./preact", file: "package/preact/mod.ts", page: "preact", title: "Preact" },@@ -0,0 +1,222 @@
1+/**2+ * Entry type closure lint. For every JSR entrypoint in the deno.json export3+ * map, every locally-declared type referenced in an exported symbol's public4+ * signature must itself be exported from that same entrypoint. A consumer who5+ * imports `RuntimeDiagnostics` from `.` must be able to name the types inside6+ * it (e.g. `SharedFieldAlert`) from `.` as well, not hunt through submodules.7+ *8+ * `deno doc --lint` only flags module-private references; a type exported from9+ * its declaring submodule but not re-exported from the entry passes that check10+ * while still leaving a hole in the entry's surface. This lint closes it.11+ *12+ * References that resolve outside `package/` (DOM lib, TypeScript built-ins,13+ * npm dependencies) and generic type parameters are ignored.14+ *15+ * @module16+ */17+18+type DocLocation = { filename: string; line: number };19+20+type TypeRefResolution =21+ | { kind: "typeParam" }22+ | { kind: "local" }23+ | { kind: "import"; specifier: string; name: string };24+25+type DocDeclaration = {26+ location: DocLocation;27+ declarationKind: string;28+ kind: string;29+};30+31+type DocSymbol = { name: string; declarations: DocDeclaration[] };32+33+type DocOutput = { nodes: Record<string, { symbols?: DocSymbol[] }> };34+35+/** A type name referenced somewhere inside one exported symbol's signature. */36+type TypeReference = {37+ /** Exported symbol whose signature contains the reference. */38+ symbol: string;39+ /** Referenced type name as written (root identifier). */40+ name: string;41+ /** Module that contains the referencing declaration. */42+ declFile: string;43+ resolution: TypeRefResolution | undefined;44+ /**45+ * References without resolution metadata (`extends` clauses, `typeof`46+ * queries) only count when the name is declared in `declFile` itself;47+ * otherwise they are ambient or imported-external and are skipped.48+ */49+ requireLocalDecl: boolean;50+};51+52+/**53+ * Suppressions a human has judged to be false positives. Keys are54+ * `"<entry> <type-name>"`; values explain why the entry is exempt.55+ */56+const allowlist: ReadonlyMap<string, string> = new Map([]);57+58+const root = `${Deno.cwd()}/`;59+const rootUrl = new URL(`file://${root}`);60+const packagePrefix = new URL("package/", rootUrl).href;61+62+async function runDoc(args: string[]): Promise<DocOutput> {63+ const command = new Deno.Command(Deno.execPath(), {64+ args: ["doc", "--json", ...args],65+ stdout: "piped",66+ stderr: "piped",67+ });68+ const result = await command.output();69+ if (!result.success) {70+ console.error(new TextDecoder().decode(result.stderr));71+ console.error(`deno doc --json failed for: ${args.join(" ")}`);72+ Deno.exit(1);73+ }74+ return JSON.parse(new TextDecoder().decode(result.stdout));75+}76+77+/**78+ * Walks a documentation node collecting referenced type names: `typeRef`79+ * nodes (unions, tuples, mapped, conditional, indexed-access, and function80+ * types all bottom out here), `typeof` queries, and string-typed class81+ * `extends` clauses. Skips `private` class members and JSDoc subtrees, which82+ * are not part of the public signature.83+ */84+function collectReferences(85+ value: unknown,86+ symbol: string,87+ declFile: string,88+ out: TypeReference[],89+): void {90+ if (Array.isArray(value)) {91+ for (const item of value) collectReferences(item, symbol, declFile, out);92+ return;93+ }94+ if (value === null || typeof value !== "object") return;95+ const node = value as Record<string, unknown>;96+ if (node.accessibility === "private") return;97+ if (node.kind === "typeRef") {98+ const ref = node.value as { typeName?: string; resolution?: TypeRefResolution } | undefined;99+ if (ref?.typeName) {100+ out.push({101+ symbol,102+ name: ref.typeName.split(".")[0],103+ declFile,104+ resolution: ref.resolution,105+ requireLocalDecl: false,106+ });107+ }108+ } else if (node.kind === "typeQuery" && typeof node.value === "string") {109+ out.push({110+ symbol,111+ name: node.value.split(".")[0],112+ declFile,113+ resolution: undefined,114+ requireLocalDecl: true,115+ });116+ }117+ if (typeof node.extends === "string") {118+ // Class defs carry `extends` as a bare superclass name (interface119+ // `extends` is an array of types and recurses normally).120+ out.push({121+ symbol,122+ name: node.extends,123+ declFile,124+ resolution: undefined,125+ requireLocalDecl: true,126+ });127+ }128+ for (const [key, child] of Object.entries(node)) {129+ if (key === "jsDoc" || key === "location") continue;130+ collectReferences(child, symbol, declFile, out);131+ }132+}133+134+/** Lazily built per-module map of declared symbol name → declaration line. */135+const declarationCache = new Map<string, Promise<Map<string, number>>>();136+137+function declarationsIn(fileUrl: string): Promise<Map<string, number>> {138+ let cached = declarationCache.get(fileUrl);139+ if (!cached) {140+ cached = runDoc(["--private", fileUrl]).then((doc) => {141+ const map = new Map<string, number>();142+ for (const symbol of doc.nodes[fileUrl]?.symbols ?? []) {143+ const line = symbol.declarations[0]?.location.line;144+ if (line !== undefined && !map.has(symbol.name)) map.set(symbol.name, line);145+ }146+ return map;147+ });148+ declarationCache.set(fileUrl, cached);149+ }150+ return cached;151+}152+153+/**154+ * Resolves a reference to the module expected to declare it, or `null` when155+ * the reference is a generic type parameter or resolves outside `package/`.156+ * `names` lists identifiers that satisfy the closure when exported from the157+ * entry (the local alias plus, for re-imports, the original name).158+ */159+function resolveTarget(ref: TypeReference): { file: string; names: string[] } | null {160+ const { resolution } = ref;161+ if (resolution?.kind === "typeParam") return null;162+ if (resolution?.kind === "local" || resolution === undefined) {163+ if (!ref.declFile.startsWith(packagePrefix)) return null;164+ if (resolution === undefined && !ref.requireLocalDecl) return null; // ambient (DOM/built-in)165+ return { file: ref.declFile, names: [ref.name] };166+ }167+ if (!resolution.specifier.startsWith(".")) return null; // bare specifier: npm/jsr dependency168+ const target = new URL(resolution.specifier, ref.declFile).href;169+ if (!target.startsWith(packagePrefix)) return null;170+ return { file: target, names: [ref.name, resolution.name] };171+}172+173+const denoConfig = JSON.parse(await Deno.readTextFile(new URL("deno.json", rootUrl)));174+const entries = Object.entries(denoConfig.exports as Record<string, string>)175+ .map(([jsrName, path]) => ({ jsrName, url: new URL(path, rootUrl).href }));176+177+const doc = await runDoc(entries.map((entry) => entry.url));178+const violations: string[] = [];179+let emptyEntries = 0;180+181+for (const entry of entries) {182+ const symbols = doc.nodes[entry.url]?.symbols ?? [];183+ if (symbols.length === 0) {184+ emptyEntries += 1;185+ continue;186+ }187+ const exported = new Set(symbols.map((symbol) => symbol.name));188+ const references: TypeReference[] = [];189+ for (const symbol of symbols) {190+ for (const declaration of symbol.declarations) {191+ collectReferences(declaration, symbol.name, declaration.location.filename, references);192+ }193+ }194+ const seen = new Set<string>();195+ for (const ref of references) {196+ if (exported.has(ref.name)) continue;197+ if (allowlist.has(`${entry.jsrName} ${ref.name}`)) continue;198+ const target = resolveTarget(ref);199+ if (target === null || target.names.some((name) => exported.has(name))) continue;200+ const key = `${ref.symbol} ${ref.name}`;201+ if (seen.has(key)) continue;202+ seen.add(key);203+ const declared = await declarationsIn(target.file);204+ const line = declared.get(target.names.at(-1) ?? ref.name);205+ if (line === undefined && ref.requireLocalDecl) continue; // ambient or imported-external206+ const path = target.file.slice(rootUrl.href.length);207+ violations.push(208+ `${entry.jsrName} ${ref.symbol} → ${ref.name} (declared at ${path}:${line ?? "?"})`,209+ );210+ }211+}212+213+if (violations.length > 0) {214+ console.error(violations.sort().join("\n"));215+ console.error(`Entry type closure failed with ${violations.length} unexported type(s).`);216+ Deno.exit(1);217+}218+219+console.log(220+ `Entry type closure holds for ${entries.length} JSR entrypoints` +221+ (emptyEntries > 0 ? ` (${emptyEntries} export no symbols).` : "."),222+);