clone

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

refactor(api): pre-release author-surface pass — entry type closure, internal de-exports, shared-field seam, naming (#169)

FelineStateMachine committed 6cdf1ab
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>
80 files changed, +1493 -547
Mdeno.json3+-
Mdocs/nouns-and-verbs.md8+-
Mdocs/reference/access.md18++-
Mpackage/access/access_security_test.ts3+-
Mpackage/access/mod.ts46++++--
Mpackage/access/operations.ts52++++++-
Mpackage/access/policies.ts19++-
Mpackage/access/schema.ts4+-
Mpackage/access/shared-field-lifecycle.ts8+-
Mpackage/access/shared_fields_lifecycle_sync_test.ts21++-
Mpackage/access/shared_fields_read_sync_test.ts14+-
Apackage/astro/manifest.ts106++++++++++++++
Mpackage/astro/mod.ts140+++++--------------
Mpackage/astro/vendored_manifest_test.ts7+-
Mpackage/commands/build.ts20+--
Mpackage/commands/preview.ts7+-
Mpackage/commands/shared.ts21+++
Mpackage/preact/DeviceStatus.tsx6+-
Mpackage/preact/PwaActions.tsx9+-
Mpackage/preact/RuntimeRecovery.tsx14+-
Mpackage/preact/TicketEnrollForm.tsx8+-
Mpackage/preact/mod.ts19++-
Mpackage/preact/use-device-capabilities.ts22+++-
Mpackage/recipes/file-handler.ts6+-
Mpackage/recipes/launch-handler.ts18+-
Mpackage/recipes/related-app-discovery.ts37++++-
Mpackage/recipes/related-app-discovery_test.ts7+-
Mpackage/recipes/web-share.ts37+++--
Mpackage/recipes/window-controls-overlay.ts19++-
Mpackage/runtime/app.ts5+
Mpackage/runtime/auth.ts7+-
Mpackage/runtime/boot-progress.ts8+-
Mpackage/runtime/device-capabilities.ts31+---
Mpackage/runtime/diagnostics.ts14++-
Mpackage/runtime/envelope.ts8+
Mpackage/runtime/live-query-store.ts9+-
Mpackage/runtime/mod.ts69++++++---
Mpackage/runtime/provision.ts12+-
Mpackage/runtime/pwa.ts8+-
Mpackage/runtime/runtime.ts8+-
Mpackage/runtime/schema-compat.ts15++-
Mpackage/runtime/session.ts12+-
Mpackage/runtime/startup-recovery.ts5+
Mpackage/runtime/storage-fork.ts8+-
Mpackage/runtime/table-mutations.ts17+--
Mpackage/runtime/table-store.ts38+++--
Mpackage/runtime/table-store_test.ts8+-
Mpackage/runtime/write-handle.ts87++++++++++--
Mpackage/runtime/write-handle_test.ts46++++---
Mpackage/runtime/write-ledger.ts62+++++---
Mpackage/runtime/write-ledger_test.ts8+-
Mpackage/schema/compat.ts10+-
Mpackage/schema/conformance_test.ts3+-
Mpackage/schema/effects.ts19++-
Mpackage/schema/effects_test.ts13+-
Mpackage/schema/encrypted.ts12+-
Mpackage/schema/mod.ts51++++---
Mpackage/schema/mod_test.ts2+-
Mpackage/schema/nested.ts37++++-
Mpackage/schema/nested_test.ts14+-
Mpackage/schema/private-table.ts11+-
Mpackage/schema/private_table_test.ts3+-
Mpackage/schema/shared-crypto.ts50++++---
Mpackage/schema/store.ts76++++++++---
Mpackage/schema/store_test.ts8+-
Mpackage/testing/browser_error.ts13++-
Mpackage/testing/convergence.ts54++++++-
Mpackage/testing/convergence_test.ts32++++
Mpackage/testing/fixture.ts9+-
Mpackage/testing/mod.ts55+++++++-
Mpackage/testing/readiness.ts46+++++-
Mpackage/testing/readiness_test.ts39+++++-
Mpackage/testing/safety.ts31++++-
Mpackage/testing/trace.ts8+
Mpackage/testing/webauthn.ts2+-
Apackage/tooling/build-info.ts21+++
Mpackage/tooling/schema-manifest.ts6+-
Mtools/api_docs.ts2+-
Mtools/entrypoints.ts7+
Atools/type_closure_lint.ts222++++++++++++++++++++++++++++++
M deno.json +2 -1
M docs/nouns-and-verbs.md +4 -4
@@ -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
M docs/reference/access.md +12 -6
@@ -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`
M package/access/access_security_test.ts +2 -1
M package/access/mod.ts +31 -15
@@ -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,
M package/access/operations.ts +41 -11
@@ -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));
M package/access/policies.ts +12 -7
@@ -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 * @returns A template for {@link 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 });
M package/access/schema.ts +3 -1
M package/access/shared-field-lifecycle.ts +3 -5
M package/access/shared_fields_lifecycle_sync_test.ts +12 -9
M package/access/shared_fields_read_sync_test.ts +7 -7
A package/astro/manifest.ts +106 -0
@@ -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;
M package/astro/mod.ts +34 -106
@@ -4,125 +4,45 @@
44 * {@link 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 * @module828 */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");
M package/astro/vendored_manifest_test.ts +3 -4
M package/commands/build.ts +8 -12
@@ -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));
M package/commands/preview.ts +6 -1
M package/commands/shared.ts +21 -0
M package/preact/DeviceStatus.tsx +3 -3
M package/preact/PwaActions.tsx +8 -1
M package/preact/RuntimeRecovery.tsx +8 -6
M package/preact/TicketEnrollForm.tsx +6 -2
M package/preact/mod.ts +15 -4
@@ -2,8 +2,9 @@
22 * Optional Preact bindings for the lofi runtime: live typed queries33 * ({@link useLiveQuery}), table mutations ({@link useTableMutations}), the44 * per-write sync lifecycle ({@link useWrite}, {@link usePendingWrites},5- * {@link useSyncStatus}), first-load progress ({@link useBootProgress}), and6- * the schema-compatibility gate5+ * {@link useSyncStatus}), first-load progress ({@link useBootProgress}),6+ * PWA install/update state ({@link usePwaState}), device capabilities7+ * ({@link useDeviceCapabilities}), the schema-compatibility gate78 * ({@link useSchemaCompat}), and the storage-container fork guard89 * ({@link useStorageFork}), plus package-owned example components910 * ({@link DeviceStatus}, {@link PwaActions}, {@link RuntimeRecovery},
@@ -12,7 +13,7 @@
1213 *1314 * @module1415 */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";
M package/preact/use-device-capabilities.ts +19 -3
M package/recipes/file-handler.ts +4 -2
M package/recipes/launch-handler.ts +9 -9
@@ -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");
M package/recipes/related-app-discovery.ts +26 -11
@@ -4,9 +4,22 @@
44 * @module55 */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 {
M package/recipes/related-app-discovery_test.ts +5 -2
M package/recipes/web-share.ts +22 -15
@@ -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 }
M package/recipes/window-controls-overlay.ts +13 -6
M package/runtime/app.ts +5 -0
M package/runtime/auth.ts +6 -1
M package/runtime/boot-progress.ts +7 -1
M package/runtime/device-capabilities.ts +11 -20
@@ -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();
M package/runtime/diagnostics.ts +12 -2
@@ -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
M package/runtime/envelope.ts +8 -0
M package/runtime/live-query-store.ts +3 -6
@@ -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);
M package/runtime/mod.ts +44 -25
@@ -18,10 +18,21 @@
1818 * until {@link applyPwaUpdate} lands a newer bundle — and install/update1919 * state through {@link 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 {@link RuntimeDiagnostics.sharedFieldAlerts} is resolved with25+ * {@link trustPeerKey} after out-of-band verification.26+ *2127 * @module2228 */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";
M package/runtime/provision.ts +11 -1
M package/runtime/pwa.ts +7 -1
M package/runtime/runtime.ts +5 -3
M package/runtime/schema-compat.ts +13 -2
@@ -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 {
M package/runtime/session.ts +10 -2
M package/runtime/startup-recovery.ts +5 -0
M package/runtime/storage-fork.ts +7 -1
M package/runtime/table-mutations.ts +5 -12
@@ -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);
M package/runtime/table-store.ts +25 -13
@@ -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
M package/runtime/table-store_test.ts +5 -3
M package/runtime/write-handle.ts +71 -16
@@ -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 * @example106156 * ```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);
M package/runtime/write-handle_test.ts +27 -19
@@ -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 });
M package/runtime/write-ledger.ts +38 -24
@@ -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 }
M package/runtime/write-ledger_test.ts +4 -4
@@ -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>(
M package/schema/compat.ts +7 -3
M package/schema/conformance_test.ts +1 -2
M package/schema/effects.ts +12 -7
@@ -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 ({@link MutationOptions.expires}), so rejections today carry60+ * retired ({@link 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);
M package/schema/effects_test.ts +4 -9
M package/schema/encrypted.ts +7 -5
M package/schema/mod.ts +28 -23
@@ -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+ * `@nzip/lofi/access`, and pin remediation for changed peer keys is surfaced27+ * through the main `@nzip/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
M package/schema/mod_test.ts +1 -1
M package/schema/nested.ts +27 -10
@@ -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;
M package/schema/nested_test.ts +11 -3
@@ -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 );
M package/schema/private-table.ts +9 -2
M package/schema/private_table_test.ts +2 -1
M package/schema/shared-crypto.ts +29 -21
@@ -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);
M package/schema/store.ts +57 -19
@@ -21,7 +21,14 @@
2121 * @module2222 */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 }
M package/schema/store_test.ts +5 -3
M package/testing/browser_error.ts +12 -1
M package/testing/convergence.ts +45 -9
@@ -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(
M package/testing/convergence_test.ts +32 -0
@@ -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 {
M package/testing/fixture.ts +7 -2
M package/testing/mod.ts +54 -1
@@ -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 * @module820 */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";
M package/testing/readiness.ts +40 -6
@@ -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.
M package/testing/readiness_test.ts +37 -2
@@ -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 });
M package/testing/safety.ts +28 -3
@@ -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,
M package/testing/trace.ts +8 -0
M package/testing/webauthn.ts +1 -1
A package/tooling/build-info.ts +21 -0
M package/tooling/schema-manifest.ts +4 -2
M tools/api_docs.ts +1 -1
M tools/entrypoints.ts +7 -0
A tools/type_closure_lint.ts +222 -0
@@ -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+);