clone

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

Merge pull request #27 from FelineStateMachine/m2

FelineStateMachine committed 0559efb
promote: M2 local-first framework contract to dev
commit
0559efb261e75b743f2fee4d9b866e979d81fc75
parent
33fdd3593240e7abf4bd92eab0c3c38d85472333
parent
dc451e8255a93b562c2cbc1ba17e887e80ec39e7
author
FelineStateMachine <dami.etoile@gmail.com>
105 files changed, +7644 -638
A.github/ISSUE_TEMPLATE/config.yml5+
A.github/ISSUE_TEMPLATE/milestone-task.yml35+
A.github/ISSUE_TEMPLATE/spike.yml50+
A.github/pull_request_template.md20+
ACONTRIBUTING.md80++
ALICENSE21+
AREADME.md139+++
Dapps/prototype/src/components/NotesIsland.tsx58-
Dapps/prototype/src/pages/index.astro35-
Dapps/prototype/src/permissions.ts9-
Dapps/prototype/src/runtime/config.ts16-
Dapps/prototype/src/runtime/notes-store.ts149----
Dapps/prototype/src/runtime/notes-store_test.ts156----
Dapps/prototype/src/runtime/probe.ts34-
Aapps/reference/README.md33+
Rapps/prototype/astro.config.ts → apps/reference/astro.config.ts9+
Rapps/prototype/public/favicon.svg → apps/reference/public/favicon.svg0
Rapps/prototype/public/manifest.webmanifest → apps/reference/public/manifest.webmanifest4+-
Rapps/prototype/public/sw.js → apps/reference/public/sw.js0
Rapps/prototype/src/components/DeviceStatus.tsx → apps/reference/src/_lofi/DeviceStatus.tsx6+-
Aapps/reference/src/_lofi/ReferenceShell.astro25+
Rapps/prototype/src/runtime/boot.ts → apps/reference/src/_lofi/boot.ts0
Aapps/reference/src/_lofi/checklist-store.ts222+++++
Aapps/reference/src/_lofi/checklist-store_test.ts255++++++
Aapps/reference/src/_lofi/config.ts21+
Rapps/prototype/src/runtime/device-capabilities.ts → apps/reference/src/_lofi/device-capabilities.ts0
Aapps/reference/src/_lofi/device-capabilities_test.ts34+
Aapps/reference/src/_lofi/inspector.ts208+++++
Aapps/reference/src/_lofi/inspector_test.ts41+
Aapps/reference/src/_lofi/probe.ts120+++
Rapps/prototype/src/runtime/pwa.ts → apps/reference/src/_lofi/pwa.ts0
Rapps/prototype/src/runtime/pwa_test.ts → apps/reference/src/_lofi/pwa_test.ts0
Rapps/prototype/src/runtime/resource-lifecycle.ts → apps/reference/src/_lofi/resource-lifecycle.ts0
Rapps/prototype/src/runtime/resource-lifecycle_test.ts → apps/reference/src/_lofi/resource-lifecycle_test.ts9+-
Rapps/prototype/src/runtime/lofi-runtime.ts → apps/reference/src/_lofi/runtime.ts41+-
Aapps/reference/src/_lofi/test-assert.ts7+
Rapps/prototype/src/runtime/ui-mutation.ts → apps/reference/src/_lofi/ui-mutation.ts0
Rapps/prototype/src/runtime/ui-mutation_test.ts → apps/reference/src/_lofi/ui-mutation_test.ts0
Rapps/prototype/src/runtime/use-notes.ts → apps/reference/src/_lofi/use-checklist.ts37+-
Rapps/prototype/src/runtime/use-device-capabilities.ts → apps/reference/src/_lofi/use-device-capabilities.ts0
Aapps/reference/src/app.ts13+
Rapps/prototype/src/env.d.ts → apps/reference/src/env.d.ts3+
Aapps/reference/src/islands/ChecklistIsland.tsx135+++
Aapps/reference/src/migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts28+
Aapps/reference/src/migrations/snapshots/20260715T194819-6c62fec42c35.json20+
Aapps/reference/src/migrations/snapshots/20260715T194947-ff85ac1d97ee.json27+
Aapps/reference/src/pages/index.astro27+
Aapps/reference/src/permissions.ts9+
Rapps/prototype/src/schema.ts → apps/reference/src/schema.ts5+-
Rapps/prototype/src/styles/global.css → apps/reference/src/styles/global.css108++-
Aapps/reference/src/ui-contract.ts10+
Aapps/reference/tests/author-boundary_test.ts31+
Aapps/reference/tests/testing-contract_test.ts23+
Rapps/prototype/tsconfig.json → apps/reference/tsconfig.json0
Mdeno.json22+-
Mdeno.lock41+-
Mdocs/decisions/0002-graduate-thin-preact-adapter.md2+-
Adocs/decisions/0005-publish-one-nzip-lofi-package.md38+
Adocs/decisions/0006-bound-layer3-inspection-and-testing.md80++
Mdocs/devx-contract.md175+++-
Adocs/m2-abstraction-leaks.md24+
Minit.md74+-
Ajsr.json33+
Apackage/commands/build.ts55+
Apackage/commands/check.ts47+
Apackage/commands/dev.ts36+
Apackage/commands/doctor.ts7+
Apackage/commands/preview.ts53+
Apackage/commands/run_tests.ts15+
Apackage/commands/shared.ts21+
Apackage/create.ts34+
Apackage/create_core.ts235++++++
Apackage/create_core_test.ts210+++++
Apackage/starter_template.ts108+++
Apackage/testdata/starter.snapshot.json45+
Apackage/testing/browser_error.ts20+
Apackage/testing/convergence.ts126+++
Apackage/testing/convergence_test.ts133+++
Apackage/testing/fixture.ts386+++++++++
Apackage/testing/fixture_integration.ts171++++
Apackage/testing/mod.ts22+
Apackage/testing/readiness.ts40+
Apackage/testing/readiness_test.ts48+
Apackage/testing/safety.ts73++
Apackage/testing/safety_test.ts81++
Apackage/testing/trace.ts140+++
Apackage/tooling/diagnostics.ts132+++
Apackage/tooling/diagnostics_test.ts82++
Apackage/tooling/environment.ts167++++
Apackage/tooling/environment_test.ts22+
Apackage/tooling/process.ts55+
Apackage/tooling/process_test.ts68++
Apackage/tooling/project.ts164++++
Apackage/tooling/project_test.ts120+++
Apackage/version.ts5+
Mtools/artifact_files_test.ts6+-
Atools/assert.ts3+
Atools/check_migrations.ts161++++
Atools/golden_path.ts1220++++++++++++++++++++++++++++++
Atools/golden_path_checkout.ts18+
Atools/golden_path_core.ts707+++++++++++++++++
Atools/golden_path_core_test.ts340++++++++
Atools/golden_path_create.ts79++
Mtools/preview.ts2+-
Rtools/run_prototype.ts → tools/run_reference.ts18+-
A .github/ISSUE_TEMPLATE/config.yml +5 -0
A .github/ISSUE_TEMPLATE/milestone-task.yml +35 -0
A .github/ISSUE_TEMPLATE/spike.yml +50 -0
A .github/pull_request_template.md +20 -0
A CONTRIBUTING.md +80 -0
@@ -0,0 +1,80 @@
1+# Contributing to lofi2+3+This repository runs on a small number of strict habits. They exist so that a prototype built on an4+alpha data layer stays honest about what actually works.5+6+## Prerequisites7+8+- **Deno 2.9+** — the only required global runtime.9+- **Git** — a contributor prerequisite for this repository journey (generated apps will not need10+ it).11+12+## The one command that gates everything13+14+```sh15+deno task check16+```17+18+This runs formatting, linting, type checks, Astro checks, the deterministic test suite, the19+environment-contract check, and the secret-leak scan. A change is not ready for review until it20+passes locally. There are no other hidden global tools.21+22+## Contract-driven changes23+24+The product is defined by [docs/devx-contract.md](docs/devx-contract.md) — a table of falsifiable25+promises with IDs, budgets, and statuses. Work relates to it in one of three ways:26+27+1. **Implements a promise.** Cite the contract IDs (e.g. `DX-LOCAL-01`) in the issue and PR, and28+ show the evidence that the budget is met.29+2. **Changes a promise.** Evidence disproved or narrowed it — update the contract row30+ (`validated / revised / rejected`) in the same PR and record the delta in a decision record.31+3. **Touches no promise.** Say so; small mechanical changes do not need contract ceremony.32+33+### Decision records34+35+Time-boxed experiments (`type: spike`) must produce a record in36+[docs/decisions/](docs/decisions/README.md) with the shape defined there: question, contract IDs,37+exact versions, control, procedure, retained evidence, decision, contract delta, follow-up.38+39+Two rules worth repeating:40+41+- A spike is complete when it supports a decision — **including a negative decision** — not when a42+ demo renders once.43+- A search summary without a stable primary source is not evidence. Cite documentation, changelog,44+ source, or retained device artifacts.45+46+## Issues, labels, milestones47+48+- **Milestones** gate scope: M2 (DevX vertical slice) → M3 (mobile PWA hardening) → M4 (framework49+ extraction). Every substantive issue belongs to one.50+- **Labels:**51+ - `priority: p0` blocks the golden path or the next milestone; `p1` is important milestone work;52+ `p2` is follow-up.53+ - `area: jazz | pwa | tooling | framework | auth` locates the subsystem.54+ - `type: spike` means time-boxed with an explicit decision; `type: devx` means developer55+ experience and authoring workflow.56+- Issue and PR templates in `.github/` prompt for contract IDs and evidence — filling those fields57+ is what makes review fast.58+59+## Branches and pull requests60+61+- Branch from `main`; agent-driven work uses the `agent/<scope>` prefix, human work can use any62+ clear prefix (`chore/`, `docs/`, `fix/`).63+- Merge to `main` happens through a PR. Keep PRs scoped to one milestone concern; link the issues64+ they close.65+- The PR description should let a reviewer verify claims without re-deriving them: what was run,66+ what was observed, where retained evidence lives.67+68+## Hard boundaries (do not relax casually)69+70+These are contract commitments, not style preferences:71+72+- **Secrets:** `JAZZ_ADMIN_SECRET` and `BACKEND_SECRET` are server-only — never projected into73+ client config, logs, examples with values, or build output. The secret scan enforces this.74+- **Silent degradation is forbidden:** durable storage never quietly becomes memory-only;75+ diagnostics never claim signals the pinned vendor API does not expose.76+- **The author boundary:** product UI never imports raw Jazz, workers, transports, or Workbox77+ config. If a change needs that, it belongs in the runtime layer or an explicitly named escape78+ hatch (see the [escape-hatch policy](docs/devx-contract.md#escape-hatch-policy)).79+- **Version pins are decisions:** bumping the Jazz alpha (or any pinned tool) requires re-running80+ the affected evidence and a decision record if behavior changed.
A LICENSE +21 -0
A README.md +139 -0
@@ -0,0 +1,139 @@
1+# lofi2+3+**A local-first mobile PWA meta-framework, built as an honest prototype.**4+5+Write a schema, build Preact islands, ship an installable offline-capable app. The UI always6+hydrates from local data, identity never waits on a server, and the sync module is the only network7+surface an app developer ever sees.8+9+Built on [Jazz 2](https://jazz.tools) (CRDT data + sync) · [Preact](https://preactjs.com) islands in10+[Astro](https://astro.build) · the [Deno](https://deno.com) toolchain.11+12+> **Status: working prototype, pre-release.** Nothing is published yet. M0 (DevX contract) and M113+> (feasibility) are complete; all three M2 source layers are merged on `m2` and undergoing combined14+> promotion review. See the [roadmap](#roadmap).15+16+## Why lofi17+18+- **Local-first without the plumbing.** Product code touches schema, config, islands, styles, and19+ tests — never providers, workers, transports, or service-worker internals20+ ([DX-AUTHOR-01, DX-LEAK-01](docs/devx-contract.md#measurable-promises)).21+- **Honest by construction.** Durable storage never silently degrades to memory; diagnostics only22+ claim what the underlying APIs can actually observe; identity wording matches real custody and23+ recovery semantics.24+- **Mobile PWA first.** Platform floors (Android Chrome 148+, iOS Safari 16.4+) are enforced boot25+ gates, not advisory footnotes. Unsupported browsers get an explicit answer, never silent data26+ loss.27+28+## The north star29+30+When every milestone below has landed, this is the entire onboarding experience:31+32+```sh33+deno run -A jsr:@nzip/lofi/create my-app34+cd my-app35+deno task dev36+```37+38+From there a developer makes a retained local write, reloads it, works offline, runs checks, builds,39+previews, and opens the same stable secure origin on a physical phone — with Deno as the only40+required global runtime and a command surface of exactly seven verbs: `create`, `dev`, `doctor`,41+`check`, `test`, `build`, `preview`.42+43+## Try it today (checkout journey)44+45+The `m2` branch contains the accepted source generator, package-owned commands, development46+inspector, and browser-testing helpers. The package is not published until explicitly authorized and47+the registry-backed smoke passes, so the currently available journey runs from a checkout:48+49+```sh50+git clone https://github.com/FelineStateMachine/lofi.git51+cd lofi52+deno task dev53+```54+55+- **Deno 2.9+** is the only required global runtime.56+- **No `.env` means local-only mode**, stated explicitly at boot. For cloud sync, copy57+ `.env.example` and set the public `JAZZ_APP_ID`/`JAZZ_SERVER_URL` pair. Server-only secrets never58+ reach client output — this is tested, not hoped.59+- From `deno task dev` you should reach a retained local write in under 60 seconds60+ ([DX-PROTOTYPE-01](docs/devx-contract.md#measurable-promises)).61+62+### Command surface63+64+| Command | What it does today |65+| ------------------- | ------------------------------------------------------------------------ |66+| `deno task dev` | Serves the prototype; prints URL plus storage, identity, and sync state. |67+| `deno task check` | Format, lint, type, Astro, env-contract, and secret-leak checks. |68+| `deno task test` | Deterministic local-first tests (no hand-timed sleeps). |69+| `deno task build` | Production build through the supported Deno path. |70+| `deno task preview` | Serves the production build. |71+72+`create` and `doctor` have graduated through the source-backed generated-project golden journey and73+clean-room review. The public JSR invocation remains unavailable until publication and its74+registry-backed smoke pass; the output contracts are specified in the75+[DevX contract](docs/devx-contract.md#canonical-command-surface-and-output-contract).76+77+## How this repository works78+79+Development here is **contract-driven**: the product is a set of falsifiable promises, and code80+graduates only by producing evidence against them.81+82+1. **[The DevX contract](docs/devx-contract.md)** defines measurable promises with IDs (like83+ `DX-LOCAL-01`), budgets, and statuses (`proposed → validated / revised / rejected`).84+2. **[Decision records](docs/decisions/)** capture each experiment: question, exact versions,85+ control, procedure, retained evidence, and the resulting contract delta. A spike is complete when86+ it supports a decision — including a negative one — not when a demo renders once.87+3. **[Milestones](https://github.com/FelineStateMachine/lofi/milestones)** gate scope. Issues carry88+ `area:*`, `type:*`, and `priority:p0–p2` labels; p0 blocks the golden path.89+90+Notable honest outcomes so far: the Jazz 2 alpha passkey-backup ceremony was **rejected** on91+security grounds rather than demoed92+([decision 0004](docs/decisions/0004-reject-alpha53-passkey-backup.md)), and live transport state is93+reported as _unavailable_ rather than faked, because the pinned API exposes no supported signal.94+95+## Roadmap96+97+| Milestone | Goal | Status |98+| ------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------- |99+| **M0 — DevX contract** | Define the golden path, command surface, environment contract, and measurable acceptance criteria. | ✅ Done |100+| **M1 — Feasibility spikes** | Prove Jazz 2, Preact, OPFS, identity, Astro islands, and Deno behavior with go/no-go decisions. | ✅ Done |101+| **M2 — DevX vertical slice** | `create`, `dev`, `doctor`, diagnostics, inspector, and testing workflows around a reference app. | 🧪 Promotion review |102+| **M3 — Mobile PWA hardening** | Install, persistence, offline cold start, lifecycle recovery, and physical-device validation. | ⏳ Queued |103+| **M4 — Runtime API extraction** | Extract remaining proven runtime seams as `@nzip/lofi` subpaths; validate them through a second app. | ⏳ Queued |104+105+The definition of done for the whole prototype is the [north star](#the-north-star) journey passing106+its contract budgets end-to-end, on real devices.107+108+## Repository map109+110+```text111+lofi/112+├── docs/113+│ ├── devx-contract.md # The product contract: promises, budgets, statuses114+│ └── decisions/ # Evidence-backed decision records (ADR-style)115+├── apps/reference/ # The integrated app graduated from M1 and reshaped in M2116+├── package/ # The single @nzip/lofi package source and generated-project commands117+├── spikes/ # M1 feasibility experiments, kept for evidence118+├── tools/ # Deno tasks: env contract, secret scanning, runners119+├── init.md # Original research plan — input, not the contract120+├── deno.json # Workspace tasks and pinned toolchain121+└── jsr.json # One package name, version, export map, and publish manifest122+```123+124+## Stack125+126+| Layer | Choice | Pinned at |127+| ---------- | --------------------------------------------- | ------------------- |128+| Data/sync | jazz-tools 2.0 alpha (exact pin), OPFS | `2.0.0-alpha.53` |129+| UI runtime | Preact islands, `client:only`, thin adapter | Preact 10 / Astro 7 |130+| Shell | Astro, fully prerendered, static-through-Deno | Astro 7 |131+| Toolchain | Deno tasks + npm-compat | Deno 2.9 |132+133+Version pins are deliberate: the data layer is an alpha, so every bump is a reviewed decision, and134+`docs/decisions/` records what each pin was verified to do.135+136+## Contributing137+138+See [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow: how promises, evidence, and decision139+records fit together, what `deno task check` enforces, and how issues and milestones are organized.
D apps/prototype/src/components/NotesIsland.tsx +0 -58
@@ -1,58 +0,0 @@
1-import { useState } from "preact/hooks";2-import "../runtime/boot.ts";3-import { settleUiMutation } from "../runtime/ui-mutation.ts";4-import { useNotes } from "../runtime/use-notes.ts";5-6-export interface NotesIslandProps {7- label: string;8-}9-10-export default function NotesIsland({ label }: NotesIslandProps) {11- const state = useNotes();12- const [body, setBody] = useState("");13- const inputId = `note-${label.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}`;14-15- return (16- <section class="island" data-island={label}>17- <header>18- <p class="eyebrow">{label}</p>19- <h2>Shared notes</h2>20- </header>21- <form22- onSubmit={(event) => {23- event.preventDefault();24- const next = body.trim();25- if (!next) return;26- setBody("");27- void settleUiMutation(state.add(next));28- }}29- >30- <label for={inputId}>New note</label>31- <div class="composer">32- <input33- id={inputId}34- value={body}35- onInput={(event) => setBody(event.currentTarget.value)}36- autocomplete="off"37- />38- <button type="submit">Add from {label}</button>39- </div>40- </form>41- <p class="state" role="status">42- {state.status === "loading" && "Opening persistent storage…"}43- {state.status === "error" && `Error: ${state.error}`}44- {state.status === "ready" &&45- `${state.notes.length} note(s) · last write ${state.durability}`}46- </p>47- <ul aria-label={`${label} notes`}>48- {state.notes.map((note) => <li key={note.id} data-note-id={note.id}>{note.body}</li>)}49- </ul>50- <p class="diagnostics" data-diagnostics>51- storage {state.diagnostics.storageState} · clients {state.diagnostics.activeClients}{" "}52- · consumers {state.diagnostics.activeConsumers} · subscriptions{" "}53- {state.diagnostics.activeVendorSubscriptions} · error listeners {state54- .diagnostics.activeMutationListeners}55- </p>56- </section>57- );58-}
D apps/prototype/src/pages/index.astro +0 -35
D apps/prototype/src/permissions.ts +0 -9
D apps/prototype/src/runtime/config.ts +0 -16
D apps/prototype/src/runtime/notes-store.ts +0 -149
@@ -1,149 +0,0 @@
1-import type { Db, MutationErrorEvent } from "jazz-tools";2-import { app } from "../schema.ts";3-import { serverUrl } from "./config.ts";4-5-export type Note = { id: string; body: string; createdAt: Date };6-export type NotesSnapshot = {7- status: "loading" | "ready" | "error";8- notes: Note[];9- durability: "none" | "local" | "global" | "failed";10- error: string | null;11-};12-13-export type RuntimeDiagnostics = {14- storageState: "persistent-requested" | "persistent-driver-open" | "failed";15- clientsCreated: number;16- activeClients: number;17- activeConsumers: number;18- activeVendorSubscriptions: number;19- totalVendorSubscriptions: number;20- activeMutationListeners: number;21- totalMutationListeners: number;22- unsubscribeCalls: number;23- localWaitCalls: number;24- mutationErrors: number;25-};26-27-type Listener = () => void;28-29-export class NotesStore {30- readonly #db: Db;31- readonly #diagnostics: RuntimeDiagnostics;32- readonly #listeners = new Set<Listener>();33- #vendorUnsubscribe: (() => void) | null = null;34- #snapshot: NotesSnapshot = {35- status: "loading",36- notes: [],37- durability: "none",38- error: null,39- };40-41- constructor(db: Db, diagnostics: RuntimeDiagnostics) {42- this.#db = db;43- this.#diagnostics = diagnostics;44- }45-46- getSnapshot = (): NotesSnapshot => this.#snapshot;47-48- subscribe = (listener: Listener): () => void => {49- this.#listeners.add(listener);50- this.#diagnostics.activeConsumers = this.#listeners.size;51- if (this.#listeners.size === 1) this.#openVendorSubscription();52- this.#emit();53- let active = true;54- return () => {55- if (!active) return;56- active = false;57- this.#listeners.delete(listener);58- this.#diagnostics.activeConsumers = this.#listeners.size;59- if (this.#listeners.size === 0) this.#closeVendorSubscription();60- else this.#emit();61- };62- };63-64- async add(body: string): Promise<void> {65- const write = this.#db.insert(app.notes, { body, createdAt: new Date() });66- try {67- await write.wait({ tier: "local" });68- this.#diagnostics.localWaitCalls += 1;69- this.#set({ durability: "local", error: null });70- if (serverUrl) {71- void write.wait({ tier: "global" }).then(72- () => this.#set({ durability: "global", error: null }),73- (error) => this.#fail(error),74- );75- }76- } catch (error) {77- this.#fail(error);78- throw error;79- }80- }81-82- async update(id: string, body: string): Promise<void> {83- try {84- await this.#db.update(app.notes, id, { body }).wait({ tier: "local" });85- this.#diagnostics.localWaitCalls += 1;86- this.#set({ durability: "local", error: null });87- } catch (error) {88- this.#fail(error);89- throw error;90- }91- }92-93- reportMutationError(event: MutationErrorEvent) {94- this.#diagnostics.mutationErrors += 1;95- this.#fail(new Error(`${event.code}: ${event.reason}`));96- }97-98- close() {99- this.#listeners.clear();100- this.#diagnostics.activeConsumers = 0;101- this.#closeVendorSubscription();102- }103-104- #openVendorSubscription() {105- if (this.#vendorUnsubscribe) return;106- try {107- this.#vendorUnsubscribe = this.#db.subscribeAll(app.notes, (delta) => {108- this.#snapshot = {109- ...this.#snapshot,110- status: "ready",111- notes: delta.all as Note[],112- error: null,113- };114- this.#emit();115- });116- this.#diagnostics.activeVendorSubscriptions += 1;117- this.#diagnostics.totalVendorSubscriptions += 1;118- } catch (error) {119- this.#fail(error);120- }121- }122-123- #closeVendorSubscription() {124- if (!this.#vendorUnsubscribe) return;125- this.#vendorUnsubscribe();126- this.#diagnostics.unsubscribeCalls += 1;127- this.#vendorUnsubscribe = null;128- this.#diagnostics.activeVendorSubscriptions -= 1;129- }130-131- #set(change: Partial<NotesSnapshot>) {132- this.#snapshot = { ...this.#snapshot, ...change };133- this.#emit();134- }135-136- #fail(error: unknown) {137- this.#snapshot = {138- ...this.#snapshot,139- status: "error",140- durability: "failed",141- error: error instanceof Error ? error.message : String(error),142- };143- this.#emit();144- }145-146- #emit() {147- for (const listener of this.#listeners) listener();148- }149-}
D apps/prototype/src/runtime/notes-store_test.ts +0 -156
@@ -1,156 +0,0 @@
1-import type { Db, MutationErrorEvent } from "jazz-tools";2-import { NotesStore, type RuntimeDiagnostics } from "./notes-store.ts";3-4-const test = (globalThis as unknown as {5- Deno: { test(name: string, body: () => void | Promise<void>): void };6-}).Deno.test;7-8-function assert(condition: unknown, message: string): asserts condition {9- if (!condition) throw new Error(message);10-}11-12-function assertCount(actual: number, expected: number, message: string) {13- if (actual !== expected) throw new Error(`${message}: expected ${expected}, received ${actual}`);14-}15-16-function diagnostics(): RuntimeDiagnostics {17- return {18- storageState: "persistent-driver-open",19- clientsCreated: 1,20- activeClients: 1,21- activeConsumers: 0,22- activeVendorSubscriptions: 0,23- totalVendorSubscriptions: 0,24- activeMutationListeners: 1,25- totalMutationListeners: 1,26- unsubscribeCalls: 0,27- localWaitCalls: 0,28- mutationErrors: 0,29- };30-}31-32-function fakeDb() {33- let subscriber: ((delta: { all: unknown[] }) => void) | null = null;34- let unsubscribeCalls = 0;35- let rejectNextWrite: Error | null = null;36- const writes: Array<{ operation: "insert" | "update"; value: unknown }> = [];37-38- function handle() {39- return {40- wait: ({ tier }: { tier: "local" | "global" }) => {41- assert(tier === "local", "local-only tests must not request a global wait");42- if (rejectNextWrite) {43- const error = rejectNextWrite;44- rejectNextWrite = null;45- return Promise.reject(error);46- }47- return Promise.resolve();48- },49- };50- }51-52- const value = {53- subscribeAll(_collection: unknown, callback: (delta: { all: unknown[] }) => void) {54- subscriber = callback;55- return () => {56- unsubscribeCalls += 1;57- subscriber = null;58- };59- },60- insert(_collection: unknown, next: unknown) {61- writes.push({ operation: "insert", value: next });62- return handle();63- },64- update(_collection: unknown, id: string, next: unknown) {65- writes.push({ operation: "update", value: { id, next } });66- return handle();67- },68- } as unknown as Db;69-70- return {71- value,72- publish(all: unknown[]) {73- assert(subscriber, "expected an active vendor subscription");74- subscriber({ all });75- },76- failNextWrite(message: string) {77- rejectNextWrite = new Error(message);78- },79- writes,80- unsubscribeCalls: () => unsubscribeCalls,81- };82-}83-84-test("two consumers share one vendor subscription and clean up idempotently", () => {85- const db = fakeDb();86- const counts = diagnostics();87- const store = new NotesStore(db.value, counts);88- let firstUpdates = 0;89- let secondUpdates = 0;90-91- const stopFirst = store.subscribe(() => firstUpdates += 1);92- const stopSecond = store.subscribe(() => secondUpdates += 1);93- assertCount(counts.activeConsumers, 2, "both component consumers must be counted");94- assertCount(95- counts.activeVendorSubscriptions,96- 1,97- "one query should have one vendor subscription",98- );99- assertCount(counts.totalVendorSubscriptions, 1, "the underlying query should open once");100-101- db.publish([{ id: "note-1", body: "shared", createdAt: new Date(0) }]);102- assert(store.getSnapshot().status === "ready", "the first query result must end loading");103- assert(store.getSnapshot().notes.length === 1, "the query result must be published");104- assert(firstUpdates > 0 && secondUpdates > 0, "both roots must receive reactive updates");105-106- stopFirst();107- assertCount(counts.activeConsumers, 1, "unmounting one root must leave the other active");108- assertCount(109- counts.activeVendorSubscriptions,110- 1,111- "the remaining consumer must retain the query",112- );113- stopFirst();114- assertCount(counts.activeConsumers, 1, "unsubscribe must be idempotent");115-116- stopSecond();117- assertCount(counts.activeConsumers, 0, "the final unmount must remove all consumers");118- assertCount(counts.activeVendorSubscriptions, 0, "the final unmount must close the query");119- assertCount(db.unsubscribeCalls(), 1, "the vendor unsubscribe must run exactly once");120- assertCount(counts.unsubscribeCalls, 1, "cleanup must remain externally diagnosable");121-});122-123-test("local writes are awaited and failures remain visible", async () => {124- const db = fakeDb();125- const counts = diagnostics();126- const store = new NotesStore(db.value, counts);127-128- await store.add("first");129- await store.update("note-1", "edited");130- assert(counts.localWaitCalls === 2, "every accepted write must await local durability");131- assert(db.writes.length === 2, "insert and update must reach the vanilla Jazz boundary");132- assert(store.getSnapshot().durability === "local", "the UI may claim local after the wait");133-134- db.failNextWrite("disk full");135- let rejected = false;136- try {137- await store.add("cannot persist");138- } catch {139- rejected = true;140- }141- assert(rejected, "a failed local wait must reject the public mutation");142- assert(store.getSnapshot().status === "error", "a failed local wait must reach UI state");143- assert(store.getSnapshot().error === "disk full", "the durable-write cause must remain visible");144-});145-146-test("public mutation errors update observable diagnostics", () => {147- const db = fakeDb();148- const counts = diagnostics();149- const store = new NotesStore(db.value, counts);150- store.reportMutationError({151- code: "WriteRejected",152- reason: "permission denied",153- } as MutationErrorEvent);154- assert(counts.mutationErrors === 1, "mutation errors must be counted");155- assert(store.getSnapshot().error?.includes("permission denied"), "the reason must reach the UI");156-});
D apps/prototype/src/runtime/probe.ts +0 -34
A apps/reference/README.md +33 -0
@@ -0,0 +1,33 @@
1+# lofi reference application2+3+This application proves the author-facing lofi workflow while the public `@nzip/lofi` package is4+graduated in layers. M2 extracts the generated-project commands; broader framework API extraction5+remains M4 work. The application is deliberately split into two ownership zones.6+7+## Author-owned files8+9+- `src/schema.ts` — application data model10+- `src/permissions.ts` — application access policy11+- `src/app.ts` — named application configuration12+- `src/pages/` — product pages and shell content13+- `src/islands/` — product interaction and rendering14+- `src/styles/` — product styling15+- `tests/` — product behavior and author-boundary tests16+17+## Generated and runtime-owned files18+19+- `astro.config.ts`, `tsconfig.json`, and `src/env.d.ts` — compatible toolchain wiring20+- `public/manifest.webmanifest`, `public/sw.js`, and `public/favicon.svg` — PWA plumbing21+- `src/_lofi/` — storage, identity, synchronization, lifecycle, diagnostics, and framework adapter22+23+Application work should not require edits in `src/_lofi/`. The M2 generator copies these validated24+runtime-owned files without turning them into public framework APIs. M4 may replace the local seam25+with proven `@nzip/lofi` subpath imports without changing product UI.26+27+Browser journeys import readiness, offline, two-client, and sanitized failure-artifact helpers from28+`@nzip/lofi/testing`. The normal `deno task test` verifies that generated projects resolve this29+surface without launching Chromium; real browser and cloud-convergence journeys remain explicit30+developer-run gates.31+32+Passkey backup and recovery are intentionally absent. The pinned Jazz alpha exposes a rejected33+credential design, so this reference uses a device-local identity and does not imply recoverability.
R apps/prototype/astro.config.ts → apps/reference/astro.config.ts +9 -0
R apps/prototype/public/favicon.svg → apps/reference/public/favicon.svg +0 -0

No content changes.

R apps/prototype/public/manifest.webmanifest → apps/reference/public/manifest.webmanifest +2 -2
R apps/prototype/public/sw.js → apps/reference/public/sw.js +0 -0

No content changes.

R apps/prototype/src/components/DeviceStatus.tsx → apps/reference/src/_lofi/DeviceStatus.tsx +3 -3
A apps/reference/src/_lofi/ReferenceShell.astro +25 -0
R apps/prototype/src/runtime/boot.ts → apps/reference/src/_lofi/boot.ts +0 -0

No content changes.

A apps/reference/src/_lofi/checklist-store.ts +222 -0
@@ -0,0 +1,222 @@
1+import type { Db, MutationErrorEvent } from "jazz-tools";2+import { referenceApp } from "../app.ts";3+import { serverUrl } from "./config.ts";4+5+export type ChecklistTask = {6+ id: string;7+ text: string;8+ completed: boolean;9+ createdAt: Date;10+};11+12+export type ChecklistSnapshot = {13+ status: "loading" | "ready" | "error";14+ tasks: ChecklistTask[];15+ durability: "none" | "local" | "global" | "failed";16+ error: string | null;17+};18+19+export type RuntimeDiagnostics = {20+ storageState: "persistent-requested" | "persistent-driver-open" | "failed";21+ clientsCreated: number;22+ activeClients: number;23+ activeConsumers: number;24+ activeVendorSubscriptions: number;25+ totalVendorSubscriptions: number;26+ activeMutationListeners: number;27+ totalMutationListeners: number;28+ unsubscribeCalls: number;29+ localWaitCalls: number;30+ pendingLocalWrites: number;31+ pendingGlobalWrites: number;32+ lastWriteDurability: "none" | "local" | "global" | "failed";33+ mutationErrors: number;34+};35+36+type Listener = () => void;37+type MutationHandle = {38+ wait(options: { tier: "local" | "global" }): Promise<unknown>;39+};40+41+export class ChecklistStore {42+ readonly #db: Db;43+ readonly #diagnostics: RuntimeDiagnostics;44+ readonly #syncConfigured: boolean;45+ readonly #diagnosticsChanged: () => void;46+ readonly #listeners = new Set<Listener>();47+ #vendorUnsubscribe: (() => void) | null = null;48+ #writeGeneration = 0;49+ #snapshot: ChecklistSnapshot = {50+ status: "loading",51+ tasks: [],52+ durability: "none",53+ error: null,54+ };55+56+ constructor(57+ db: Db,58+ diagnostics: RuntimeDiagnostics,59+ syncConfigured = Boolean(serverUrl),60+ diagnosticsChanged: () => void = () => undefined,61+ ) {62+ this.#db = db;63+ this.#diagnostics = diagnostics;64+ this.#syncConfigured = syncConfigured;65+ this.#diagnosticsChanged = diagnosticsChanged;66+ }67+68+ getSnapshot = (): ChecklistSnapshot => this.#snapshot;69+70+ subscribe = (listener: Listener): () => void => {71+ this.#listeners.add(listener);72+ this.#diagnostics.activeConsumers = this.#listeners.size;73+ this.#diagnosticsChanged();74+ if (this.#listeners.size === 1) this.#openVendorSubscription();75+ this.#emit();76+ let active = true;77+ return () => {78+ if (!active) return;79+ active = false;80+ this.#listeners.delete(listener);81+ this.#diagnostics.activeConsumers = this.#listeners.size;82+ this.#diagnosticsChanged();83+ if (this.#listeners.size === 0) this.#closeVendorSubscription();84+ else this.#emit();85+ };86+ };87+88+ async create(text: string): Promise<void> {89+ const now = new Date();90+ await this.#settle(this.#db.insert(referenceApp.schema.tasks, {91+ text,92+ completed: false,93+ createdAt: now,94+ }));95+ }96+97+ async update(id: string, text: string): Promise<void> {98+ await this.#settle(99+ this.#db.update(referenceApp.schema.tasks, id, { text }),100+ );101+ }102+103+ async setCompleted(id: string, completed: boolean): Promise<void> {104+ await this.#settle(105+ this.#db.update(referenceApp.schema.tasks, id, { completed }),106+ );107+ }108+109+ async delete(id: string): Promise<void> {110+ await this.#settle(this.#db.delete(referenceApp.schema.tasks, id));111+ }112+113+ reportMutationError(event: MutationErrorEvent): void {114+ this.#diagnostics.mutationErrors += 1;115+ this.#diagnosticsChanged();116+ this.#fail(new Error(`${event.code}: ${event.reason}`));117+ }118+119+ close(): void {120+ this.#listeners.clear();121+ this.#diagnostics.activeConsumers = 0;122+ this.#diagnosticsChanged();123+ this.#closeVendorSubscription();124+ }125+126+ async #settle(mutation: MutationHandle): Promise<void> {127+ const generation = ++this.#writeGeneration;128+ let localPending = true;129+ this.#diagnostics.pendingLocalWrites += 1;130+ this.#diagnosticsChanged();131+ this.#set({ status: "ready", durability: "none", error: null });132+ try {133+ await mutation.wait({ tier: "local" });134+ localPending = false;135+ this.#diagnostics.pendingLocalWrites -= 1;136+ this.#diagnostics.localWaitCalls += 1;137+ this.#diagnosticsChanged();138+ if (generation === this.#writeGeneration) {139+ this.#set({ durability: "local", error: null });140+ }141+ if (this.#syncConfigured) {142+ this.#diagnostics.pendingGlobalWrites += 1;143+ this.#diagnosticsChanged();144+ void mutation.wait({ tier: "global" }).then(145+ () => {146+ if (generation === this.#writeGeneration) {147+ this.#set({ durability: "global", error: null });148+ }149+ },150+ (error) => {151+ this.#diagnostics.mutationErrors += 1;152+ this.#diagnosticsChanged();153+ this.#fail(error);154+ },155+ ).finally(() => {156+ this.#diagnostics.pendingGlobalWrites -= 1;157+ this.#diagnosticsChanged();158+ });159+ }160+ } catch (error) {161+ if (localPending) this.#diagnostics.pendingLocalWrites -= 1;162+ this.#diagnostics.mutationErrors += 1;163+ this.#diagnosticsChanged();164+ this.#fail(error);165+ throw error;166+ }167+ }168+169+ #openVendorSubscription(): void {170+ if (this.#vendorUnsubscribe) return;171+ try {172+ this.#vendorUnsubscribe = this.#db.subscribeAll(referenceApp.schema.tasks, (delta) => {173+ this.#snapshot = {174+ ...this.#snapshot,175+ status: "ready",176+ tasks: delta.all as ChecklistTask[],177+ error: null,178+ };179+ this.#emit();180+ });181+ this.#diagnostics.activeVendorSubscriptions += 1;182+ this.#diagnostics.totalVendorSubscriptions += 1;183+ this.#diagnosticsChanged();184+ } catch (error) {185+ this.#fail(error);186+ }187+ }188+189+ #closeVendorSubscription(): void {190+ if (!this.#vendorUnsubscribe) return;191+ this.#vendorUnsubscribe();192+ this.#diagnostics.unsubscribeCalls += 1;193+ this.#vendorUnsubscribe = null;194+ this.#diagnostics.activeVendorSubscriptions -= 1;195+ this.#diagnosticsChanged();196+ }197+198+ #set(change: Partial<ChecklistSnapshot>): void {199+ this.#snapshot = { ...this.#snapshot, ...change };200+ if (change.durability) {201+ this.#diagnostics.lastWriteDurability = change.durability;202+ this.#diagnosticsChanged();203+ }204+ this.#emit();205+ }206+207+ #fail(error: unknown): void {208+ this.#snapshot = {209+ ...this.#snapshot,210+ status: "error",211+ durability: "failed",212+ error: error instanceof Error ? error.message : String(error),213+ };214+ this.#diagnostics.lastWriteDurability = "failed";215+ this.#diagnosticsChanged();216+ this.#emit();217+ }218+219+ #emit(): void {220+ for (const listener of this.#listeners) listener();221+ }222+}
A apps/reference/src/_lofi/checklist-store_test.ts +255 -0
@@ -0,0 +1,255 @@
1+import type { Db, MutationErrorEvent } from "jazz-tools";2+import { ChecklistStore, type RuntimeDiagnostics } from "./checklist-store.ts";3+import { assert, assertCount } from "./test-assert.ts";4+5+const test = (globalThis as unknown as {6+ Deno: { test(name: string, body: () => void | Promise<void>): void };7+}).Deno.test;8+9+function diagnostics(): RuntimeDiagnostics {10+ return {11+ storageState: "persistent-driver-open",12+ clientsCreated: 1,13+ activeClients: 1,14+ activeConsumers: 0,15+ activeVendorSubscriptions: 0,16+ totalVendorSubscriptions: 0,17+ activeMutationListeners: 1,18+ totalMutationListeners: 1,19+ unsubscribeCalls: 0,20+ localWaitCalls: 0,21+ pendingLocalWrites: 0,22+ pendingGlobalWrites: 0,23+ lastWriteDurability: "none",24+ mutationErrors: 0,25+ };26+}27+28+function fakeDb(options: { allowGlobal?: boolean } = {}) {29+ let subscriber: ((delta: { all: unknown[] }) => void) | null = null;30+ let unsubscribeCalls = 0;31+ let rejectNextWrite: Error | null = null;32+ let deferredNextWrite: {33+ promise: Promise<void>;34+ resolve(): void;35+ reject(error: Error): void;36+ } | null = null;37+ let globalWaitCalls = 0;38+ const writes: Array<{ operation: "insert" | "update" | "delete"; value: unknown }> = [];39+40+ function handle() {41+ const deferredWrite = deferredNextWrite;42+ deferredNextWrite = null;43+ return {44+ wait: ({ tier }: { tier: "local" | "global" }) => {45+ if (tier === "global") {46+ globalWaitCalls += 1;47+ if (!options.allowGlobal) {48+ return Promise.reject(new Error("unconfigured stores must not request global waits"));49+ }50+ return Promise.resolve();51+ }52+ if (tier === "local" && rejectNextWrite) {53+ const error = rejectNextWrite;54+ rejectNextWrite = null;55+ return Promise.reject(error);56+ }57+ if (tier === "local" && deferredWrite) return deferredWrite.promise;58+ return Promise.resolve();59+ },60+ };61+ }62+63+ const value = {64+ subscribeAll(_collection: unknown, callback: (delta: { all: unknown[] }) => void) {65+ subscriber = callback;66+ return () => {67+ unsubscribeCalls += 1;68+ subscriber = null;69+ };70+ },71+ insert(_collection: unknown, next: unknown) {72+ writes.push({ operation: "insert", value: next });73+ return handle();74+ },75+ update(_collection: unknown, id: string, next: unknown) {76+ writes.push({ operation: "update", value: { id, next } });77+ return handle();78+ },79+ delete(_collection: unknown, id: string) {80+ writes.push({ operation: "delete", value: id });81+ return handle();82+ },83+ } as unknown as Db;84+85+ return {86+ value,87+ publish(all: unknown[]) {88+ assert(subscriber, "expected an active vendor subscription");89+ subscriber({ all });90+ },91+ failNextWrite(message: string) {92+ rejectNextWrite = new Error(message);93+ },94+ deferNextWrite() {95+ let resolve!: () => void;96+ let reject!: (error: Error) => void;97+ const promise = new Promise<void>((accept, decline) => {98+ resolve = accept;99+ reject = decline;100+ });101+ deferredNextWrite = { promise, resolve, reject };102+ return deferredNextWrite;103+ },104+ writes,105+ globalWaitCalls: () => globalWaitCalls,106+ unsubscribeCalls: () => unsubscribeCalls,107+ };108+}109+110+test("two consumers share one task subscription and clean up idempotently", () => {111+ const db = fakeDb();112+ const counts = diagnostics();113+ const store = new ChecklistStore(db.value, counts);114+ let firstUpdates = 0;115+ let secondUpdates = 0;116+117+ const stopFirst = store.subscribe(() => firstUpdates += 1);118+ const stopSecond = store.subscribe(() => secondUpdates += 1);119+ assertCount(counts.activeConsumers, 2, "both component consumers must be counted");120+ assertCount(counts.activeVendorSubscriptions, 1, "one query should have one subscription");121+ assertCount(counts.totalVendorSubscriptions, 1, "the underlying query should open once");122+123+ db.publish([{124+ id: "task-1",125+ text: "shared",126+ completed: false,127+ createdAt: new Date(0),128+ }]);129+ assert(store.getSnapshot().status === "ready", "the first query result must end loading");130+ assert(store.getSnapshot().tasks.length === 1, "the query result must be published");131+ assert(firstUpdates > 0 && secondUpdates > 0, "both roots must receive reactive updates");132+133+ stopFirst();134+ assertCount(counts.activeConsumers, 1, "unmounting one root must leave the other active");135+ assertCount(counts.activeVendorSubscriptions, 1, "the remaining consumer retains the query");136+ stopFirst();137+ assertCount(counts.activeConsumers, 1, "unsubscribe must be idempotent");138+ stopSecond();139+ assertCount(counts.activeConsumers, 0, "the final unmount must remove all consumers");140+ assertCount(counts.activeVendorSubscriptions, 0, "the final unmount must close the query");141+ assertCount(db.unsubscribeCalls(), 1, "the vendor unsubscribe must run exactly once");142+ assertCount(counts.unsubscribeCalls, 1, "cleanup must remain externally diagnosable");143+});144+145+test("every checklist mutation exposes pending work and waits for local durability", async () => {146+ const db = fakeDb();147+ const counts = diagnostics();148+ const store = new ChecklistStore(db.value, counts);149+150+ const deferred = db.deferNextWrite();151+ const first = store.create("first");152+ await Promise.resolve();153+ assertCount(counts.pendingLocalWrites, 1, "an unsettled local write must be observable");154+ assert(String(counts.lastWriteDurability) === "none", "pending work must not claim durability");155+ deferred.resolve();156+ await first;157+ assertCount(counts.pendingLocalWrites, 0, "a retained local write must leave no pending work");158+ await store.update("task-1", "edited");159+ await store.setCompleted("task-1", true);160+ await store.delete("task-1");161+162+ assertCount(counts.localWaitCalls, 4, "each accepted mutation must await local durability");163+ assert(164+ db.writes.map((write) => write.operation).join(",") === "insert,update,update,delete",165+ "create, edit, complete, and delete must reach the Jazz boundary",166+ );167+ assert(store.getSnapshot().durability === "local", "the UI may claim local after the wait");168+ assert(169+ String(counts.lastWriteDurability) === "local",170+ "diagnostics must expose local durability",171+ );172+ assertCount(173+ db.globalWaitCalls(),174+ 0,175+ "an unconfigured store must never request global durability",176+ );177+});178+179+test("configured sync advances the latest write from local to global durability", async () => {180+ const db = fakeDb({ allowGlobal: true });181+ const store = new ChecklistStore(db.value, diagnostics(), true);182+ await store.create("sync after reconnect");183+ await Promise.resolve();184+ assert(185+ store.getSnapshot().durability === "global",186+ "the public mutation handle must make completed global durability visible",187+ );188+ assertCount(db.globalWaitCalls(), 1, "configured sync must request global durability");189+});190+191+test("local write failures remain visible and reject the mutation", async () => {192+ const db = fakeDb();193+ const store = new ChecklistStore(db.value, diagnostics());194+ db.failNextWrite("disk full");195+ let rejected = false;196+ try {197+ await store.create("cannot persist");198+ } catch {199+ rejected = true;200+ }201+ assert(rejected, "a failed local wait must reject the public mutation");202+ assert(store.getSnapshot().status === "error", "a failed local wait must reach UI state");203+ assert(store.getSnapshot().error === "disk full", "the durable-write cause must remain visible");204+});205+206+test("a superseded local-write failure remains visible", async () => {207+ const db = fakeDb();208+ const counts = diagnostics();209+ const store = new ChecklistStore(db.value, counts);210+ const firstWrite = db.deferNextWrite();211+212+ const firstMutation = store.create("first");213+ await Promise.resolve();214+ await store.create("newer");215+ firstWrite.reject(new Error("first write lost"));216+217+ let rejected = false;218+ try {219+ await firstMutation;220+ } catch {221+ rejected = true;222+ }223+ assert(rejected, "the superseded mutation promise must still reject");224+ assert(store.getSnapshot().status === "error", "the stale rejection must reach UI state");225+ assert(store.getSnapshot().durability === "failed", "the stale rejection must be durable state");226+ assert(store.getSnapshot().error === "first write lost", "the stale cause must remain visible");227+ assertCount(counts.mutationErrors, 1, "the stale failure must remain diagnosable");228+});229+230+test("retrying after failure clears the error status immediately", async () => {231+ const db = fakeDb();232+ const store = new ChecklistStore(db.value, diagnostics());233+ db.failNextWrite("disk full");234+ await store.create("fails").catch(() => undefined);235+ const retryWrite = db.deferNextWrite();236+237+ const retry = store.create("retry");238+ await Promise.resolve();239+ assert(store.getSnapshot().status === "ready", "retry must leave the failed list status");240+ assert(store.getSnapshot().error === null, "retry must not render a null error message");241+ assert(store.getSnapshot().durability === "none", "retry is pending until local durability");242+ retryWrite.resolve();243+ await retry;244+});245+246+test("public mutation errors update observable diagnostics", () => {247+ const counts = diagnostics();248+ const store = new ChecklistStore(fakeDb().value, counts);249+ store.reportMutationError({250+ code: "WriteRejected",251+ reason: "permission denied",252+ } as MutationErrorEvent);253+ assertCount(counts.mutationErrors, 1, "mutation errors must be counted");254+ assert(store.getSnapshot().error?.includes("permission denied"), "the reason must reach the UI");255+});
A apps/reference/src/_lofi/config.ts +21 -0
R apps/prototype/src/runtime/device-capabilities.ts → apps/reference/src/_lofi/device-capabilities.ts +0 -0

No content changes.

A apps/reference/src/_lofi/device-capabilities_test.ts +34 -0
A apps/reference/src/_lofi/inspector.ts +208 -0
@@ -0,0 +1,208 @@
1+export const DEVELOPMENT_INSPECTOR_MARKER = "lofi-development-inspector";2+3+export type InspectorSnapshot = {4+ identity: {5+ state: "device-local key active" | "anonymous" | "external" | "unavailable";6+ backup: "blocked by alpha security review" | "configured" | "unavailable";7+ };8+ storage: {9+ driver: "persistent requested" | "persistent open" | "failed";10+ persistence: "granted" | "not granted" | "unavailable" | "error";11+ fallback: "none" | "explicit memory" | "unavailable";12+ };13+ sync: {14+ mode: "local-only" | "managed configured";15+ transport: "paused by inspector" | "live detail unavailable" | "not configured";16+ pendingLocalWrites: number;17+ pendingGlobalWrites: number;18+ lastWrite: "none" | "local" | "global" | "failed";19+ };20+ runtime: {21+ clients: number;22+ consumers: number;23+ vendorSubscriptions: number;24+ mutationListeners: number;25+ mutationErrors: number;26+ };27+ multiTab: {28+ role: "unavailable" | "leader" | "follower";29+ detail: string;30+ };31+};32+33+export type InspectorAdapter = {34+ readSnapshot(): InspectorSnapshot | Promise<InspectorSnapshot>;35+ subscribe(listener: () => void): () => void;36+ setTransportPaused(paused: boolean): Promise<void>;37+ restartClient(): Promise<void>;38+ clearLocalReplica(): Promise<void>;39+};40+41+export type InspectorRow = {42+ label: string;43+ value: string;44+};45+46+export function inspectorRows(snapshot: InspectorSnapshot): InspectorRow[] {47+ return [48+ { label: "Identity", value: snapshot.identity.state },49+ { label: "Auth backup", value: snapshot.identity.backup },50+ { label: "Storage driver", value: snapshot.storage.driver },51+ { label: "Persistence", value: snapshot.storage.persistence },52+ { label: "Storage fallback", value: snapshot.storage.fallback },53+ { label: "Sync mode", value: snapshot.sync.mode },54+ { label: "Transport", value: snapshot.sync.transport },55+ { label: "Pending lofi local", value: String(snapshot.sync.pendingLocalWrites) },56+ { label: "Pending lofi global", value: String(snapshot.sync.pendingGlobalWrites) },57+ { label: "Last write", value: snapshot.sync.lastWrite },58+ { label: "Clients", value: String(snapshot.runtime.clients) },59+ { label: "Consumers", value: String(snapshot.runtime.consumers) },60+ { label: "Vendor subscriptions", value: String(snapshot.runtime.vendorSubscriptions) },61+ { label: "Mutation listeners", value: String(snapshot.runtime.mutationListeners) },62+ { label: "Mutation errors", value: String(snapshot.runtime.mutationErrors) },63+ { label: "Multi-tab role", value: snapshot.multiTab.role },64+ { label: "Multi-tab detail", value: snapshot.multiTab.detail },65+ ];66+}67+68+function element<K extends keyof HTMLElementTagNameMap>(69+ document: Document,70+ name: K,71+ text?: string,72+): HTMLElementTagNameMap[K] {73+ const node = document.createElement(name);74+ if (text !== undefined) node.textContent = text;75+ return node;76+}77+78+export function mountInspector(79+ adapter: InspectorAdapter,80+ options: { document?: Document } = {},81+): { element: HTMLElement; dispose(): void } {82+ const document = options.document ?? globalThis.document;83+ if (!document?.body) {84+ throw new Error("lofi inspector requires a browser document body");85+ }86+87+ const host = element(document, "aside");88+ host.dataset.lofiInspector = DEVELOPMENT_INSPECTOR_MARKER;89+ host.setAttribute("aria-label", "lofi developer inspector");90+ const shadow = host.attachShadow({ mode: "open" });91+ const style = element(document, "style");92+ style.textContent = `93+ :host { all: initial; color: #ecf7f1; font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }94+ section { position: fixed; right: 12px; bottom: 12px; z-index: 2147483647; width: min(390px, calc(100vw - 24px)); max-height: min(660px, calc(100vh - 24px)); overflow: auto; border: 1px solid #587066; border-radius: 10px; background: #101b17f2; box-shadow: 0 18px 54px #0008; }95+ header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; position: sticky; top: 0; background: #101b17; }96+ h2 { font: 700 13px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; margin: 0; }97+ button { border: 1px solid #6f8d80; border-radius: 6px; color: inherit; background: #1b3028; padding: 6px 8px; font: inherit; cursor: pointer; }98+ button:disabled { cursor: wait; opacity: .55; }99+ [data-body][hidden] { display: none; }100+ dl { display: grid; grid-template-columns: minmax(120px, .8fr) minmax(0, 1.2fr); margin: 0; padding: 0 12px 10px; }101+ dt, dd { border-top: 1px solid #ffffff18; margin: 0; padding: 5px 0; overflow-wrap: anywhere; }102+ dt { color: #a8c3b7; }103+ .actions { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 12px; }104+ [role=status] { color: #f0c47c; min-height: 1.4em; margin: 0; padding: 0 12px 10px; }105+ `;106+ const panel = element(document, "section");107+ const header = element(document, "header");108+ const title = element(document, "h2", "lofi dev inspector");109+ const toggle = element(document, "button", "Hide");110+ toggle.type = "button";111+ toggle.setAttribute("aria-expanded", "true");112+ const body = element(document, "div");113+ body.dataset.body = "";114+ const list = element(document, "dl");115+ const actions = element(document, "div");116+ actions.className = "actions";117+ const offline = element(document, "button", "Simulate transport offline");118+ const restart = element(document, "button", "Restart client");119+ const clear = element(document, "button", "Clear local replica");120+ const status = element(document, "p");121+ status.setAttribute("role", "status");122+ for (const button of [offline, restart, clear]) {123+ button.type = "button";124+ actions.append(button);125+ }126+ header.append(title, toggle);127+ body.append(list, actions, status);128+ panel.append(header, body);129+ shadow.append(style, panel);130+ document.body.append(host);131+132+ let active = true;133+ let busy = false;134+ let current: InspectorSnapshot | null = null;135+ let refreshGeneration = 0;136+ const setBusy = (value: boolean) => {137+ busy = value;138+ for (const button of [offline, restart, clear]) button.disabled = value;139+ };140+ const render = (snapshot: InspectorSnapshot) => {141+ current = snapshot;142+ list.replaceChildren();143+ for (const row of inspectorRows(snapshot)) {144+ list.append(element(document, "dt", row.label), element(document, "dd", row.value));145+ }146+ offline.textContent = snapshot.sync.transport === "paused by inspector"147+ ? "Resume cloud transport"148+ : snapshot.sync.mode === "local-only"149+ ? "Transport pause unavailable in local-only"150+ : "Pause cloud transport";151+ offline.disabled = busy || snapshot.sync.mode === "local-only";152+ };153+ const refresh = async () => {154+ const generation = ++refreshGeneration;155+ const snapshot = await adapter.readSnapshot();156+ if (active && generation === refreshGeneration) render(snapshot);157+ };158+ const runAction = async (name: string, action: () => Promise<void>) => {159+ if (busy) return;160+ setBusy(true);161+ status.textContent = `${name}…`;162+ try {163+ await action();164+ status.textContent = `${name} complete.`;165+ await refresh();166+ } catch {167+ status.textContent =168+ `${name} failed. Run deno task doctor, apply the first action, then retry.`;169+ } finally {170+ setBusy(false);171+ }172+ };173+174+ toggle.addEventListener("click", () => {175+ body.hidden = !body.hidden;176+ toggle.textContent = body.hidden ? "Show" : "Hide";177+ toggle.setAttribute("aria-expanded", String(!body.hidden));178+ });179+ offline.addEventListener("click", () => {180+ const nextPaused = current?.sync.transport !== "paused by inspector";181+ void runAction(182+ nextPaused ? "Cloud transport pause" : "Cloud transport resume",183+ () => adapter.setTransportPaused(nextPaused),184+ );185+ });186+ restart.addEventListener("click", () => void runAction("Client restart", adapter.restartClient));187+ clear.addEventListener("click", () => {188+ const confirmed = globalThis.confirm?.(189+ "Clear this development replica? The device-local identity is preserved, but unsynced local data is deleted.",190+ ) === true;191+ if (confirmed) void runAction("Local replica clear", adapter.clearLocalReplica);192+ });193+194+ const unsubscribe = adapter.subscribe(() => void refresh());195+ void refresh().catch(() => {196+ status.textContent =197+ "Inspector snapshot failed. Run deno task doctor, apply the first action, then reload.";198+ });199+ return {200+ element: host,201+ dispose() {202+ if (!active) return;203+ active = false;204+ unsubscribe();205+ host.remove();206+ },207+ };208+}
A apps/reference/src/_lofi/inspector_test.ts +41 -0
@@ -0,0 +1,41 @@
1+import { inspectorRows, type InspectorSnapshot } from "./inspector.ts";2+3+const test = (globalThis as unknown as {4+ Deno: { test(name: string, body: () => void | Promise<void>): void };5+}).Deno.test;6+7+function assert(condition: unknown, message: string): asserts condition {8+ if (!condition) throw new Error(message);9+}10+11+const snapshot: InspectorSnapshot = {12+ identity: { state: "device-local key active", backup: "blocked by alpha security review" },13+ storage: { driver: "persistent open", persistence: "granted", fallback: "none" },14+ sync: {15+ mode: "managed configured",16+ transport: "live detail unavailable",17+ pendingLocalWrites: 1,18+ pendingGlobalWrites: 2,19+ lastWrite: "local",20+ },21+ runtime: {22+ clients: 1,23+ consumers: 2,24+ vendorSubscriptions: 1,25+ mutationListeners: 1,26+ mutationErrors: 0,27+ },28+ multiTab: {29+ role: "unavailable",30+ detail: "Jazz alpha.53 exposes no supported leader/follower signal",31+ },32+};33+34+test("inspector rows retain truthful unavailable and pending states", () => {35+ const rows = Object.fromEntries(inspectorRows(snapshot).map((row) => [row.label, row.value]));36+ assert(rows.Transport === "live detail unavailable", "transport precision was fabricated");37+ assert(rows["Pending lofi local"] === "1", "local pending work was omitted");38+ assert(rows["Pending lofi global"] === "2", "global pending work was omitted");39+ assert(rows["Multi-tab role"] === "unavailable", "multi-tab role was fabricated");40+ assert(!JSON.stringify(rows).includes("secret"), "inspector rows exposed a secret-shaped field");41+});
A apps/reference/src/_lofi/probe.ts +120 -0
@@ -0,0 +1,120 @@
1+import { serverUrl } from "./config.ts";2+import { readDeviceCapabilityReport } from "./device-capabilities.ts";3+import { type InspectorAdapter, type InspectorSnapshot, mountInspector } from "./inspector.ts";4+import { getRuntime, getRuntimeDiagnostics, subscribeRuntimeDiagnostics } from "./runtime.ts";5+6+export type LofiDevelopmentBridge = InspectorAdapter;7+8+declare global {9+ interface Window {10+ __LOFI_INSPECTOR__?: LofiDevelopmentBridge;11+ }12+}13+14+const stateListeners = new Set<() => void>();15+let transportPaused = false;16+17+function reloadBrowserClient(): Promise<void> {18+ globalThis.location.reload();19+ // The current document must not report completion while its replacement is20+ // still booting. Navigation destroys this pending promise with the page.21+ return new Promise(() => undefined);22+}23+24+function notifyState(): void {25+ for (const listener of stateListeners) listener();26+}27+28+async function readSnapshot(): Promise<InspectorSnapshot> {29+ const [runtime, device] = await Promise.all([30+ getRuntime(),31+ readDeviceCapabilityReport(),32+ ]);33+ const diagnostics = getRuntimeDiagnostics();34+ const authMode = runtime.db.getAuthState().authMode;35+ return {36+ identity: {37+ state: authMode === "local-first"38+ ? "device-local key active"39+ : authMode === "external"40+ ? "external"41+ : authMode === "anonymous"42+ ? "anonymous"43+ : "unavailable",44+ backup: "blocked by alpha security review",45+ },46+ storage: {47+ driver: diagnostics.storageState === "persistent-driver-open"48+ ? "persistent open"49+ : diagnostics.storageState === "persistent-requested"50+ ? "persistent requested"51+ : "failed",52+ persistence: device.persistentPermission === "not-granted"53+ ? "not granted"54+ : device.persistentPermission,55+ fallback: diagnostics.storageState === "failed" ? "unavailable" : "none",56+ },57+ sync: {58+ mode: serverUrl ? "managed configured" : "local-only",59+ transport: serverUrl60+ ? transportPaused ? "paused by inspector" : "live detail unavailable"61+ : "not configured",62+ pendingLocalWrites: diagnostics.pendingLocalWrites,63+ pendingGlobalWrites: diagnostics.pendingGlobalWrites,64+ lastWrite: diagnostics.lastWriteDurability,65+ },66+ runtime: {67+ clients: diagnostics.activeClients,68+ consumers: diagnostics.activeConsumers,69+ vendorSubscriptions: diagnostics.activeVendorSubscriptions,70+ mutationListeners: diagnostics.activeMutationListeners,71+ mutationErrors: diagnostics.mutationErrors,72+ },73+ multiTab: {74+ role: "unavailable",75+ detail: "Jazz alpha.53 exposes no supported leader/follower signal",76+ },77+ };78+}79+80+const bridge: LofiDevelopmentBridge = {81+ readSnapshot,82+ subscribe(listener) {83+ stateListeners.add(listener);84+ const unsubscribeRuntime = subscribeRuntimeDiagnostics(listener);85+ let active = true;86+ return () => {87+ if (!active) return;88+ active = false;89+ stateListeners.delete(listener);90+ unsubscribeRuntime();91+ };92+ },93+ async setTransportPaused(paused) {94+ if (!serverUrl) {95+ throw new Error("Cloud transport pause is unavailable in local-only mode");96+ }97+ const runtime = await getRuntime();98+ if (paused) await runtime.db.disconnect();99+ else await runtime.db.reconnect();100+ transportPaused = paused;101+ notifyState();102+ },103+ restartClient() {104+ return reloadBrowserClient();105+ },106+ async clearLocalReplica() {107+ const runtime = await getRuntime();108+ await runtime.db.deleteClientStorage();109+ return await reloadBrowserClient();110+ },111+};112+113+if (typeof document !== "undefined") {114+ (globalThis as typeof globalThis & Window).__LOFI_INSPECTOR__ = bridge;115+ const mounted = mountInspector(bridge);116+ import.meta.hot?.dispose(() => {117+ mounted.dispose();118+ delete (globalThis as typeof globalThis & Window).__LOFI_INSPECTOR__;119+ });120+}
R apps/prototype/src/runtime/pwa.ts → apps/reference/src/_lofi/pwa.ts +0 -0

No content changes.

R apps/prototype/src/runtime/pwa_test.ts → apps/reference/src/_lofi/pwa_test.ts +0 -0

No content changes.

R apps/prototype/src/runtime/resource-lifecycle.ts → apps/reference/src/_lofi/resource-lifecycle.ts +0 -0

No content changes.

R apps/prototype/src/runtime/resource-lifecycle_test.ts → apps/reference/src/_lofi/resource-lifecycle_test.ts +1 -8
R apps/prototype/src/runtime/lofi-runtime.ts → apps/reference/src/_lofi/runtime.ts +35 -6
@@ -1,7 +1,7 @@
11 import { BrowserAuthSecretStore, createDb, type Db } from "jazz-tools";2+import { ChecklistStore, type RuntimeDiagnostics } from "./checklist-store.ts";23 import { appId, databaseConfig } from "./config.ts";34 import { assertDurableBrowser } from "./device-capabilities.ts";4-import { NotesStore, type RuntimeDiagnostics } from "./notes-store.ts";55 import {66 AdapterLifecycle,77 createSerializedResourceState,
@@ -13,7 +13,7 @@ import {
1313 1414 export type LofiRuntime = {1515 db: Db;16- notes: NotesStore;16+ checklist: ChecklistStore;1717 diagnostics: RuntimeDiagnostics;1818 shutdown(): Promise<void>;1919 };
@@ -23,6 +23,7 @@ export const runtimeRecreatedEvent = "lofi:runtime-recreated";
2323 type RuntimeSlot = {2424 client: SerializedResourceState<Db>;2525 diagnostics: RuntimeDiagnostics;26+ diagnosticListeners: Set<() => void>;2627 };2728 2829 const slotName = "__LOFI_ALPHA53_RUNTIME__";
@@ -42,12 +43,20 @@ function slot(): RuntimeSlot {
4243 totalMutationListeners: 0,4344 unsubscribeCalls: 0,4445 localWaitCalls: 0,46+ pendingLocalWrites: 0,47+ pendingGlobalWrites: 0,48+ lastWriteDurability: "none",4549 mutationErrors: 0,4650 },51+ diagnosticListeners: new Set(),4752 };4853 return browserGlobal[slotName];4954 }5055 56+function notifyDiagnostics(state = slot()): void {57+ for (const listener of state.diagnosticListeners) listener();58+}59+5160 async function createClient(state: RuntimeSlot): Promise<Db> {5261 try {5362 assertDurableBrowser();
@@ -56,6 +65,7 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
5665 state.diagnostics.storageState = "persistent-driver-open";5766 state.diagnostics.clientsCreated += 1;5867 state.diagnostics.activeClients += 1;68+ notifyDiagnostics(state);5969 return db;6070 } catch (error) {6171 state.diagnostics.storageState = "failed";
@@ -66,6 +76,7 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
6676 async function destroyClient(state: RuntimeSlot, db: Db): Promise<void> {6777 await db.shutdown();6878 state.diagnostics.activeClients -= 1;79+ notifyDiagnostics(state);6980 }7081 7182 const adapter = new AdapterLifecycle<Db, LofiRuntime>();
@@ -73,21 +84,28 @@ let recreationPromise: Promise<LofiRuntime> | null = null;
7384 let shutdownPromise: Promise<void> | null = null;7485 7586 function attachRuntime(state: RuntimeSlot, db: Db): LofiRuntime {76- const notes = new NotesStore(db, state.diagnostics);77- const stopMutationErrors = db.onMutationError((event) => notes.reportMutationError(event));87+ const checklist = new ChecklistStore(88+ db,89+ state.diagnostics,90+ undefined,91+ () => notifyDiagnostics(state),92+ );93+ const stopMutationErrors = db.onMutationError((event) => checklist.reportMutationError(event));7894 state.diagnostics.activeMutationListeners += 1;7995 state.diagnostics.totalMutationListeners += 1;96+ notifyDiagnostics(state);8097 let active = true;8198 const runtime: LofiRuntime = {8299 db,83- notes,100+ checklist,84101 diagnostics: state.diagnostics,85102 shutdown() {86103 if (!active) return Promise.resolve();87104 active = false;88- notes.close();105+ checklist.close();89106 stopMutationErrors();90107 state.diagnostics.activeMutationListeners -= 1;108+ notifyDiagnostics(state);91109 return Promise.resolve();92110 },93111 };
@@ -107,6 +125,17 @@ export function getRuntimeDiagnostics(): RuntimeDiagnostics {
107125 return { ...slot().diagnostics };108126 }109127 128+export function subscribeRuntimeDiagnostics(listener: () => void): () => void {129+ const listeners = slot().diagnosticListeners;130+ listeners.add(listener);131+ let active = true;132+ return () => {133+ if (!active) return;134+ active = false;135+ listeners.delete(listener);136+ };137+}138+110139 export function recreateRuntime(): Promise<LofiRuntime> {111140 if (recreationPromise) return recreationPromise;112141 const state = slot();
A apps/reference/src/_lofi/test-assert.ts +7 -0
R apps/prototype/src/runtime/ui-mutation.ts → apps/reference/src/_lofi/ui-mutation.ts +0 -0

No content changes.

R apps/prototype/src/runtime/ui-mutation_test.ts → apps/reference/src/_lofi/ui-mutation_test.ts +0 -0

No content changes.

R apps/prototype/src/runtime/use-notes.ts → apps/reference/src/_lofi/use-checklist.ts +18 -19
@@ -1,17 +1,16 @@
11 import { useCallback, useEffect, useState } from "preact/hooks";2-import { getRuntime, getRuntimeDiagnostics, runtimeRecreatedEvent } from "./lofi-runtime.ts";3-import type { NotesSnapshot, RuntimeDiagnostics } from "./notes-store.ts";2+import type { ChecklistSnapshot } from "./checklist-store.ts";3+import { getRuntime, runtimeRecreatedEvent } from "./runtime.ts";44 5-const initial: NotesSnapshot = {5+const initial: ChecklistSnapshot = {66 status: "loading",7- notes: [],7+ tasks: [],88 durability: "none",99 error: null,1010 };1111 12-export function useNotes() {12+export function useChecklist() {1313 const [snapshot, setSnapshot] = useState(initial);14- const [diagnostics, setDiagnostics] = useState<RuntimeDiagnostics>(getRuntimeDiagnostics());1514 1615 useEffect(() => {1716 let cancelled = false;
@@ -24,11 +23,8 @@ export function useNotes() {
2423 setSnapshot(initial);2524 void getRuntime().then((runtime) => {2625 if (cancelled || generation !== connectionGeneration) return;27- const update = () => {28- setSnapshot(runtime.notes.getSnapshot());29- setDiagnostics(getRuntimeDiagnostics());30- };31- unsubscribe = runtime.notes.subscribe(update);26+ const update = () => setSnapshot(runtime.checklist.getSnapshot());27+ unsubscribe = runtime.checklist.subscribe(update);3228 update();3329 }, (error) => {3430 if (cancelled || generation !== connectionGeneration) return;
@@ -50,15 +46,18 @@ export function useNotes() {
5046 };5147 }, []);5248 53- const add = useCallback(async (body: string) => {54- const runtime = await getRuntime();55- await runtime.notes.add(body);49+ const create = useCallback(async (text: string) => {50+ await (await getRuntime()).checklist.create(text);5651 }, []);57-58- const update = useCallback(async (id: string, body: string) => {59- const runtime = await getRuntime();60- await runtime.notes.update(id, body);52+ const update = useCallback(async (id: string, text: string) => {53+ await (await getRuntime()).checklist.update(id, text);54+ }, []);55+ const setCompleted = useCallback(async (id: string, completed: boolean) => {56+ await (await getRuntime()).checklist.setCompleted(id, completed);57+ }, []);58+ const remove = useCallback(async (id: string) => {59+ await (await getRuntime()).checklist.delete(id);6160 }, []);6261 63- return { ...snapshot, diagnostics, add, update };62+ return { ...snapshot, create, update, setCompleted, remove };6463 }
R apps/prototype/src/runtime/use-device-capabilities.ts → apps/reference/src/_lofi/use-device-capabilities.ts +0 -0

No content changes.

A apps/reference/src/app.ts +13 -0
R apps/prototype/src/env.d.ts → apps/reference/src/env.d.ts +3 -0
A apps/reference/src/islands/ChecklistIsland.tsx +135 -0
@@ -0,0 +1,135 @@
1+import { useEffect, useState } from "preact/hooks";2+import type { ChecklistTask } from "../_lofi/checklist-store.ts";3+import { settleUiMutation } from "../_lofi/ui-mutation.ts";4+import { useChecklist } from "../_lofi/use-checklist.ts";5+import { checklistUi } from "../ui-contract.ts";6+7+export interface ChecklistIslandProps {8+ label: string;9+}10+11+type ChecklistRowProps = {12+ task: ChecklistTask;13+ update(id: string, text: string): Promise<void>;14+ setCompleted(id: string, completed: boolean): Promise<void>;15+ remove(id: string): Promise<void>;16+};17+18+function ChecklistRow({ task, update, setCompleted, remove }: ChecklistRowProps) {19+ const [editing, setEditing] = useState(false);20+ const [draft, setDraft] = useState(task.text);21+ const editId = `edit-${task.id}`;22+23+ useEffect(() => {24+ if (!editing) setDraft(task.text);25+ }, [editing, task.text]);26+27+ return (28+ <li class={task.completed ? "task task-complete" : "task"} data-task-id={task.id}>29+ <label class="task-toggle">30+ <input31+ type="checkbox"32+ checked={task.completed}33+ aria-label={checklistUi.complete(task.text)}34+ onChange={(event) =>35+ void settleUiMutation(setCompleted(task.id, event.currentTarget.checked))}36+ />37+ <span>{task.text}</span>38+ </label>39+ {editing40+ ? (41+ <form42+ class="edit-form"43+ onSubmit={(event) => {44+ event.preventDefault();45+ const next = draft.trim();46+ if (!next) return;47+ setEditing(false);48+ void settleUiMutation(update(task.id, next));49+ }}50+ >51+ <label class="visually-hidden" for={editId}>{checklistUi.edit(task.text)}</label>52+ <input53+ id={editId}54+ value={draft}55+ onInput={(event) => setDraft(event.currentTarget.value)}56+ autocomplete="off"57+ />58+ <button type="submit" aria-label={checklistUi.save(task.text)}>Save</button>59+ <button type="button" onClick={() => setEditing(false)}>Cancel</button>60+ </form>61+ )62+ : (63+ <div class="task-actions">64+ <button65+ type="button"66+ aria-label={checklistUi.edit(task.text)}67+ onClick={() => setEditing(true)}68+ >69+ Edit70+ </button>71+ <button72+ type="button"73+ class="button-danger"74+ aria-label={checklistUi.delete(task.text)}75+ onClick={() => void settleUiMutation(remove(task.id))}76+ >77+ Delete78+ </button>79+ </div>80+ )}81+ </li>82+ );83+}84+85+export default function ChecklistIsland({ label }: ChecklistIslandProps) {86+ const checklist = useChecklist();87+ const [text, setText] = useState("");88+ const inputId = `task-${label.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}`;89+90+ return (91+ <section class="island" data-island={label}>92+ <header>93+ <p class="eyebrow">{label}</p>94+ <h2>Shared checklist</h2>95+ </header>96+ <form97+ onSubmit={(event) => {98+ event.preventDefault();99+ const next = text.trim();100+ if (!next) return;101+ setText("");102+ void settleUiMutation(checklist.create(next));103+ }}104+ >105+ <label for={inputId}>{checklistUi.newItem}</label>106+ <div class="composer">107+ <input108+ id={inputId}109+ value={text}110+ onInput={(event) => setText(event.currentTarget.value)}111+ autocomplete="off"112+ />113+ <button type="submit">{checklistUi.addFrom(label)}</button>114+ </div>115+ </form>116+ <p class="state" role="status">117+ {checklist.status === "loading" && "Opening persistent storage…"}118+ {checklist.status === "error" && `${checklistUi.writeFailed} ${checklist.error}`}119+ {checklist.status === "ready" &&120+ `${checklist.tasks.length} item(s) · last write ${checklist.durability}`}121+ </p>122+ <ul aria-label={`${label} checklist`}>123+ {checklist.tasks.map((task) => (124+ <ChecklistRow125+ key={task.id}126+ task={task}127+ update={checklist.update}128+ setCompleted={checklist.setCompleted}129+ remove={checklist.remove}130+ />131+ ))}132+ </ul>133+ </section>134+ );135+}
A apps/reference/src/migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts +28 -0
A apps/reference/src/migrations/snapshots/20260715T194819-6c62fec42c35.json +20 -0
A apps/reference/src/migrations/snapshots/20260715T194947-ff85ac1d97ee.json +27 -0
A apps/reference/src/pages/index.astro +27 -0
A apps/reference/src/permissions.ts +9 -0
R apps/prototype/src/schema.ts → apps/reference/src/schema.ts +3 -2
R apps/prototype/src/styles/global.css → apps/reference/src/styles/global.css +95 -13
@@ -23,8 +23,7 @@ main {
2323 max-width: 58rem;2424 }2525 26-.eyebrow,27-.diagnostics {26+.eyebrow {2827 color: #9bc9b1;2928 font-size: 0.75rem;3029 font-weight: 800;
@@ -61,6 +60,11 @@ h1 {
6160 padding: 1.4rem;6261 }6362 63+.island h2 {64+ font-size: 1.5rem;65+ margin: 0.25rem 0 1.5rem;66+}67+6468 .device-status {6569 background: #1d2b25;6670 border: 1px solid #3b594c;
@@ -95,18 +99,15 @@ h1 {
9599 margin: 0.25rem 0 0;96100 }97101 98-.island h2 {99- font-size: 1.5rem;100- margin: 0.25rem 0 1.5rem;101-}102-103102 label {104103 display: block;105104 font-weight: 750;106105 margin-bottom: 0.5rem;107106 }108107 109-.composer {108+.composer,109+.edit-form,110+.task-actions {110111 display: flex;111112 gap: 0.5rem;112113 }
@@ -119,8 +120,10 @@ button {
119120 padding: 0.7rem 0.8rem;120121 }121122 122-input {123+input[type="text"],124+input:not([type]) {123125 background: #f3faf6;126+ color: #122019;124127 flex: 1;125128 min-width: 0;126129 }
@@ -132,6 +135,17 @@ button {
132135 font-weight: 800;133136 }134137 138+button:focus-visible,139+input:focus-visible {140+ outline: 3px solid #d5f26a;141+ outline-offset: 2px;142+}143+144+.button-danger {145+ background: transparent;146+ color: #ffd6d6;147+}148+135149 .state {136150 color: #bed0c6;137151 min-height: 1.5rem;
@@ -139,21 +153,89 @@ button {
139153 140154 ul {141155 display: grid;142- gap: 0.5rem;156+ gap: 0.65rem;143157 list-style: none;144158 padding: 0;145159 }146160 147-li {161+.task {162+ align-items: center;148163 background: #18231f;149164 border-left: 3px solid #99e5bd;165+ display: grid;166+ gap: 0.75rem;167+ grid-template-columns: minmax(0, 1fr) auto;150168 padding: 0.75rem 0.9rem;151169 }152170 171+.task-toggle {172+ align-items: center;173+ display: flex;174+ gap: 0.65rem;175+ margin: 0;176+ min-width: 0;177+}178+179+.task-toggle input {180+ height: 1.15rem;181+ width: 1.15rem;182+}183+184+.task-toggle span {185+ overflow-wrap: anywhere;186+}187+188+.task-complete .task-toggle span {189+ color: #9bc9b1;190+ text-decoration: line-through;191+}192+193+.edit-form {194+ grid-column: 1 / -1;195+}196+197+.identity-note {198+ background: #1d2b25;199+ border: 1px solid #3b594c;200+ border-radius: 0.7rem;201+ color: #bed0c6;202+ line-height: 1.6;203+ margin-top: 1rem;204+ padding: 1rem 1.4rem;205+}206+207+.identity-note strong {208+ color: #eff8f2;209+}210+211+.visually-hidden {212+ clip: rect(0 0 0 0);213+ clip-path: inset(50%);214+ height: 1px;215+ overflow: hidden;216+ position: absolute;217+ white-space: nowrap;218+ width: 1px;219+}220+153221 footer {222+ color: #9bc9b1;154223 margin-top: 2rem;155224 }156225 157-a {158- color: #99e5bd;226+@media (max-width: 40rem) {227+ main {228+ padding: 2.5rem 1rem;229+ }230+231+ .composer,232+ .edit-form,233+ .task-actions {234+ align-items: stretch;235+ flex-direction: column;236+ }237+238+ .task {239+ grid-template-columns: 1fr;240+ }159241 }
A apps/reference/src/ui-contract.ts +10 -0
A apps/reference/tests/author-boundary_test.ts +31 -0
A apps/reference/tests/testing-contract_test.ts +23 -0
R apps/prototype/tsconfig.json → apps/reference/tsconfig.json +0 -0

No content changes.

M deno.json +16 -6
@@ -2,10 +2,12 @@
22 "imports": {33 "@astrojs/check": "npm:@astrojs/check@0.9.9",44 "@astrojs/preact": "npm:@astrojs/preact@6.0.1",5+ "@nzip/lofi/testing": "./package/testing/mod.ts",56 "astro": "npm:astro@7.0.9",67 "astro/config": "npm:astro@7.0.9/config",78 "jazz-tools": "npm:jazz-tools@2.0.0-alpha.53",89 "jazz-tools/dev/vite": "npm:jazz-tools@2.0.0-alpha.53/dev/vite",10+ "playwright": "npm:playwright@1.61.1",911 "preact": "npm:preact@10.29.7",1012 "preact/hooks": "npm:preact@10.29.7/hooks",1113 "preact/jsx-dev-runtime": "npm:preact@10.29.7/jsx-dev-runtime",
@@ -22,12 +24,20 @@
2224 "tasks": {2325 "check:env": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env tools/check_env.ts",2426 "check:secrets": "deno run --allow-env=JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read --allow-run=git tools/check_secret_leaks.ts",25- "check:astro": "LOFI_SKIP_JAZZ_MANAGED=1 JAZZ_ADMIN_SECRET= BACKEND_SECRET= deno run -A npm:astro@7.0.9 check --root apps/prototype",26- "check": "deno fmt --check && deno lint && deno check tools/*.ts spikes/jazz2-baseline/*.ts spikes/jazz2-baseline/src/*.ts apps/prototype/*.ts apps/prototype/src/**/*.ts apps/prototype/src/**/*.tsx && deno task check:astro && deno task test && deno task check:env && deno task check:secrets",27- "test": "deno test --allow-read --allow-write=. --allow-run=git tools apps/prototype/src/runtime",28- "dev": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env,apps/prototype/.env --allow-write=apps/prototype/.env --allow-run tools/run_prototype.ts dev",29- "build": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env,apps/prototype/.env,apps/prototype/dist --allow-write=apps/prototype/.env,apps/prototype/dist --allow-run tools/run_prototype.ts build",30- "preview": "deno run --allow-net --allow-read=apps/prototype/dist tools/preview.ts",27+ "check:migrations": "deno run --allow-read=apps/reference/src/migrations --allow-run tools/check_migrations.ts",28+ "check:astro": "LOFI_SKIP_JAZZ_MANAGED=1 JAZZ_ADMIN_SECRET= BACKEND_SECRET= deno run -A npm:astro@7.0.9 check --root apps/reference",29+ "check": "deno fmt --check && deno lint && deno check package/**/*.ts tools/*.ts spikes/jazz2-baseline/*.ts spikes/jazz2-baseline/src/*.ts apps/reference/*.ts apps/reference/src/**/*.ts apps/reference/src/**/*.tsx && deno task check:astro && deno task check:env && deno task check:secrets && deno task check:migrations && deno task test",30+ "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package apps/reference/src/_lofi apps/reference/tests",31+ "test:update:create": "LOFI_UPDATE_SNAPSHOT=1 deno test --allow-env=LOFI_UPDATE_SNAPSHOT --allow-read --allow-write=. --allow-run package/create_core_test.ts",32+ "test": "deno task test:unit",33+ "test:browser:install": "deno run -A npm:playwright@1.61.1 install chromium",34+ "test:golden:checkout": "deno run -A tools/golden_path_checkout.ts",35+ "test:golden:create": "deno run -A tools/golden_path_create.ts",36+ "dev": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env,apps/reference/.env --allow-write=apps/reference/.env --allow-run tools/run_reference.ts dev",37+ "build": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env,apps/reference/.env,apps/reference/dist --allow-write=apps/reference/.env,apps/reference/dist --allow-run tools/run_reference.ts build",38+ "schema:validate": "deno run -A npm:jazz-tools@2.0.0-alpha.53 validate --schema-dir apps/reference/src",39+ "schema:deploy": "deno run --env-file=.env -A npm:jazz-tools@2.0.0-alpha.53 deploy --schema-dir apps/reference/src --migrations-dir apps/reference/src/migrations",40+ "preview": "deno run --allow-net --allow-read=apps/reference/dist tools/preview.ts",3141 "spike:jazz2:dev": "deno run --allow-read=spikes/jazz2-baseline/.env --allow-write=spikes/jazz2-baseline/.env --allow-run tools/run_jazz2_spike.ts local",3242 "spike:jazz2:cloud": "deno run --allow-read=spikes/jazz2-baseline/.env --allow-write=spikes/jazz2-baseline/.env --allow-run tools/run_jazz2_spike.ts cloud",3343 "spike:jazz2:build": "deno run -A npm:vite@8.0.1 build spikes/jazz2-baseline --config spikes/jazz2-baseline/vite.config.ts",
M deno.lock +30 -11
@@ -5,6 +5,8 @@
55 "npm:@astrojs/preact@6.0.1": "6.0.1_preact@10.29.7__preact-render-to-string@6.7.0_@babel+core@7.29.7",66 "npm:astro@7.0.9": "7.0.9_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1",77 "npm:jazz-tools@2.0.0-alpha.53": "2.0.0-alpha.53",8+ "npm:playwright@*": "1.61.1",9+ "npm:playwright@1.61.1": "1.61.1",810 "npm:preact@10.29.7": "10.29.7_preact-render-to-string@6.7.0",911 "npm:typescript@6.0.3": "6.0.3",1012 "npm:vite@8.0.1": "8.0.1_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1"
@@ -1475,7 +1477,7 @@
14751477 "emmet",14761478 "jsonc-parser@2.3.1",14771479 "vscode-languageserver-textdocument",1478- "vscode-languageserver-types@3.18.0",1480+ "vscode-languageserver-types",14791481 "vscode-uri"14801482 ]14811483 },
@@ -1934,6 +1936,11 @@
19341936 "tiny-inflate"19351937 ]19361938 },1939+ "fsevents@2.3.2": {1940+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",1941+ "os": ["darwin"],1942+ "scripts": true1943+ },19371944 "fsevents@2.3.3": {19381945 "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",19391946 "os": ["darwin"],
@@ -2390,6 +2397,20 @@
23902397 "picomatch@4.0.5": {23912398 "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="23922399 },2400+ "playwright-core@1.61.1": {2401+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",2402+ "bin": true2403+ },2404+ "playwright@1.61.1": {2405+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",2406+ "dependencies": [2407+ "playwright-core"2408+ ],2409+ "optionalDependencies": [2410+ "fsevents@2.3.2"2411+ ],2412+ "bin": true2413+ },23932414 "pluralize-esm@9.0.5": {23942415 "integrity": "sha512-Kb2dcpMsIutFw2hYrN0EhsAXOUJTd6FVMIxvNAkZCMQLVt9NGZqQczvGpYDxNWCZeCWLHUPxQIBudWzt1h7VVA=="23952416 },
@@ -2852,7 +2873,7 @@
28522873 "tinyglobby"28532874 ],28542875 "optionalDependencies": [2855- "fsevents"2876+ "fsevents@2.3.3"28562877 ],28572878 "bin": true28582879 },
@@ -2867,7 +2888,7 @@
28672888 "tinyglobby"28682889 ],28692890 "optionalDependencies": [2870- "fsevents"2891+ "fsevents@2.3.3"28712892 ],28722893 "optionalPeers": [28732894 "esbuild@0.28.1"
@@ -2971,7 +2992,7 @@
29712992 "dependencies": [29722993 "@vscode/l10n",29732994 "vscode-languageserver-textdocument",2974- "vscode-languageserver-types@3.17.5",2995+ "vscode-languageserver-types",29752996 "vscode-uri"29762997 ]29772998 },
@@ -2980,7 +3001,7 @@
29803001 "dependencies": [29813002 "@vscode/l10n",29823003 "vscode-languageserver-textdocument",2983- "vscode-languageserver-types@3.18.0",3004+ "vscode-languageserver-types",29843005 "vscode-uri"29853006 ]29863007 },
@@ -2989,7 +3010,7 @@
29893010 "dependencies": [29903011 "jsonc-parser@3.3.1",29913012 "vscode-languageserver-textdocument",2992- "vscode-languageserver-types@3.18.0",3013+ "vscode-languageserver-types",29933014 "vscode-nls",29943015 "vscode-uri"29953016 ]
@@ -3001,7 +3022,7 @@
30013022 "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",30023023 "dependencies": [30033024 "vscode-jsonrpc",3004- "vscode-languageserver-types@3.17.5"3025+ "vscode-languageserver-types"30053026 ]30063027 },30073028 "vscode-languageserver-textdocument@1.0.12": {
@@ -3010,9 +3031,6 @@
30103031 "vscode-languageserver-types@3.17.5": {30113032 "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="30123033 },3013- "vscode-languageserver-types@3.18.0": {3014- "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="3015- },30163034 "vscode-languageserver@9.0.1": {30173035 "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",30183036 "dependencies": [
@@ -3061,7 +3079,7 @@
30613079 "vscode-json-languageservice",30623080 "vscode-languageserver",30633081 "vscode-languageserver-textdocument",3064- "vscode-languageserver-types@3.18.0",3082+ "vscode-languageserver-types",30653083 "vscode-uri",30663084 "yaml"30673085 ],
@@ -3108,6 +3126,7 @@
31083126 "npm:@astrojs/preact@6.0.1",31093127 "npm:astro@7.0.9",31103128 "npm:jazz-tools@2.0.0-alpha.53",3129+ "npm:playwright@1.61.1",31113130 "npm:preact@10.29.7",31123131 "npm:typescript@6.0.3",31133132 "npm:vite@8.0.1"
M docs/decisions/0002-graduate-thin-preact-adapter.md +1 -1
A docs/decisions/0005-publish-one-nzip-lofi-package.md +38 -0
@@ -0,0 +1,38 @@
1+# ADR 0005: Publish one `@nzip/lofi` package2+3+Status: accepted\4+Date: 2026-07-155+6+Owner confirmation: FelineStateMachine explicitly confirmed `@nzip/lofi` as the canonical package7+after #21 and superseded the earlier `@lofi-cat/*` direction. The `@nzip` JSR scope is the owned8+nzip publishing surface and already lists the reserved `@nzip/lofi` package alongside the published9+nzip packages. Publication credentials remain a release-time secret, not repository configuration.10+11+## Decision12+13+Use `@nzip/lofi` as lofi's only JSR package. Public framework boundaries remain explicit subpath14+exports such as `@nzip/lofi/core`, `@nzip/lofi/sync`, `@nzip/lofi/auth`, `@nzip/lofi/ui`, and15+`@nzip/lofi/pwa`. Public tooling graduates under the same package, including the M2 creation command16+at `jsr:@nzip/lofi/create` and testing helpers at `@nzip/lofi/testing`.17+18+Do not publish lockstep `@lofi-cat/*` packages. Internal module boundaries and dependency direction19+remain enforceable even though those modules share one package version.20+21+## Why22+23+The prototype's modules evolve together around a fast-moving pinned Jazz alpha. One JSR package24+provides one version, one publish operation, and direct subpath imports without creating independent25+release promises before the seams have been proven by a second application.26+27+## Consequences28+29+- The M2 public create command is `deno run -A jsr:@nzip/lofi/create <name>`.30+- Consumers import framework APIs through named subpaths, not the package root.31+- Package extraction validates export boundaries, but does not create independently versioned32+ artifacts.33+- A future split requires evidence that a module needs an independent compatibility or release34+ lifecycle.35+36+## Registry evidence37+38+- [JSR `@nzip` scope](https://jsr.io/@nzip), including the reserved `@nzip/lofi` package
A docs/decisions/0006-bound-layer3-inspection-and-testing.md +80 -0
@@ -0,0 +1,80 @@
1+# 0006: Bound Layer 3 inspection and testing to truthful observable seams2+3+Status: accepted for M2 implementation\4+Date: 2026-07-15\5+Issues: #10, #126+7+## Decision8+9+Layer 3 graduates two developer surfaces:10+11+- a visible development-only inspector backed by a provisional internal snapshot/action adapter;12+- readiness-first Playwright helpers exported from the single package at `@nzip/lofi/testing`.13+14+The inspector is generated runtime machinery, not a public framework facade. `./testing` is the only15+new public package export in this layer. Broader `core`, `ui`, `sync`, `auth`, and `pwa` extraction16+still requires the M4 evidence described by ADR 0005.17+18+The inspector may report only signals lofi can observe:19+20+- device-local identity and the blocked alpha passkey-backup state;21+- requested/open/failed persistent storage plus browser persistence permission;22+- local-only or managed-configured mode;23+- lofi-wrapped per-write local/global durability and mutation failures; and24+- active client, consumer, vendor-subscription, and mutation-listener counts.25+26+The Jazz alpha pin exposes no supported live connection state, vendor queue depth, or multi-tab27+leader/follower role. Those fields remain visibly unavailable. Configuration is never presented as28+connectivity, and checklist writes are not mislabeled as the vendor's complete pending queue.29+30+Inspector transport pause/resume calls Jazz `Db.disconnect()`/`reconnect()` only when managed sync31+is configured and labels the action as a cloud-transport test seam. Real browser offline control is32+owned by `@nzip/lofi/testing` through Playwright `BrowserContext.setOffline()`. Client restart uses33+a full document reload: Jazz alpha.53 does not reliably reopen the same browser-broker namespace34+after an in-document `Db.shutdown()`/`createDb()` cycle, while a document lifecycle restart retains35+the locally durable replica. Replica clear uses `Db.deleteClientStorage()` behind a destructive36+confirmation, then reloads the document; it clears the local OPFS namespace while preserving the37+localStorage identity and warns that unsynced data may be lost.38+39+## Two-client identity rule40+41+The current permissions are creator-only. Two distinct user identities therefore cannot both read42+and edit one row, so claiming distinct-identity convergence would be false. The convergence fixture43+uses two isolated browser contexts and separate OPFS replicas with the same restored identity. The44+first context's Playwright storage state is passed directly to the second context in memory. It is45+never written to reports, traces, snapshots, logs, or screenshots because it contains the bearer46+identity secret.47+48+A truly distinct-user fixture belongs with sharing/invitation and permission-model work, not this49+testing layer.50+51+## Verification boundary52+53+Local acceptance proves readiness predicates, offline restoration in `finally`, restart/reload,54+artifact redaction, inspector actions, and production exclusion. Closing the convergence contract55+also requires a cloud-configured generated-project journey:56+57+1. seed a row and wait for global durability;58+2. boot the second isolated replica with the in-memory restored identity;59+3. take both browser contexts offline;60+4. make orthogonal edits, then reload/recreate while work remains local;61+5. restore network control in `finally` and wait for both visible snapshots to converge; and62+6. remove fixture rows and wait for global durability.63+64+Only the public `JAZZ_APP_ID`/`JAZZ_SERVER_URL` pair reaches the child application. Server secrets65+remain blank, and no configured value is retained in evidence.66+67+Production builds must contain no lofi inspector marker, global bridge, route, or chunk. Jazz's raw68+vendor inspector remains disabled.69+70+## Deferred physical-device evidence71+72+Playwright does not graduate installed-PWA lifecycle, storage eviction, airplane-mode cold launch,73+real passkey custody, or mobile background/foreground recovery. Those remain explicit M3 physical-74+device gates.75+76+## Why77+78+This boundary gives developers useful controls and deterministic tests without inventing vendor79+precision, leaking identity material, weakening creator-only permissions, or prematurely turning the80+M1 runtime into a general framework API.
M docs/devx-contract.md +117 -58
@@ -1,7 +1,7 @@
11 # lofi developer-experience contract22 3-Status: **v0 proposed**\4-Scope: **M0 contract and M1 feasibility**\3+Status: **v0 validated through the M2 source layers; combined promotion review in progress**\4+Scope: **M0 contract, M1 feasibility, and M2 DevX graduation**\55 Last reviewed: **2026-07-15**66 77 This is the product contract for lofi. Implementation choices are provisional until they satisfy
@@ -18,23 +18,28 @@ The older `init.md` is research input, not an authoritative description of Jazz
1818 - **deferred** — valuable but intentionally owned by a later milestone.1919 - **rejected** — disproved or incompatible with the product direction.2020 21-The promises marked **M1 gate: yes** must be validated, revised, or rejected before M1 merges. A21+The promises marked **M1 gate: yes** were validated, revised, or rejected before M1 merged. A2222 post-M1 promise may remain proposed only when it has a named milestone and does not affect the23-honesty of the graduated prototype.23+honesty of the graduated reference application.2424 2525 ## Two distinct journeys2626 2727 ### North-star generated-project journey2828 29-This is the eventual product experience. The generator itself is M2 work and is not claimed as an M130-deliverable.29+This is the product experience validated from M2 source. The registry command remains unavailable30+until the first explicitly authorized publish and registry-backed smoke.3131 3232 ```sh33-deno run -A jsr:@lofi/create my-app33+deno run -A jsr:@nzip/lofi/create my-app3434 cd my-app3535 deno task dev3636 ```3737 38+The JSR surface is one package, `@nzip/lofi`, with one version and publish operation. M2 validates39+the command subpaths plus `./testing`; remaining runtime subpaths such as `./core`, `./sync`,40+`./auth`, `./ui`, and `./pwa` graduate only when M4 proves their seams through another application.41+See [ADR 0005](decisions/0005-publish-one-nzip-lofi-package.md).42+3843 From there the developer makes a retained local write, reloads it, works offline, runs checks,3944 builds, previews, and opens the same stable secure origin on a physical device.4045
@@ -49,11 +54,11 @@ cd lofi
4954 deno task dev5055 ```5156 52-The graduated `apps/prototype` must then support a local write, reload, offline cold-start and edit,53-check, test, build, and preview. It does not claim observable transport reconnection or convergence54-because the pinned Jazz API exposes neither signal; that remains a later test contract. Git is a55-contributor prerequisite for this repository journey; Deno remains the only required global runtime56-for a generated application.57+The graduated M1 application, now retained as `apps/reference`, must support a local write, reload,58+offline cold-start and edit, check, test, build, and preview. It does not claim observable transport59+reconnection or convergence because the pinned Jazz API exposes neither signal; that remains a later60+test contract. Git is a contributor prerequisite for this repository journey; Deno remains the only61+required global runtime for a generated application.5762 5863 ## End-to-end journey contract5964
@@ -87,44 +92,44 @@ for a generated application.
8792 8893 ## Measurable promises8994 90-| ID | Promise | v0 budget or condition | Evidence owner | M1 gate | Status |91-| --------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------- | --------- |92-| DX-CREATE-01 | Named creation is non-interactive. | Deno is the only required global runtime; zero prompts; uncached time reported. | M2 | no | proposed |93-| DX-CMD-01 | The public command surface is small. | create, dev, doctor, check, test, build, preview only. | #2 and #6 | yes | validated |94-| DX-TTFW-01 | Generated create-to-retained-write is fast. | At most three shell commands and two minutes on the recorded machine/network. | M2 | no | proposed |95-| DX-PROTOTYPE-01 | The M1 checkout reaches a retained write quickly. | `deno task dev` to retained write within 60 seconds after cached setup. | integrated M1 prototype | yes | validated |96-| DX-START-01 | Cached development startup feels immediate. | Warm median at most 2 seconds; slowest at most 5 seconds across five samples. | #6 | yes | validated |97-| DX-HMR-01 | UI edits retain state and runtime cardinality. | Median feedback at most 300 ms; one client and one vendor subscription per shared query after five edits. | #5 and #6 | yes | validated |98-| DX-AUTHOR-01 | Product edits avoid framework plumbing. | The integrated task changes only schema, app config, page/island, style, or test files. | integrated prototype | yes | validated |99-| DX-LEAK-01 | Runtime machinery stays outside product UI. | No provider, raw client, worker, transport URL, Workbox config, or browser branch in product UI. | #5, #6, integration | yes | validated |100-| DX-LOCAL-01 | Local work does not await network/auth round trips. | Subscribed UI reflects an offline write in the same event turn or next render; reload retains it. | #7 | yes | validated |101-| DX-DUR-01 | Durable mode never silently becomes ephemeral. | Boot reports durable, unsupported, or explicitly opted-in memory mode. | #4 and #7 | yes | validated |102-| DX-SYNC-01 | Application-data transport has one lofi-owned surface. | App config selects a named adapter; product UI never constructs peers or transports. | #7 and integration | yes | validated |103-| DX-OBS-01 | Diagnostics expose only truthful signals. | Configured state and per-write durability map to public APIs; unavailable transport detail is named. | #7 | yes | validated |104-| DX-ENV-01 | Environment handling is safe by construction. | Real env ignored; allowlisted loader; server values absent from client projection and built output. | #3, #7, final build | yes | validated |105-| DX-ERROR-01 | Failures state capability, impact, and action. | No secret values; common config recovery is one edit plus rerun. | #3, #4, #6 | yes | validated |106-| DX-AUTH-01 | Identity wording matches custody/recovery. | UI identifies the device-local key, blocks the rejected alpha passkey path, and defines any future phrase as an identity bearer secret rather than data backup. | #8 | yes | revised |107-| DX-DEVICE-01 | Device/auth tests use a stable, appropriately isolated origin. | M1 proves a stable HTTPS PWA scope for OPFS; installed-app RP-ID preservation and identity isolation remain required before any replacement passkey ceremony. | #4 and #8 | yes | revised |108-| DX-DEVICE-UX-01 | Device preview is one productized command. | Primary path prints stable URL, capability report, and remediation. | M3 | no | proposed |109-| DX-OFFLINE-01 | Installed production cold-start renders retained data offline. | Airplane-mode launch renders shell and data. | feasibility #4; full M3 | no | proposed |110-| DX-BUILD-01 | Development and production share the Deno command contract. | `deno task build` and `preview`; no undocumented Node command. | #6 | yes | validated |111-| DX-TEST-01 | Local-first tests avoid hand-timed sleeps. | Readiness-based offline/two-client primitives. | feasibility #7; M2 implementation | no | proposed |95+| ID | Promise | v0 budget or condition | Evidence owner | M1 gate | Status |96+| --------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------- | --------- |97+| DX-CREATE-01 | Named creation is non-interactive. | Deno is the only required global runtime; zero prompts; uncached time reported. | M2 source; cold registry pending | no | proposed |98+| DX-CMD-01 | The public command surface is small. | create, dev, doctor, check, test, build, preview only. | #2 and #6 | yes | validated |99+| DX-TTFW-01 | Generated create-to-retained-write is fast. | At most three shell commands and two minutes on the recorded machine/network. | M2 source; cold registry pending | no | proposed |100+| DX-PROTOTYPE-01 | The M1 checkout reaches a retained write quickly. | `deno task dev` to retained write within 60 seconds after cached setup. | integrated M1 prototype | yes | validated |101+| DX-START-01 | Cached development startup feels immediate. | Warm median at most 2 seconds; slowest at most 5 seconds across five samples. | #6 | yes | validated |102+| DX-HMR-01 | UI edits retain state and runtime cardinality. | Median feedback at most 300 ms; one client and one vendor subscription per shared query after five edits. | #5 and #6 | yes | validated |103+| DX-AUTHOR-01 | Product edits avoid framework plumbing. | The integrated task changes only schema, app config, page/island, style, or test files. | integrated prototype | yes | validated |104+| DX-LEAK-01 | Runtime machinery stays outside product UI. | No provider, raw client, worker, transport URL, Workbox config, or browser branch in product UI. | #5, #6, integration | yes | validated |105+| DX-LOCAL-01 | Local work does not await network/auth round trips. | Subscribed UI reflects an offline write in the same event turn or next render; reload retains it. | #7 | yes | validated |106+| DX-DUR-01 | Durable mode never silently becomes ephemeral. | Boot reports durable, unsupported, or explicitly opted-in memory mode. | #4 and #7 | yes | validated |107+| DX-SYNC-01 | Application-data transport has one lofi-owned surface. | App config selects a named adapter; product UI never constructs peers or transports. | #7 and integration | yes | validated |108+| DX-OBS-01 | Diagnostics expose only truthful signals. | Configured state and per-write durability map to public APIs; unavailable transport detail is named. | #7 | yes | validated |109+| DX-ENV-01 | Environment handling is safe by construction. | Real env ignored; allowlisted loader; server values absent from client projection and built output. | #3, #7, final build | yes | validated |110+| DX-ERROR-01 | Failures state capability, impact, and action. | No secret values; common config recovery is one edit plus rerun. | #3, #4, #6 | yes | validated |111+| DX-AUTH-01 | Identity wording matches custody/recovery. | UI identifies the device-local key, blocks the rejected alpha passkey path, and defines any future phrase as an identity bearer secret rather than data backup. | #8 | yes | revised |112+| DX-DEVICE-01 | Device/auth tests use a stable, appropriately isolated origin. | M1 proves a stable HTTPS PWA scope for OPFS; installed-app RP-ID preservation and identity isolation remain required before any replacement passkey ceremony. | #4 and #8 | yes | revised |113+| DX-DEVICE-UX-01 | Device preview is one productized command. | Primary path prints stable URL, capability report, and remediation. | M3 | no | proposed |114+| DX-OFFLINE-01 | Installed production cold-start renders retained data offline. | Airplane-mode launch renders shell and data. | feasibility #4; full M3 | no | proposed |115+| DX-BUILD-01 | Development and production share the Deno command contract. | `deno task build` and `preview`; no undocumented Node command. | #6 | yes | validated |116+| DX-TEST-01 | Local-first tests avoid hand-timed sleeps. | Readiness-based offline/two-client primitives. | #7 and M2 Layer 3 | no | validated |112117 113118 ## Canonical command surface and output contract114119 115-These are product outputs, not claims that every command is implemented at Contract v0. The M1116-prototype must implement `dev`, `check`, `test`, `build`, and `preview`; `create` and the complete117-`doctor` experience graduate in M2.120+These outputs are implemented and verified from M2 source. The `create` registry invocation remains121+conditional on an explicitly authorized publish and registry-backed smoke; the generated command122+surface itself is covered by the source-backed golden journey.118123 119-| Command | Success output must contain | Failure output must contain |120-| ------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------- |121-| `deno run -A jsr:@lofi/create <name>` | Created path and exact `cd`/`deno task dev` next steps. | Conflicting input/path, unchanged-files guarantee, and corrective command. |122-| `deno task dev` | Usable URL plus storage, identity, sync, and PWA states. | Failed subsystem, developer impact, and one remediation. |123-| `deno task doctor` | Versioned capability/configuration table without values. | Invalid/unsupported item plus remediation; nonzero exit for blockers. |124-| `deno task check` | Checks run and concise pass summary. | First failing check and rerun command. |125-| `deno task test` | Suites, durations, and retained failure-artifact location. | Failing scenario and artifact location without fixed-sleep advice. |126-| `deno task build` | Output path, route count, and secret-scan result. | Failed internal tool/adapter and supported fallback action. |127-| `deno task preview` | Production URL and build identity. | Missing/stale build and exact build command. |124+| Command | Success output must contain | Failure output must contain |125+| ------------------------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------- |126+| `deno run -A jsr:@nzip/lofi/create <name>` | Created path and exact `cd`/`deno task dev` next steps. | Conflicting input/path, unchanged-files guarantee, and corrective command. |127+| `deno task dev` | Usable URL plus storage, identity, sync, and PWA states. | Failed subsystem, developer impact, and one remediation. |128+| `deno task doctor` | Versioned capability/configuration table without values. | Invalid/unsupported item plus remediation; nonzero exit for blockers. |129+| `deno task check` | Checks run and concise pass summary. | First failing check and rerun command. |130+| `deno task test` | Suites, durations, and retained failure-artifact location. | Failing scenario and artifact location without fixed-sleep advice. |131+| `deno task build` | Output path, route count, and secret-scan result. | Failed internal tool/adapter and supported fallback action. |132+| `deno task preview` | Production URL and build identity. | Missing/stale build and exact build command. |128133 129134 Example successful development output:130135
@@ -155,14 +160,17 @@ my-app/
155160 ├── public/156161 ├── src/157162 │ ├── schema.ts163+│ ├── permissions.ts158164 │ ├── app.ts159165 │ ├── pages/160-│ └── islands/166+│ ├── islands/167+│ ├── styles/168+│ └── _lofi/ # generated; not author-edited161169 └── tests/162170 ```163171 164-The exact layout may change during M1 only when verified toolchain behavior requires it or the165-change removes an author-facing concept.172+The exact layout may change only when verified toolchain behavior requires it or the change removes173+an author-facing concept. `src/_lofi/` is generated machinery, not an author-editing surface.166174 167175 ## Author boundary168176
@@ -170,6 +178,7 @@ change removes an author-facing concept.
170178 | -------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |171179 | Product UI/pages/islands | lofi components/hooks and application types only. | Raw Jazz, worker, transport, Workbox, and capability branching are forbidden. |172180 | `schema.ts` | Application schema declarations. | Direct pinned Jazz 2 schema declarations are allowed in the M1 prototype until #7 determines whether a lofi-owned schema facade is honest and affordable. |181+| `permissions.ts` | Application access policy next to the schema. | Direct pinned Jazz 2 permission declarations are allowed; Jazz tooling watches this location. |173182 | `app.ts` | Named lofi configuration and composition. | Raw vendor setup is allowed only inside spike controls, not the graduated prototype. |174183 | Generated/internal runtime | Vendor clients, workers, storage, sync, auth, and PWA wiring. | May change across alpha pins without changing product UI. |175184 | Escape-hatch module | Explicitly isolated unsupported vendor access. | Never generated by default; no compatibility promise before framework extraction. |
@@ -208,9 +217,9 @@ Missing public configuration selects local-only mode if #7 verifies support. Par
208217 cloud configuration is `invalid`, stops the relevant command, and cannot be projected into client209218 config. Server-only values without client configuration remain isolated and produce a warning.210219 211-Root-repository ignore and leak tests do not satisfy the generated-project/release-artifact portion212-of #3. That acceptance item remains open until the M1 prototype produces a build to inspect; the M2213-generator gets its own fixture inspection.220+Root-repository ignore and leak tests are supplemented by generated-project fixture inspection,221+production build scanning, and retained-artifact scans. Publication still requires a fresh registry222+smoke and release-artifact review; source acceptance does not imply that a package exists on JSR.214223 215224 ## Escape-hatch policy216225
@@ -268,9 +277,59 @@ generator gets its own fixture inspection.
268277 269278 ## Graduation rule270279 271-M1 graduates exactly one integrated `apps/prototype` containing the approved Jazz version, Preact272-strategy, Astro/Deno path, durability policy, and identity model. Rejected runtime alternatives are273-removed from the final tree; their evidence remains in decisions and merge-commit history.274-275-Package extraction, the generator, full inspector/testing SDK, and production PWA hardening remain276-M2 or later work.280+M1 graduated exactly one integrated application, now named `apps/reference`, containing the approved281+Jazz version, Preact strategy, Astro/Deno path, durability policy, and identity model. Rejected282+runtime alternatives are removed from the final tree; their evidence remains in decisions and283+merge-commit history.284+285+M2 has graduated the single-package command surface, generator, development inspector, and286+readiness-first browser-testing helpers. Production PWA/device hardening remains M3 work; remaining287+runtime package seams and a second consumer remain M4 work.288+289+## M2 layered graduation290+291+M2 integrated into the `m2` branch as three separately reviewed layers. Each layer passed its own292+checks and independent pull-request review before merge. The combined branch is now being293+re-reviewed before it may merge into `dev`.294+295+Layer verification runs outside the product and framework surface. An independent agent checks the296+exact pushed head in a detached Git worktree, runs `deno task check` plus the layer's applicable297+golden journey, and confirms that the retained report names the tested commit. The pull request is a298+communication channel for the monitoring reviewer, not a reason to impose a permanent per-push CI299+workflow on the framework or generated applications. A layer may merge only when both the clean-room300+result and independent review accept that exact head. The final `m2` combination repeats the same301+clean-room and review boundary before promotion to `dev`.302+303+| Layer | Issues | Contract result |304+| ---------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------- |305+| 1 — reference and checkout journey | #9; tracks #11 | Honest checklist CRUD, explicit author/generated boundary, and reusable checkout-mode golden-path evidence. |306+| 2 — public command path | #13, #14; source gate for #11 | Non-interactive creation plus actionable `dev`/`doctor`; the same journey runs against generated output. |307+| 3 — testing and inspection | #12, #10 | Reusable offline/convergence controls and truthful developer diagnostics without leaking machinery into product UI. |308+309+Layer 1 alone did not close #11: checkout-mode evidence was necessary, and Layer 2 subsequently310+passed the source-backed generated-project journey. Registry-backed acceptance remains conditional311+on explicit publication authorization. Remaining abstraction leaks are tracked in312+`docs/m2-abstraction-leaks.md` rather than hidden behind premature package APIs.313+314+Layer 2 has two distinct package gates. Before publication, the generated-project journey invokes315+the real local `./create` entrypoint and uses an explicitly test-only file-URL package override so316+all generated commands execute from the exact source under review. The default generated output317+still pins `jsr:@nzip/lofi` to the manifest version. This source gate does not claim that the318+registry command exists: after source acceptance and explicit publication authorization, a319+fresh-directory smoke must invoke `deno run -A jsr:@nzip/lofi/create <name>` before #13 and #11 can320+close.321+322+Layer 3 keeps inspection and testing semantics distinct. The generated development inspector may323+pause a configured Jazz transport, restart the client, and confirmation-gate an OPFS replica clear;324+it does not call transport pause "browser offline" and does not infer live connectivity, vendor325+queue depth, or leader/follower role. Real network control and readiness-first two-client fixtures326+graduate at `@nzip/lofi/testing`. The convergence fixture uses two isolated OPFS replicas with one327+identity restored only in memory because the current creator-only permissions do not allow two328+distinct identities to edit the same row. See329+[ADR 0006](decisions/0006-bound-layer3-inspection-and-testing.md).330+331+Layer 1 keeps M1's `lofi-prototype-<appId>` OPFS namespace so existing device rows remain visible to332+the reviewed notes-to-tasks lens. Jazz discovers that lens and its snapshots beside `schema.ts` in333+`apps/reference/src/migrations`. `deno task check:migrations` couples the current schema hash,334+incoming migration edge, and snapshot; `deno task schema:deploy` is the explicit cloud publication335+step for the schema, migration, and permissions bundle.
A docs/m2-abstraction-leaks.md +24 -0
@@ -0,0 +1,24 @@
1+# M2 abstraction-leak ledger2+3+Status: active during M2\4+Last reviewed: 2026-07-155+6+This ledger keeps the Layer 1 reference application honest. A listed leak is not a supported lofi7+API; it is a concrete input to the layer that owns removing or productizing it.8+9+| Leak retained in Layer 1 | Why it remains | Owning issue | Graduation evidence |10+| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- |11+| Repository-root wrappers still own the checkout journey; generated commands now live in `@nzip/lofi`. | The generated path is implemented without repository-relative paths, but this leak remains listed until exact-head generated golden evidence is retained. | #13, #14 | A generated directory runs its own documented tasks without repository-relative paths. |12+| `schema.ts` and `permissions.ts` import the pinned Jazz schema DSL directly. | M1 rejected inventing a second schema language without a proven ergonomic gain. | Later API extraction after M2 | A second real application demonstrates a smaller stable facade, or the direct DSL is accepted explicitly. |13+| `app.ts` uses a provisional plain configuration object and the Jazz schema value. | Layer 1 needs configuration-as-data but does not yet have two consumers that justify a public factory or exported type. | #13 | The generator emits the approved configuration without raw client, transport, or environment plumbing. |14+| Live socket state, vendor queue depth, and multi-tab role remain unavailable. | Jazz alpha.53 exposes mutation durability but no supported signal for these details; ADR 0006 accepts explicit unavailability instead of fabricated precision. | Jazz baseline review | A later Jazz pin exposes supported signals, or the unavailable boundary remains an intentional product contract. |15+16+## Graduated in Layer 317+18+| Former leak | Graduated surface | Evidence |19+| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |20+| Runtime cardinality and lifecycle evidence used a development-only probe. | The generated dev-only inspector owns redacted diagnostics and safe actions; production builds contain no inspector marker, bridge, route, or chunk. | [ADR 0006](decisions/0006-bound-layer3-inspection-and-testing.md), inspector unit coverage, and generated golden restart/clear/production-exclusion assertions in `tools/golden_path.ts`. |21+| Browser orchestration existed only in the repository checkout journey. | `@nzip/lofi/testing` exports readiness, offline restoration, two-client identity isolation, convergence coordination, and sanitized failure artifacts. | [ADR 0006](decisions/0006-bound-layer3-inspection-and-testing.md), generated subpath-resolution coverage, real Chromium integration, and the cloud two-replica convergence gate. |22+23+Deleting an entry requires retained evidence in the owning issue or a decision record accepting the24+behavior as an intentional public boundary.
M init.md +35 -39
@@ -4,7 +4,7 @@
44 > contract lives in `docs/devx-contract.md`. M1 spikes are expected to correct or reject claims in55 > this draft as Jazz 2 and target-browser behavior are verified.66 7-Working name **`lofi`** (import paths like `@lofi/core`). Goal: validate that Jazz + Preact7+Working name **`lofi`** (import paths like `@nzip/lofi/core`). Goal: validate that Jazz + Preact88 islands + Astro + Deno can be wrapped into an ergonomic framework where the UI always hydrates from99 local data, identity is passkey-only, and **the only network surface an app developer ever sees is1010 the sync module's primitives/hooks**.
@@ -29,8 +29,8 @@ targeting Android Chrome 148+ / iOS Safari 16.4+ as hard floors**.
2929 ## Platform floors (enforced, not advisory)3030 3131 Jazz 2.0's only fallback below these floors is memory-only storage, which silently loses data — so32-`@lofi/core`'s `detect()` is a **boot gate**: unsupported browser → explicit "unsupported" screen33-(or opt-in memory mode with a "nothing will persist" banner). Never a silent fallback.32+`@nzip/lofi/core`'s `detect()` is a **boot gate**: unsupported browser → explicit "unsupported"33+screen (or opt-in memory mode with a "nothing will persist" banner). Never a silent fallback.3434 3535 | Feature | iOS Safari tab | iOS installed PWA | Android Chrome 148+ | Framework behavior |3636 | ----------------------------------------------- | ----------------------------------------------- | ----------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
@@ -44,35 +44,31 @@ Jazz 2.0's only fallback below these floors is memory-only storage, which silent
4444 | WebAuthn passkeys | ✅ iCloud Keychain | ✅ | ✅ | Primary auth |4545 | WebAuthn PRF | Safari 18+, no external keys | same | robust | Stretch phase only; feature-detect, never require |4646 47-App code never sniffs user agents — `@lofi/pwa` and `@lofi/sync` branch on the `Capabilities` object48-from `detect()`.47+App code never sniffs user agents — `@nzip/lofi/pwa` and `@nzip/lofi/sync` branch on the48+`Capabilities` object from `detect()`.4949 5050 ## Monorepo layout (Deno workspace)5151 5252 ```5353 lofi/54-├── deno.json # workspace: ./packages/*, ./apps/demo; shared tasks54+├── deno.json # workspace: ./packages/lofi, ./apps/demo; shared tasks5555 ├── packages/56-│ ├── core/ # @lofi/core — schema (re-export co/z + defineSchema), createApp()57-│ │ # singleton client (lazy, auth-gated), platform.ts detect() boot gate58-│ ├── auth/ # @lofi/auth — registerPasskey/signInWithPasskey/recoverWithPassphrase,59-│ │ # authState signal: "loading" | "anonymous" | "signedIn"60-│ ├── sync/ # @lofi/sync — THE ONLY NETWORK-AWARE MODULE: syncState signal61-│ │ # (connected/syncing/pendingOps/lastSyncedAt), pauseSync/resumeSync,62-│ │ # lifecycle.ts: visibility/online reconnect manager (iOS WS-drop fix)63-│ ├── ui/ # @lofi/ui — Preact hooks: useCoState, useAccount, useAuthState,64-│ │ # useSyncStatus, useOnline; adapter-compat.ts (Plan A) / adapter-vanilla.ts (Plan B)65-│ ├── pwa/ # @lofi/pwa — lofiPwa() @vite-pwa/astro config preset, install-prompt66-│ │ # orchestration (beforeinstallprompt / iOS coach-mark / EU degrade),67-│ │ # requestPersistentStorage() incl. Safari notification-permission dance68-│ └── testing/ # @lofi/testing — Playwright virtual-authenticator + offline/sync test helpers56+│ └── lofi/ # @nzip/lofi — one JSR package, one version, subpath exports57+│ ├── core/ # ./core — schema (re-export co/z + defineSchema), createApp(), detect()58+│ ├── auth/ # ./auth — passkey registration/sign-in/recovery and authState59+│ ├── sync/ # ./sync — THE ONLY NETWORK-AWARE MODULE and lifecycle manager60+│ ├── ui/ # ./ui — context-free Preact hooks and adapter implementation61+│ ├── pwa/ # ./pwa — PWA config, install orchestration, persistent-storage UX62+│ ├── testing/ # ./testing — virtual-authenticator and offline/sync test helpers63+│ └── create/ # ./create — non-interactive project creation command6964 └── apps/demo/ # Astro app: "Ticktick-lite" checklist; schema.ts, app.ts, islands/7065 ```7166 72-Dependency direction (strict): `demo → ui,pwa,auth,sync,core` · `ui → sync,auth,core` · `pwa → core`73-· `sync → core` · `auth → core` · `core → npm:jazz-tools` and nothing else. **Only `sync` may74-construct or configure a network peer** — the enforcement point for the "sync is the only network75-surface" constraint.67+The public modules are subpath exports of `@nzip/lofi`, not separately versioned packages.68+Dependency direction remains strict inside that package: `demo → ui,pwa,auth,sync,core` ·69+`ui → sync,auth,core` · `pwa → core` · `sync → core` · `auth → core` · `core → npm:jazz-tools` and70+nothing else. **Only `sync` may construct or configure a network peer** — the enforcement point for71+the "sync is the only network surface" constraint.7672 7773 Multi-tab coordination comes free: the SharedWorker Jazz 2.0 already requires is the single owner of7874 storage (and possibly sync) across tabs. Open question for Spike 0: does Jazz 2.0 run the sync
@@ -83,7 +79,7 @@ manager.
8379 8480 ```ts8581 // schema.ts86-import { co, defineSchema, z } from "@lofi/core";82+import { co, defineSchema, z } from "@nzip/lofi/core";8783 export const Task = co.map({ text: z.string(), done: z.boolean(), createdAt: z.date() });8884 export const TaskList = co.list(Task);8985 export const schema = defineSchema({
@@ -95,19 +91,19 @@ export const schema = defineSchema({
9591 });9692 9793 // app.ts — module scope, imported by every island (Vite guarantees one instance per page)98-import { createApp } from "@lofi/core";94+import { createApp } from "@nzip/lofi/core";9995 export const app = createApp({10096 schema,10197 sync: { peer: `wss://cloud.jazz.tools/?key=${import.meta.env.PUBLIC_JAZZ_KEY}` },10298 storage: "opfs",10399 });104100 // createApp() registers config only; the client boots lazily on first hook/auth call,105-// and the sync peer is attached exclusively by @lofi/sync.101+// and the sync peer is attached exclusively by @nzip/lofi/sync.106102 ```107103 108104 ```tsx109105 // islands/TaskListView.tsx110-import { useCoState } from "@lofi/ui";106+import { useCoState } from "@nzip/lofi/ui";111107 export default function TaskListView({ listId }) {112108 const list = useCoState(TaskList, listId, { resolve: { $each: true } });113109 if (list === undefined) return <Spinner />; // still loading from OPFS
@@ -155,15 +151,15 @@ exists, which is what makes multi-island apps work.
155151 156152 ## Risk-ordered roadmap157153 158-| # | Phase | Duration | Learn / kill criteria |159-| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |160-| 0 | **Spike: preact/compat × jazz-tools 2.0** — bare Vite+Preact page (no Astro, no Deno), Plan A then Plan B, anonymous account, `useCoState` round-trip to Jazz Cloud; measure gzipped bundle; find where the sync socket lives (worker vs tab) | 2 days | Which adapter plan; bundle cost. **Kill:** both plans fail to subscribe reliably → Jazz choice is dead, re-evaluate data layer |161-| 0.5 | **Spike: OPFS persistence on real devices** — same page with `storage: "opfs"` on a real iPhone (Safari tab + installed) and a real Chrome-148 Android; kill/relaunch, check data survives; multi-tab open | 2 days | Does Jazz 2.0 persistent mode actually work on WebKit's SharedWorker. **Kill:** broken on iOS → fall back to jazz-tools 0.20.x/IndexedDB (architecture unchanged — `storage:` is a named seam) |162-| 1 | **Spike: PasskeyAuth on real devices** — register/sign-in/passphrase-recovery over real HTTPS on iOS Safari tab, installed PWA, Android Chrome, WebAPK; verify the credential survives install | 3 days | Does Jazz's passkey custody work on mobile Safari (its PRF/largeBlob mechanism is undocumented — this is where we find out). **Kill:** fails on iOS → passphrase-primary UX or reconsider |163-| 2 | **Astro shell + islands + Deno toolchain** — scaffold workspace, port spikes into `@lofi/*`, two _separate_ islands sharing one client and live-updating each other (acceptance test for the singleton design); `deno task dev/build/serve` end-to-end with @deno/astro-adapter | 1 wk | Island-boundary state sharing works; npm-compat friction. **Soft kill:** Astro misbehaves under Deno → Node for build, Deno for serve; cap at 1 day |164-| 3 | **PWA hardening** — Workbox shell precache, manifest, Android install prompt, iOS coach-mark, EU-iOS degrade, `storage.persist()` incl. Safari notification-permission dance, reconnect-on-foreground verified on devices | 1 wk | Offline cold-start (airplane mode → installed PWA → shell + data render); WS reconnect reliability. Failures here are degradable UX, not fatal |165-| 4 | **Demo app "Ticktick-lite"** — checklists, shared lists via Group invite links (two accounts, two devices), sync badge, offline edits, conflict-free merge test | 1 wk | Is the API pleasant; where the abstraction leaks; invite-revocation gap surfaces concretely |166-| 5 | **(Stretch) PRF-derived E2E-at-rest** — WebAuthn PRF → HKDF → field-level encryption before data enters Jazz | optional | Only if a real threat model demands it; Safari 18 iCloud-Keychain-only; feature-detect, never require |154+| # | Phase | Duration | Learn / kill criteria |155+| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |156+| 0 | **Spike: preact/compat × jazz-tools 2.0** — bare Vite+Preact page (no Astro, no Deno), Plan A then Plan B, anonymous account, `useCoState` round-trip to Jazz Cloud; measure gzipped bundle; find where the sync socket lives (worker vs tab) | 2 days | Which adapter plan; bundle cost. **Kill:** both plans fail to subscribe reliably → Jazz choice is dead, re-evaluate data layer |157+| 0.5 | **Spike: OPFS persistence on real devices** — same page with `storage: "opfs"` on a real iPhone (Safari tab + installed) and a real Chrome-148 Android; kill/relaunch, check data survives; multi-tab open | 2 days | Does Jazz 2.0 persistent mode actually work on WebKit's SharedWorker. **Kill:** broken on iOS → fall back to jazz-tools 0.20.x/IndexedDB (architecture unchanged — `storage:` is a named seam) |158+| 1 | **Spike: PasskeyAuth on real devices** — register/sign-in/passphrase-recovery over real HTTPS on iOS Safari tab, installed PWA, Android Chrome, WebAPK; verify the credential survives install | 3 days | Does Jazz's passkey custody work on mobile Safari (its PRF/largeBlob mechanism is undocumented — this is where we find out). **Kill:** fails on iOS → passphrase-primary UX or reconsider |159+| 2 | **Astro shell + islands + Deno toolchain** — scaffold workspace, port spikes into `@nzip/lofi` subpath modules, two _separate_ islands sharing one client and live-updating each other (acceptance test for the singleton design); `deno task dev/build/serve` end-to-end with @deno/astro-adapter | 1 wk | Island-boundary state sharing works; npm-compat friction. **Soft kill:** Astro misbehaves under Deno → Node for build, Deno for serve; cap at 1 day |160+| 3 | **PWA hardening** — Workbox shell precache, manifest, Android install prompt, iOS coach-mark, EU-iOS degrade, `storage.persist()` incl. Safari notification-permission dance, reconnect-on-foreground verified on devices | 1 wk | Offline cold-start (airplane mode → installed PWA → shell + data render); WS reconnect reliability. Failures here are degradable UX, not fatal |161+| 4 | **Demo app "Ticktick-lite"** — checklists, shared lists via Group invite links (two accounts, two devices), sync badge, offline edits, conflict-free merge test | 1 wk | Is the API pleasant; where the abstraction leaks; invite-revocation gap surfaces concretely |162+| 5 | **(Stretch) PRF-derived E2E-at-rest** — WebAuthn PRF → HKDF → field-level encryption before data enters Jazz | optional | Only if a real threat model demands it; Safari 18 iCloud-Keychain-only; feature-detect, never require |167163 168164 ## Verification169165
@@ -174,7 +170,7 @@ exists, which is what makes multi-island apps work.
174170 - **Per-phase device pass:** one-page manual checklist on a physical iPhone (tab + installed) and175171 Android (tab + WebAPK); debug via Safari Web Inspector over USB and `chrome://inspect`.176172 - **CI-able WebAuthn:** Playwright CDP virtual authenticator (resident key + user verification) in177- `@lofi/testing` for register/sign-in/recovery. Real-device passkey behavior still verified173+ `@nzip/lofi/testing` for register/sign-in/recovery. Real-device passkey behavior still verified178174 manually in Spike 1 (virtual authenticators don't model iCloud Keychain or PRF quirks).179175 - **Offline:** Playwright `context.setOffline(true)` for automated shell+data cold-start; manual180176 airplane mode on devices for the installed path.
@@ -190,8 +186,8 @@ exists, which is what makes multi-island apps work.
190186 2. **Jazz 2.0 OPFS persistence on WebKit** — SharedWorker on iOS Safari exists but is quirk-prone;191187 Spike 0.5 decides. Fallback to 0.20.x/IndexedDB costs nothing architecturally (`storage:` seam).192188 3. **Alpha API churn** — pin the exact 2.0-alpha version; expect breakage on every bump;193- `@lofi/core` is the single jazz-tools chokepoint containing migration cost. 2.0 docs are sparse —194- read the changelog and source when docs fail.189+ `@nzip/lofi/core` is the single jazz-tools chokepoint containing migration cost. 2.0 docs are190+ sparse — read the changelog and source when docs fail.195191 4. **Pre-148 Android installed base** — SharedWorker only returned in Chrome 148 (stable ~May–June196192 2026); older devices hit the boot gate. Acceptable for a prototype; revisit for any real release.197193 5. **Astro-under-Deno friction** — pre-agreed fallback: Node for `astro build`, Deno for everything
A jsr.json +33 -0
A package/commands/build.ts +55 -0
@@ -0,0 +1,55 @@
1+#!/usr/bin/env -S deno run -A2+3+import { extname, join } from "node:path";4+import { LOFI_VERSION } from "../version.ts";5+import { loadEnvironment, serverEnvironmentNames } from "../tooling/environment.ts";6+import { precacheUrls, scanSecrets, sourceFingerprint, walkFiles } from "../tooling/project.ts";7+import { runDeno } from "../tooling/process.ts";8+import { exitOnFailure, validatedCommandEnvironment } from "./shared.ts";9+10+const environment = await validatedCommandEnvironment();11+environment.LOFI_SKIP_JAZZ_MANAGED = "1";12+environment.JAZZ_ADMIN_SECRET = "";13+environment.BACKEND_SECRET = "";14+const sourceHash = (await sourceFingerprint()).slice(0, 12);15+16+exitOnFailure(17+ await runDeno(["run", "-A", "npm:astro@7.0.9", "build"], environment),18+ "production build",19+);20+21+await Deno.writeTextFile(22+ "dist/lofi-build.json",23+ `${24+ JSON.stringify({ lofiVersion: LOFI_VERSION, sourceHash, builtAt: new Date().toISOString() })25+ }\n`,26+);27+const serviceWorkerPath = "dist/sw.js";28+const serviceWorker = await Deno.readTextFile(serviceWorkerPath);29+await Deno.writeTextFile(30+ serviceWorkerPath,31+ serviceWorker.replace("__LOFI_BUILD_REVISION__", sourceHash),32+);33+const precache = precacheUrls(await walkFiles("dist", { includeDist: true }));34+await Deno.writeTextFile("dist/lofi-precache.json", `${JSON.stringify(precache.sort())}\n`);35+36+const loadedEnvironment = await loadEnvironment();37+const serverSecrets = Object.fromEntries(38+ serverEnvironmentNames.map((name) => [name, loadedEnvironment[name] ?? ""]),39+);40+const secretResult = await scanSecrets(serverSecrets);41+if (secretResult.leaks.length > 0) {42+ for (const leak of secretResult.leaks) {43+ console.error(`error: ${leak.name} value found in ${leak.path}`);44+ }45+ console.error(46+ "error: build output may expose a server secret; remove it and rerun `deno task build`",47+ );48+ Deno.exit(1);49+}50+51+const routes = (await walkFiles("dist", { includeDist: true }))52+ .filter((path) => extname(path) === ".html").length;53+console.log(54+ `lofi build: ${join(Deno.cwd(), "dist")} (${routes} routes, ${sourceHash}, secret scan passed)`,55+);
A package/commands/check.ts +47 -0
@@ -0,0 +1,47 @@
1+#!/usr/bin/env -S deno run -A2+3+import {4+ childEnvironment,5+ loadEnvironment,6+ serverEnvironmentNames,7+} from "../tooling/environment.ts";8+import { doctorReport, printDoctorReport } from "../tooling/diagnostics.ts";9+import { scanSecrets, walkFiles } from "../tooling/project.ts";10+import { runDeno } from "../tooling/process.ts";11+import { exitOnFailure } from "./shared.ts";12+13+const report = await doctorReport();14+if (report.blocked || !report.validation.ok) {15+ printDoctorReport(report);16+ console.error("error: check stopped at lofi preflight; rerun `deno task check` after the action");17+ Deno.exit(1);18+}19+const environment = childEnvironment(report.validation);20+environment.LOFI_SKIP_JAZZ_MANAGED = "1";21+const typedFiles = await walkFiles(Deno.cwd(), {22+ extensions: new Set([".ts", ".tsx"]),23+});24+25+for (26+ const [name, args] of [27+ ["format", ["fmt", "--check"]],28+ ["lint", ["lint"]],29+ ["type", ["check", ...typedFiles]],30+ ["Astro", ["run", "-A", "npm:astro@7.0.9", "check"]],31+ ] as const32+) {33+ exitOnFailure(await runDeno(args, environment), `${name} check`);34+}35+36+const loadedEnvironment = await loadEnvironment();37+const secrets = await scanSecrets(38+ Object.fromEntries(serverEnvironmentNames.map((name) => [name, loadedEnvironment[name] ?? ""])),39+);40+if (secrets.leaks.length > 0) {41+ for (const leak of secrets.leaks) {42+ console.error(`error: ${leak.name} value found in ${leak.path}`);43+ }44+ console.error("error: secret scan failed; remove the value and rerun `deno task check`");45+ Deno.exit(1);46+}47+console.log(`lofi check passed (4 checks, ${secrets.scanned} files secret-scanned)`);
A package/commands/dev.ts +36 -0
A package/commands/doctor.ts +7 -0
A package/commands/preview.ts +53 -0
@@ -0,0 +1,53 @@
1+#!/usr/bin/env -S deno run -A2+3+import { extname, join, normalize } from "node:path";4+5+const portFlag = Deno.args.findIndex((argument) => argument === "--port");6+const port = portFlag >= 0 ? Number(Deno.args[portFlag + 1]) : 4321;7+if (!Number.isInteger(port) || port < 1 || port > 65_535) {8+ console.error("error: preview --port must be an integer from 1 to 65535");9+ Deno.exit(2);10+}11+12+let identity: { lofiVersion?: string; sourceHash?: string };13+try {14+ identity = JSON.parse(await Deno.readTextFile("dist/lofi-build.json"));15+} catch {16+ console.error("error: production build is missing or stale; run `deno task build` first");17+ Deno.exit(1);18+}19+if (!identity.sourceHash) {20+ console.error("error: production build identity is invalid; run `deno task build` again");21+ Deno.exit(1);22+}23+24+const contentTypes: Record<string, string> = {25+ ".css": "text/css; charset=utf-8",26+ ".html": "text/html; charset=utf-8",27+ ".js": "text/javascript; charset=utf-8",28+ ".json": "application/json; charset=utf-8",29+ ".svg": "image/svg+xml",30+ ".wasm": "application/wasm",31+ ".webmanifest": "application/manifest+json",32+};33+34+console.log(35+ `lofi preview: http://127.0.0.1:${port}/ (build ${identity.sourceHash}, @nzip/lofi ${identity.lofiVersion})`,36+);37+Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {38+ const url = new URL(request.url);39+ let pathname = decodeURIComponent(url.pathname);40+ if (pathname.endsWith("/")) pathname += "index.html";41+ const relative = normalize(pathname).replace(/^[/\\]+/, "");42+ if (relative.startsWith("..")) return new Response("Not found", { status: 404 });43+ const path = join("dist", relative);44+ try {45+ const body = await Deno.readFile(path);46+ return new Response(request.method === "HEAD" ? null : body, {47+ headers: { "content-type": contentTypes[extname(path)] ?? "application/octet-stream" },48+ });49+ } catch (error) {50+ if (error instanceof Deno.errors.NotFound) return new Response("Not found", { status: 404 });51+ throw error;52+ }53+});
A package/commands/run_tests.ts +15 -0
A package/commands/shared.ts +21 -0
A package/create.ts +34 -0
A package/create_core.ts +235 -0
@@ -0,0 +1,235 @@
1+import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";2+import { STARTER_TEMPLATE } from "./starter_template.ts";3+import { LOFI_PACKAGE_PREFIX } from "./version.ts";4+5+export type CreateProjectOptions = {6+ cwd: string;7+ name: string;8+ packagePrefix?: string;9+};10+11+export type CreateProjectResult = {12+ destination: string;13+ displayPath: string;14+ nextCommands: readonly [string, string];15+};16+17+const generatedGitignore = `.env18+.env.*19+!.env.example20+21+node_modules/22+.astro/23+.vite/24+dist/25+coverage/26+playwright-report/27+test-results/28+.playwright-cli/29+.nzip-*/30+.DS_Store31+`;32+33+const generatedEnvironment =34+ `# Optional public Jazz cloud configuration. Leave both blank for local-only mode.35+JAZZ_APP_ID=36+JAZZ_SERVER_URL=37+38+# Server-only credentials. Never expose these through PUBLIC_ or client code.39+JAZZ_ADMIN_SECRET=40+BACKEND_SECRET=41+`;42+43+function generatedReadme(name: string): string {44+ return `# ${name}45+46+This local-first PWA was created with \`@nzip/lofi\`.47+48+\`\`\`sh49+deno task dev50+\`\`\`51+52+The default project runs local-only with durable browser storage. To enable managed sync, copy53+\`.env.example\` to \`.env\` and set both public Jazz values. Run \`deno task doctor\` before boot when54+you want configuration diagnostics without starting the application.55+56+Public tasks: \`dev\`, \`doctor\`, \`check\`, \`test\`, \`build\`, and \`preview\`.57+`;58+}59+60+function generatedDenoConfig(packagePrefix: string): string {61+ const packageCommand = (subpath: string, localPath: string) =>62+ packagePrefix.startsWith("file:")63+ ? `${packagePrefix}${localPath}`64+ : `${packagePrefix}${subpath}`;65+ const config = {66+ imports: {67+ "@astrojs/check": "npm:@astrojs/check@0.9.9",68+ "@astrojs/preact": "npm:@astrojs/preact@6.0.1",69+ "@nzip/lofi/": packagePrefix,70+ "@nzip/lofi/build": packageCommand("build", "commands/build.ts"),71+ "@nzip/lofi/check": packageCommand("check", "commands/check.ts"),72+ "@nzip/lofi/dev": packageCommand("dev", "commands/dev.ts"),73+ "@nzip/lofi/doctor": packageCommand("doctor", "commands/doctor.ts"),74+ "@nzip/lofi/preview": packageCommand("preview", "commands/preview.ts"),75+ "@nzip/lofi/testing": packageCommand("testing", "testing/mod.ts"),76+ "@nzip/lofi/test": packageCommand("test", "commands/run_tests.ts"),77+ "astro": "npm:astro@7.0.9",78+ "astro/config": "npm:astro@7.0.9/config",79+ "jazz-tools": "npm:jazz-tools@2.0.0-alpha.53",80+ "jazz-tools/dev/vite": "npm:jazz-tools@2.0.0-alpha.53/dev/vite",81+ "preact": "npm:preact@10.29.7",82+ "preact/hooks": "npm:preact@10.29.7/hooks",83+ "preact/jsx-dev-runtime": "npm:preact@10.29.7/jsx-dev-runtime",84+ "preact/jsx-runtime": "npm:preact@10.29.7/jsx-runtime",85+ "typescript": "npm:typescript@6.0.3",86+ "vite": "npm:vite@8.0.1",87+ },88+ nodeModulesDir: "auto",89+ compilerOptions: {90+ lib: ["deno.ns", "dom", "dom.iterable", "esnext"],91+ jsx: "react-jsx",92+ jsxImportSource: "preact",93+ },94+ tasks: {95+ dev: "deno run -A @nzip/lofi/dev",96+ doctor: "deno run -A @nzip/lofi/doctor",97+ check: "deno run -A @nzip/lofi/check",98+ test: "deno run -A @nzip/lofi/test",99+ build: "deno run -A @nzip/lofi/build",100+ preview: "deno run -A @nzip/lofi/preview",101+ },102+ fmt: {103+ lineWidth: 100,104+ semiColons: true,105+ singleQuote: false,106+ },107+ lint: {108+ rules: { tags: ["recommended"] },109+ },110+ };111+ return `${JSON.stringify(config, null, 2)}\n`;112+}113+114+function validateName(name: string): string[] {115+ if (name.trim() !== name || name.length === 0) {116+ throw new Error("project name must be a non-empty relative path without surrounding spaces");117+ }118+ if (isAbsolute(name)) throw new Error("project name must be a relative path");119+ const segments = name.split(/[\\/]/);120+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {121+ throw new Error("project name cannot contain empty, '.' or '..' path segments");122+ }123+ for (const segment of segments) {124+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(segment)) {125+ throw new Error(126+ `project path segment ${127+ JSON.stringify(segment)128+ } must start with a letter or number and contain only letters, numbers, '.', '_' or '-'`,129+ );130+ }131+ }132+ return segments;133+}134+135+async function directoryIsEmpty(path: string): Promise<boolean> {136+ try {137+ for await (const _entry of Deno.readDir(path)) return false;138+ return true;139+ } catch (error) {140+ if (error instanceof Deno.errors.NotFound) return true;141+ if (error instanceof Deno.errors.NotADirectory) return false;142+ throw error;143+ }144+}145+146+async function assertNoSymlinkPath(cwd: string, segments: readonly string[]): Promise<void> {147+ let path = cwd;148+ for (const segment of segments) {149+ path = join(path, segment);150+ try {151+ const stat = await Deno.lstat(path);152+ if (stat.isSymlink) {153+ throw new Error(154+ `destination path crosses symbolic link ${155+ JSON.stringify(relative(cwd, path))156+ }; no files were changed. Choose a path without symbolic links`,157+ );158+ }159+ } catch (error) {160+ if (error instanceof Deno.errors.NotFound) return;161+ throw error;162+ }163+ }164+}165+166+async function writeTemplate(destination: string): Promise<void> {167+ for (const [relativePath, content] of Object.entries(STARTER_TEMPLATE)) {168+ const path = join(destination, relativePath);169+ await Deno.mkdir(dirname(path), { recursive: true });170+ await Deno.writeTextFile(path, content);171+ }172+}173+174+async function rewritePortableAstroConfig(root: string): Promise<void> {175+ const path = join(root, "astro.config.ts");176+ const source = await Deno.readTextFile(path);177+ const portable = source.replace(178+ 'const workspaceRoot = fileURLToPath(new URL("../../", import.meta.url));',179+ 'const workspaceRoot = fileURLToPath(new URL("./", import.meta.url));',180+ );181+ if (portable === source) throw new Error("starter Astro workspace-root marker was not found");182+ await Deno.writeTextFile(path, portable);183+}184+185+export async function createProject(options: CreateProjectOptions): Promise<CreateProjectResult> {186+ const segments = validateName(options.name);187+ const cwd = resolve(options.cwd);188+ const destination = resolve(cwd, ...segments);189+ const child = relative(cwd, destination);190+ if (child.startsWith(`..${sep}`) || child === "..") {191+ throw new Error("project destination must stay inside the current directory");192+ }193+ await assertNoSymlinkPath(cwd, segments);194+195+ if (!(await directoryIsEmpty(destination))) {196+ throw new Error(197+ `destination ${198+ JSON.stringify(options.name)199+ } already exists and is not empty; no files were changed. Choose a new name, for example: deno run -A jsr:@nzip/lofi/create ${200+ basename(options.name)201+ }-new`,202+ );203+ }204+205+ await Deno.mkdir(dirname(destination), { recursive: true });206+ const staging = await Deno.makeTempDir({207+ dir: dirname(destination),208+ prefix: `.${basename(destination)}-lofi-create-`,209+ });210+ try {211+ await writeTemplate(staging);212+ await rewritePortableAstroConfig(staging);213+ await Deno.writeTextFile(join(staging, ".gitignore"), generatedGitignore);214+ await Deno.writeTextFile(join(staging, ".env.example"), generatedEnvironment);215+ await Deno.writeTextFile(join(staging, "README.md"), generatedReadme(basename(destination)));216+ await Deno.writeTextFile(217+ join(staging, "deno.json"),218+ generatedDenoConfig(options.packagePrefix ?? LOFI_PACKAGE_PREFIX),219+ );220+221+ await Deno.remove(destination).catch((error) => {222+ if (!(error instanceof Deno.errors.NotFound)) throw error;223+ });224+ await Deno.rename(staging, destination);225+ } catch (error) {226+ await Deno.remove(staging, { recursive: true }).catch(() => undefined);227+ throw error;228+ }229+230+ return {231+ destination,232+ displayPath: options.name,233+ nextCommands: [`cd ${options.name}`, "deno task dev"],234+ };235+}
A package/create_core_test.ts +210 -0
@@ -0,0 +1,210 @@
1+import { join } from "node:path";2+import packageManifest from "../jsr.json" with { type: "json" };3+import { createProject } from "./create_core.ts";4+import { LOFI_VERSION } from "./version.ts";5+6+function assert(condition: unknown, message: string): asserts condition {7+ if (!condition) throw new Error(message);8+}9+10+function assertEquals(actual: unknown, expected: unknown): void {11+ const canonical = (value: unknown): unknown => {12+ if (Array.isArray(value)) return value.map(canonical);13+ if (value !== null && typeof value === "object") {14+ return Object.fromEntries(15+ Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(16+ ([key, child]) => [key, canonical(child)],17+ ),18+ );19+ }20+ return value;21+ };22+ const actualJson = JSON.stringify(canonical(actual));23+ const expectedJson = JSON.stringify(canonical(expected));24+ if (actualJson !== expectedJson) {25+ throw new Error(`expected ${expectedJson}, received ${actualJson}`);26+ }27+}28+29+async function assertRejects(30+ action: () => Promise<unknown>,31+ messageIncludes: string,32+): Promise<void> {33+ try {34+ await action();35+ } catch (error) {36+ const message = error instanceof Error ? error.message : String(error);37+ assert(message.includes(messageIncludes), `expected error to include ${messageIncludes}`);38+ return;39+ }40+ throw new Error("expected promise to reject");41+}42+43+async function fileManifest(root: string): Promise<Record<string, string>> {44+ const manifest: Record<string, string> = {};45+ async function visit(path: string, prefix = "") {46+ const entries = [...Deno.readDirSync(path)].sort((left, right) =>47+ left.name.localeCompare(right.name)48+ );49+ for (const entry of entries) {50+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;51+ const child = join(path, entry.name);52+ if (entry.isDirectory) await visit(child, relative);53+ else if (entry.isFile) {54+ const digest = await crypto.subtle.digest("SHA-256", await Deno.readFile(child));55+ manifest[relative] = Array.from(new Uint8Array(digest))56+ .map((byte) => byte.toString(16).padStart(2, "0"))57+ .join("");58+ }59+ }60+ }61+ await visit(root);62+ return manifest;63+}64+65+function snapshotDelta(66+ actual: Readonly<Record<string, string>>,67+ expected: Readonly<Record<string, string>>,68+): string[] {69+ return [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort().filter((path) =>70+ actual[path] !== expected[path]71+ );72+}73+74+function makeTestRoot(): Promise<string> {75+ return Deno.makeTempDir({ dir: ".", prefix: ".lofi-create-test-" });76+}77+78+Deno.test("createProject materializes the complete starter snapshot", async () => {79+ const cwd = await makeTestRoot();80+ try {81+ const result = await createProject({ cwd, name: "starter" });82+ assertEquals(result.nextCommands, ["cd starter", "deno task dev"]);83+ const actual = await fileManifest(result.destination);84+ const expected = JSON.parse(85+ await Deno.readTextFile(new URL("./testdata/starter.snapshot.json", import.meta.url)),86+ );87+ if (Deno.env.get("LOFI_UPDATE_SNAPSHOT") === "1") {88+ await Deno.writeTextFile(89+ new URL("./testdata/starter.snapshot.json", import.meta.url),90+ `${JSON.stringify(actual, null, 2)}\n`,91+ );92+ } else {93+ const changed = snapshotDelta(actual, expected);94+ assert(95+ changed.length === 0,96+ `generated starter snapshot changed: ${97+ changed.join(", ")98+ }. Review the generated files, then run \`deno task test:update:create\` to accept them.`,99+ );100+ }101+ const config = JSON.parse(await Deno.readTextFile(join(result.destination, "deno.json")));102+ assertEquals(config.imports["@nzip/lofi/"], "jsr:@nzip/lofi@0.1.0/");103+ assertEquals(config.imports["@nzip/lofi/testing"], "jsr:@nzip/lofi@0.1.0/testing");104+ const lofiSpecifiers = Object.entries(config.imports)105+ .filter(([name]) => name.startsWith("@nzip/lofi/"))106+ .map(([, specifier]) => String(specifier));107+ assert(108+ lofiSpecifiers.length === 8,109+ `expected one package prefix, six commands, and testing, received ${lofiSpecifiers.length}`,110+ );111+ assert(112+ lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.1.0/")),113+ "generated lofi commands do not resolve through one exact package version",114+ );115+ } finally {116+ await Deno.remove(cwd, { recursive: true });117+ }118+});119+120+Deno.test("package manifest and generated version stay coupled", () => {121+ assertEquals(packageManifest.name, "@nzip/lofi");122+ assertEquals(packageManifest.version, LOFI_VERSION);123+ assertEquals(packageManifest.exports["./testing"], "./package/testing/mod.ts");124+});125+126+Deno.test("createProject refuses a non-empty destination without changing it", async () => {127+ const cwd = await makeTestRoot();128+ const destination = join(cwd, "taken");129+ const marker = join(destination, "keep.txt");130+ try {131+ await Deno.mkdir(destination);132+ await Deno.writeTextFile(marker, "untouched");133+ await assertRejects(() => createProject({ cwd, name: "taken" }), "no files were changed");134+ assertEquals(await Deno.readTextFile(marker), "untouched");135+ assertEquals([...Deno.readDirSync(destination)].map((entry) => entry.name), ["keep.txt"]);136+ } finally {137+ await Deno.remove(cwd, { recursive: true });138+ }139+});140+141+Deno.test("createProject refuses path traversal without creating an outside directory", async () => {142+ const cwd = await makeTestRoot();143+ const outside = join(cwd, "..", "outside-from-lofi-create-test");144+ try {145+ await Deno.remove(outside, { recursive: true }).catch(() => undefined);146+ await assertRejects(147+ () => createProject({ cwd, name: "../outside-from-lofi-create-test" }),148+ "'..'",149+ );150+ try {151+ await Deno.stat(outside);152+ throw new Error("path traversal created an outside destination");153+ } catch (error) {154+ assert(error instanceof Deno.errors.NotFound, "unexpected outside traversal path state");155+ }156+ } finally {157+ await Deno.remove(cwd, { recursive: true });158+ await Deno.remove(outside, { recursive: true }).catch(() => undefined);159+ }160+});161+162+Deno.test("createProject accepts an existing empty directory and a local package override", async () => {163+ const cwd = await makeTestRoot();164+ try {165+ await Deno.mkdir(join(cwd, "nested", "starter"), { recursive: true });166+ const result = await createProject({167+ cwd,168+ name: "nested/starter",169+ packagePrefix: "file:///clean-room/package/",170+ });171+ const config = JSON.parse(await Deno.readTextFile(join(result.destination, "deno.json")));172+ assertEquals(config.imports["@nzip/lofi/"], "file:///clean-room/package/");173+ assert(174+ !await Array.fromAsync(Deno.readDir(result.destination)).then((entries) =>175+ entries.some((entry) => entry.name === ".env")176+ ),177+ "generated project must not contain .env",178+ );179+ } finally {180+ await Deno.remove(cwd, { recursive: true });181+ }182+});183+184+Deno.test({185+ name: "createProject refuses destinations reached through a symbolic link",186+ ignore: Deno.build.os === "windows",187+ async fn() {188+ const cwd = await makeTestRoot();189+ const outside = await makeTestRoot();190+ try {191+ const link = await new Deno.Command("ln", {192+ args: ["-s", outside, join(cwd, "linked")],193+ stdout: "null",194+ stderr: "piped",195+ }).output();196+ assert(197+ link.success,198+ `failed to create test symlink: ${new TextDecoder().decode(link.stderr)}`,199+ );200+ await assertRejects(201+ () => createProject({ cwd, name: "linked/starter" }),202+ "crosses symbolic link",203+ );204+ assertEquals([...Deno.readDirSync(outside)].length, 0);205+ } finally {206+ await Deno.remove(cwd, { recursive: true });207+ await Deno.remove(outside, { recursive: true });208+ }209+ },210+});
A package/starter_template.ts +108 -0
@@ -0,0 +1,108 @@
1+import astroConfig from "../apps/reference/astro.config.ts" with { type: "text" };2+import favicon from "../apps/reference/public/favicon.svg" with { type: "text" };3+import manifest from "../apps/reference/public/manifest.webmanifest" with { type: "text" };4+import serviceWorker from "../apps/reference/public/sw.js" with { type: "text" };5+import deviceStatus from "../apps/reference/src/_lofi/DeviceStatus.tsx" with { type: "text" };6+import referenceShell from "../apps/reference/src/_lofi/ReferenceShell.astro" with { type: "text" };7+import boot from "../apps/reference/src/_lofi/boot.ts" with { type: "text" };8+import checklistStore from "../apps/reference/src/_lofi/checklist-store.ts" with { type: "text" };9+import checklistStoreTest from "../apps/reference/src/_lofi/checklist-store_test.ts" with {10+ type: "text",11+};12+import config from "../apps/reference/src/_lofi/config.ts" with { type: "text" };13+import deviceCapabilities from "../apps/reference/src/_lofi/device-capabilities.ts" with {14+ type: "text",15+};16+import deviceCapabilitiesTest from "../apps/reference/src/_lofi/device-capabilities_test.ts" with {17+ type: "text",18+};19+import inspector from "../apps/reference/src/_lofi/inspector.ts" with { type: "text" };20+import inspectorTest from "../apps/reference/src/_lofi/inspector_test.ts" with { type: "text" };21+import probe from "../apps/reference/src/_lofi/probe.ts" with { type: "text" };22+import pwa from "../apps/reference/src/_lofi/pwa.ts" with { type: "text" };23+import pwaTest from "../apps/reference/src/_lofi/pwa_test.ts" with { type: "text" };24+import resourceLifecycle from "../apps/reference/src/_lofi/resource-lifecycle.ts" with {25+ type: "text",26+};27+import resourceLifecycleTest from "../apps/reference/src/_lofi/resource-lifecycle_test.ts" with {28+ type: "text",29+};30+import runtime from "../apps/reference/src/_lofi/runtime.ts" with { type: "text" };31+import testAssert from "../apps/reference/src/_lofi/test-assert.ts" with { type: "text" };32+import uiMutation from "../apps/reference/src/_lofi/ui-mutation.ts" with { type: "text" };33+import uiMutationTest from "../apps/reference/src/_lofi/ui-mutation_test.ts" with {34+ type: "text",35+};36+import useChecklist from "../apps/reference/src/_lofi/use-checklist.ts" with { type: "text" };37+import useDeviceCapabilities from "../apps/reference/src/_lofi/use-device-capabilities.ts" with {38+ type: "text",39+};40+import app from "../apps/reference/src/app.ts" with { type: "text" };41+import environmentTypes from "../apps/reference/src/env.d.ts" with { type: "text" };42+import checklistIsland from "../apps/reference/src/islands/ChecklistIsland.tsx" with {43+ type: "text",44+};45+import migration from "../apps/reference/src/migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts" with {46+ type: "text",47+};48+import oldSchemaSnapshot from "../apps/reference/src/migrations/snapshots/20260715T194819-6c62fec42c35.json" with {49+ type: "text",50+};51+import currentSchemaSnapshot from "../apps/reference/src/migrations/snapshots/20260715T194947-ff85ac1d97ee.json" with {52+ type: "text",53+};54+import indexPage from "../apps/reference/src/pages/index.astro" with { type: "text" };55+import permissions from "../apps/reference/src/permissions.ts" with { type: "text" };56+import schema from "../apps/reference/src/schema.ts" with { type: "text" };57+import globalStyles from "../apps/reference/src/styles/global.css" with { type: "text" };58+import uiContract from "../apps/reference/src/ui-contract.ts" with { type: "text" };59+import authorBoundaryTest from "../apps/reference/tests/author-boundary_test.ts" with {60+ type: "text",61+};62+import testingContractTest from "../apps/reference/tests/testing-contract_test.ts" with {63+ type: "text",64+};65+import tsconfig from "../apps/reference/tsconfig.json" with { type: "text" };66+67+/** Every source-controlled file copied from the validated reference app into a new project. */68+export const STARTER_TEMPLATE: Readonly<Record<string, string>> = {69+ "astro.config.ts": astroConfig,70+ "public/favicon.svg": favicon,71+ "public/manifest.webmanifest": manifest,72+ "public/sw.js": serviceWorker,73+ "src/_lofi/DeviceStatus.tsx": deviceStatus,74+ "src/_lofi/ReferenceShell.astro": referenceShell,75+ "src/_lofi/boot.ts": boot,76+ "src/_lofi/checklist-store.ts": checklistStore,77+ "src/_lofi/checklist-store_test.ts": checklistStoreTest,78+ "src/_lofi/config.ts": config,79+ "src/_lofi/device-capabilities.ts": deviceCapabilities,80+ "src/_lofi/device-capabilities_test.ts": deviceCapabilitiesTest,81+ "src/_lofi/inspector.ts": inspector,82+ "src/_lofi/inspector_test.ts": inspectorTest,83+ "src/_lofi/probe.ts": probe,84+ "src/_lofi/pwa.ts": pwa,85+ "src/_lofi/pwa_test.ts": pwaTest,86+ "src/_lofi/resource-lifecycle.ts": resourceLifecycle,87+ "src/_lofi/resource-lifecycle_test.ts": resourceLifecycleTest,88+ "src/_lofi/runtime.ts": runtime,89+ "src/_lofi/test-assert.ts": testAssert,90+ "src/_lofi/ui-mutation.ts": uiMutation,91+ "src/_lofi/ui-mutation_test.ts": uiMutationTest,92+ "src/_lofi/use-checklist.ts": useChecklist,93+ "src/_lofi/use-device-capabilities.ts": useDeviceCapabilities,94+ "src/app.ts": app,95+ "src/env.d.ts": environmentTypes,96+ "src/islands/ChecklistIsland.tsx": checklistIsland,97+ "src/migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts": migration,98+ "src/migrations/snapshots/20260715T194819-6c62fec42c35.json": oldSchemaSnapshot,99+ "src/migrations/snapshots/20260715T194947-ff85ac1d97ee.json": currentSchemaSnapshot,100+ "src/pages/index.astro": indexPage,101+ "src/permissions.ts": permissions,102+ "src/schema.ts": schema,103+ "src/styles/global.css": globalStyles,104+ "src/ui-contract.ts": uiContract,105+ "tests/author-boundary_test.ts": authorBoundaryTest,106+ "tests/testing-contract_test.ts": testingContractTest,107+ "tsconfig.json": tsconfig,108+};
A package/testdata/starter.snapshot.json +45 -0
@@ -0,0 +1,45 @@
1+{2+ ".env.example": "c22aed73c7089168ac777fd4f68ad504f9b826ea7185b705514a4ac5505918b1",3+ ".gitignore": "9efb15c6a9bfc20f296ed712e74273b6015a0dd9a40f7e861b8711dcf61825f1",4+ "astro.config.ts": "8a19cae348af7fcbd9db119d2423dd2e464f0e5ace6e5723d06cd3a1cc9a0d4b",5+ "deno.json": "1285b00d154c19fe9104d7b13eff503cd95d628574d409177cdfbf7d652a98ac",6+ "public/favicon.svg": "23efdb888dae688715faafe3ef94f7b5ad1976afcc715c0a742218b54732c6ad",7+ "public/manifest.webmanifest": "d23457fe8df68a16181fde1d4ecca4b69d3b050b5fa3bf8f4051cb05bde1d3d6",8+ "public/sw.js": "d816161fd5a35f2c477ca72eaa5ba58eb8d94795a4399d29a19838e886dafa31",9+ "README.md": "26fb40f86d03a403519bbce0f263b2305971b98c310a19cec37ab0079130fb0c",10+ "src/_lofi/boot.ts": "dd66bd1e5ddb88599a0cbadb2c2cf932993734843c7e5cf41d28b389560a32c7",11+ "src/_lofi/checklist-store_test.ts": "0c0ebc75afa8702ad13fec9eaace14763a6d3ef52c3afa283ebfae33f90929d9",12+ "src/_lofi/checklist-store.ts": "3a221882b72be6652b3ccfd5d1b058703ad84ae955c12515a301cec0f8708cd7",13+ "src/_lofi/config.ts": "3ae7e31b3915e12eb595eeea158c0dbd026f4f0f8bf7b0f4809117bec9752f1a",14+ "src/_lofi/device-capabilities_test.ts": "0eece375a0645e915294c7647cba4666e992a8a471cb636cf889bcd323a65ee5",15+ "src/_lofi/device-capabilities.ts": "91b03f9bed813d3770061416d40066ed6f21829877293e5395eff8f0f98fc11e",16+ "src/_lofi/DeviceStatus.tsx": "1ba2e2c2bee7ac15bc2830e879904deddf95bba1b55359f0376cf7cb1c9aafeb",17+ "src/_lofi/inspector_test.ts": "05de6636a3ff5ff7f918f989ac919cde653fafd81cbd58c8f0c371badb554a07",18+ "src/_lofi/inspector.ts": "19fbe4f2d300ca4886ad90f16f6539cc0ad04ba1f38ac28bd9dec269c3f872bd",19+ "src/_lofi/probe.ts": "9bb284f4543e631acbfb22bb661b5cfc0798d561b0e95ee09796b36ebb5865b0",20+ "src/_lofi/pwa_test.ts": "092d9ff442d82086a03cf45c6ed0256ab0f1ce8db42fe7e636d4b93d6ebae63e",21+ "src/_lofi/pwa.ts": "185a0668528b49eae2dd0aa905a5a2e491b3787da335d491ffecaf8bfadec430",22+ "src/_lofi/ReferenceShell.astro": "a2817146d1fe62cfdc399669709996b468823a8828257abf63b445c4c8eac912",23+ "src/_lofi/resource-lifecycle_test.ts": "9a43053351b8b90f3b67a2953c3ff159e0337ab8009f1d061e9e2bf7382abf43",24+ "src/_lofi/resource-lifecycle.ts": "4e017f4391f18854486044709041f163308fa3fc84e08d5ec7f45c39e3ae3cb5",25+ "src/_lofi/runtime.ts": "4fa51fae73fa69aec95e6f9a3d750844bb841a9ae8b787e03a16b8d3a3ba3a22",26+ "src/_lofi/test-assert.ts": "38549889964a4e92a0d63ee514c746cae5a43e6160cea4f2075a9e61543d3744",27+ "src/_lofi/ui-mutation_test.ts": "16153da95e080570af254eb0c56433f2db8abacec542984f59f6afd4ebf1c501",28+ "src/_lofi/ui-mutation.ts": "3273053a796160556045d06d672f4c79d9597e1f822928f1ee71a7d31865dc32",29+ "src/_lofi/use-checklist.ts": "a6d2818d390d228ad2a3db554b7cb043b60f7e9a2e48636ba19247c666343eaf",30+ "src/_lofi/use-device-capabilities.ts": "f7c7b2321b400d8076a5347e0f1ff2924623535979d48a2d94a69f825b93841e",31+ "src/app.ts": "3fd3646054f11ef7a1c080445d089eba45d58df7934aeaa58edab7c965c65523",32+ "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",33+ "src/islands/ChecklistIsland.tsx": "77c8c085327b7832d0d9daa0f39a61d3ecf1dc6f7e50c8146499dd6d81aecbe3",34+ "src/migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts": "387201d32018cf9bbc0da7041110638baf27bb9eb55a5e03f59b124bcfc336d5",35+ "src/migrations/snapshots/20260715T194819-6c62fec42c35.json": "799e32dee2ca64b7ea69ae48869f5a6dc29adfdb97dd79767a4c8697495aa582",36+ "src/migrations/snapshots/20260715T194947-ff85ac1d97ee.json": "39bb03925d68138aa5effc6fc9799de90ad4f3b6b65df84b638afc7b2d67c16b",37+ "src/pages/index.astro": "1930f00137df528f15836eb68230b06b248f8abec53f1a051a904557a1287346",38+ "src/permissions.ts": "b4580cc5f32935ac568a9aff6109c4aa7e4b3ee8cea8b28c3f306db68a55dcc2",39+ "src/schema.ts": "f2c555b088ded8eea2653f5d955fc9aae69dfd588e891adf509e99052f1e9949",40+ "src/styles/global.css": "7cbe243842f3bca32038d99a4872afe30c346cf7137387ce41861c9333a1f5c0",41+ "src/ui-contract.ts": "fc40d20dff6ba870c0de038839e66cb61d4349031e4a7748d963ca182e8bbec0",42+ "tests/author-boundary_test.ts": "7ea79031ed4ae7d7966bef2e456e02efe7d93d82c8f0bee219d885d300260ed2",43+ "tests/testing-contract_test.ts": "4257306060d42566b879d92b3a6c395805b20f53717bee55d670c0ef7a117eec",44+ "tsconfig.json": "fe2e5acc66101ed42d893d7e7063d07d298927621b351adcfb1ebde6c3a0a2ac"45+}
A package/testing/browser_error.ts +20 -0
A package/testing/convergence.ts +126 -0
@@ -0,0 +1,126 @@
1+import type { BrowserTestClient } from "./fixture.ts";2+import type { ValueFreeState } from "./safety.ts";3+4+export interface OfflineTestClient {5+ readonly offline: boolean;6+ goOffline(): Promise<void>;7+ goOnline(): Promise<void>;8+}9+10+export interface OfflineTestFixture<Client extends OfflineTestClient> {11+ readonly clients: readonly [Client, Client];12+ readonly first: Client;13+ readonly second: Client;14+ goOffline(): Promise<void>;15+ goOnline(): Promise<void>;16+ captureFailure(17+ label: string,18+ snapshot?: (client: Client) => Promise<ValueFreeState>,19+ ): Promise<unknown>;20+}21+22+export interface ConcurrentOfflineScenario<23+ Edit,24+ Client extends OfflineTestClient = BrowserTestClient,25+> {26+ readonly edits: readonly [Edit, Edit];27+ /** Overall defensive deadline. Defaults to 60 seconds. */28+ readonly timeoutMs?: number;29+ /** App-owned, deterministic readiness assertion (locator/waitForFunction/etc.). */30+ readonly ready: (client: Client, signal: AbortSignal) => Promise<void>;31+ readonly apply: (client: Client, edit: Edit, signal: AbortSignal) => Promise<void>;32+ readonly locallyApplied: (client: Client, edit: Edit, signal: AbortSignal) => Promise<void>;33+ /** Use this hook for page/client restart while offline work is pending. */34+ readonly whilePending?: (35+ fixture: OfflineTestFixture<Client>,36+ signal: AbortSignal,37+ ) => Promise<void>;38+ /** Must resolve only when both app views have converged, or reject on timeout. */39+ readonly converged: (40+ fixture: OfflineTestFixture<Client>,41+ signal: AbortSignal,42+ ) => Promise<void>;43+ readonly snapshot?: (client: Client) => Promise<ValueFreeState>;44+ readonly failureLabel?: string;45+}46+47+export class ConvergenceScenarioError extends Error {48+ override readonly name = "ConvergenceScenarioError";49+50+ constructor(readonly stage: string, options?: ErrorOptions) {51+ super(`Concurrent offline convergence failed during ${stage}`, options);52+ }53+}54+55+/**56+ * Coordinates the transport lifecycle while the app owns edits, assertions and57+ * conflict semantics. Both offline edits are started in the same microtask.58+ */59+export async function runConcurrentOfflineConvergence<Edit, Client extends OfflineTestClient>(60+ fixture: OfflineTestFixture<Client>,61+ scenario: ConcurrentOfflineScenario<Edit, Client>,62+): Promise<void> {63+ const timeoutMs = scenario.timeoutMs ?? 60_000;64+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {65+ throw new TypeError("convergence timeoutMs must be a positive finite number");66+ }67+ const controller = new AbortController();68+ const timeout = setTimeout(69+ () => controller.abort(new Error(`convergence exceeded its ${timeoutMs}ms deadline`)),70+ timeoutMs,71+ );72+ const withinDeadline = async <Result>(operation: Promise<Result>): Promise<Result> => {73+ if (controller.signal.aborted) throw controller.signal.reason;74+ let onAbort!: () => void;75+ const aborted = new Promise<never>((_resolve, reject) => {76+ onAbort = () => reject(controller.signal.reason);77+ controller.signal.addEventListener("abort", onAbort, { once: true });78+ });79+ try {80+ return await Promise.race([operation, aborted]);81+ } finally {82+ controller.signal.removeEventListener("abort", onAbort);83+ }84+ };85+ let stage = "readiness";86+ try {87+ await withinDeadline(88+ Promise.all(fixture.clients.map((client) => scenario.ready(client, controller.signal))),89+ );90+ stage = "going offline";91+ await withinDeadline(fixture.goOffline());92+ stage = "concurrent edits";93+ await withinDeadline(94+ Promise.all([95+ scenario.apply(fixture.first, scenario.edits[0], controller.signal),96+ scenario.apply(fixture.second, scenario.edits[1], controller.signal),97+ ]),98+ );99+ stage = "local persistence";100+ await withinDeadline(101+ Promise.all([102+ scenario.locallyApplied(fixture.first, scenario.edits[0], controller.signal),103+ scenario.locallyApplied(fixture.second, scenario.edits[1], controller.signal),104+ ]),105+ );106+ stage = "pending-work hook";107+ if (scenario.whilePending) {108+ await withinDeadline(scenario.whilePending(fixture, controller.signal));109+ }110+ stage = "reconnection";111+ await withinDeadline(fixture.goOnline());112+ stage = "convergence";113+ await withinDeadline(scenario.converged(fixture, controller.signal));114+ } catch (error) {115+ await fixture.captureFailure(116+ scenario.failureLabel ?? `offline-convergence-${stage}`,117+ scenario.snapshot,118+ ).catch(() => undefined);119+ throw new ConvergenceScenarioError(stage, { cause: error });120+ } finally {121+ clearTimeout(timeout);122+ await Promise.allSettled(123+ fixture.clients.filter((client) => client.offline).map((client) => client.goOnline()),124+ );125+ }126+}
A package/testing/convergence_test.ts +133 -0
@@ -0,0 +1,133 @@
1+import {2+ ConvergenceScenarioError,3+ type OfflineTestClient,4+ type OfflineTestFixture,5+ runConcurrentOfflineConvergence,6+} from "./convergence.ts";7+import type { ValueFreeState } from "./safety.ts";8+9+function assert(condition: unknown, message: string): asserts condition {10+ if (!condition) throw new Error(message);11+}12+13+class FakeClient implements OfflineTestClient {14+ offline = false;15+ readonly events: string[] = [];16+17+ constructor(readonly name: string) {}18+19+ goOffline(): Promise<void> {20+ this.offline = true;21+ this.events.push("offline");22+ return Promise.resolve();23+ }24+25+ goOnline(): Promise<void> {26+ this.offline = false;27+ this.events.push("online");28+ return Promise.resolve();29+ }30+}31+32+class FakeFixture implements OfflineTestFixture<FakeClient> {33+ readonly first = new FakeClient("first");34+ readonly second = new FakeClient("second");35+ readonly clients = [this.first, this.second] as const;36+ captures: Array<{ label: string; states?: ValueFreeState[] }> = [];37+38+ async goOffline(): Promise<void> {39+ await Promise.all(this.clients.map((client) => client.goOffline()));40+ }41+42+ async goOnline(): Promise<void> {43+ await Promise.all(this.clients.map((client) => client.goOnline()));44+ }45+46+ async captureFailure(47+ label: string,48+ snapshot?: (client: FakeClient) => Promise<ValueFreeState>,49+ ): Promise<void> {50+ this.captures.push({51+ label,52+ states: snapshot ? await Promise.all(this.clients.map(snapshot)) : undefined,53+ });54+ }55+}56+57+Deno.test("offline convergence starts edits concurrently and leaves both clients online", async () => {58+ const fixture = new FakeFixture();59+ const entered: string[] = [];60+ let release!: () => void;61+ const bothEntered = new Promise<void>((resolve) => (release = resolve));62+63+ await runConcurrentOfflineConvergence(fixture, {64+ edits: ["alpha", "beta"],65+ ready: () => Promise.resolve(),66+ async apply(client, edit) {67+ assert(client.offline, `${client.name} edit ran online`);68+ entered.push(`${client.name}:${edit}`);69+ if (entered.length === 2) release();70+ await bothEntered;71+ },72+ locallyApplied: () => Promise.resolve(),73+ whilePending(current) {74+ assert(current.clients.every((client) => client.offline), "pending hook ran online");75+ return Promise.resolve();76+ },77+ converged(current) {78+ assert(current.clients.every((client) => !client.offline), "convergence ran offline");79+ return Promise.resolve();80+ },81+ });82+83+ assert(entered.join(",") === "first:alpha,second:beta", `edits were not paired: ${entered}`);84+ assert(fixture.clients.every((client) => !client.offline), "clients were left offline");85+ assert(fixture.captures.length === 0, "successful scenario captured failure artifacts");86+});87+88+Deno.test("offline convergence captures the failing stage and restores connectivity", async () => {89+ const fixture = new FakeFixture();90+ try {91+ await runConcurrentOfflineConvergence(fixture, {92+ edits: [1, 2],93+ ready: () => Promise.resolve(),94+ apply: () => Promise.reject(new Error("edit failed")),95+ locallyApplied: () => Promise.resolve(),96+ converged: () => Promise.resolve(),97+ snapshot: (client) => Promise.resolve({ offline: client.offline }),98+ });99+ throw new Error("expected scenario failure");100+ } catch (error) {101+ assert(error instanceof ConvergenceScenarioError, `unexpected error: ${error}`);102+ assert(error.stage === "concurrent edits", `unexpected stage: ${error.stage}`);103+ }104+ assert(fixture.clients.every((client) => !client.offline), "failure left clients offline");105+ assert(fixture.captures.length === 1, "failure artifacts were not requested once");106+ assert(107+ fixture.captures[0].label.includes("concurrent edits"),108+ `failure label lost the stage: ${fixture.captures[0].label}`,109+ );110+});111+112+Deno.test("offline convergence bounds a never-settling app predicate", async () => {113+ const fixture = new FakeFixture();114+ try {115+ await runConcurrentOfflineConvergence(fixture, {116+ edits: [1, 2],117+ timeoutMs: 10,118+ ready: () => Promise.resolve(),119+ apply: () => Promise.resolve(),120+ locallyApplied: () => Promise.resolve(),121+ converged: () => new Promise(() => undefined),122+ });123+ throw new Error("expected convergence deadline failure");124+ } catch (error) {125+ assert(error instanceof ConvergenceScenarioError, `unexpected error: ${error}`);126+ assert(error.stage === "convergence", `unexpected stage: ${error.stage}`);127+ assert(128+ error.cause instanceof Error && error.cause.message.includes("10ms deadline"),129+ `deadline cause was not retained: ${error.cause}`,130+ );131+ }132+ assert(fixture.clients.every((client) => !client.offline), "deadline left clients offline");133+});
A package/testing/fixture.ts +386 -0
@@ -0,0 +1,386 @@
1+import {2+ type Browser,3+ type BrowserContext,4+ type BrowserContextOptions,5+ chromium,6+ type ConsoleMessage,7+ type Locator,8+ type Page,9+ type Request,10+} from "npm:playwright@1.61.1";11+import { BrowserUnavailableError, rethrowBrowserLaunchError } from "./browser_error.ts";12+import {13+ artifactName,14+ assertValueFreeState,15+ redactDiagnosticText,16+ type ValueFreeState,17+} from "./safety.ts";18+import { sanitizeTraceArchive } from "./trace.ts";19+20+type MemoryStorageState = Awaited<ReturnType<BrowserContext["storageState"]>>;21+export type SafeContextOptions = Omit<BrowserContextOptions, "storageState"> & {22+ /** Identity state is managed in memory by the fixture. File paths are forbidden. */23+ storageState?: never;24+};25+26+export type ClientName = "first" | "second";27+28+export interface BrowserDiagnostic {29+ readonly client: ClientName;30+ readonly kind: "console" | "page-error" | "request-failed";31+ readonly level?: string;32+ readonly message: string;33+ readonly url?: string;34+}35+36+export type IdentityOptions =37+ | {38+ readonly mode: "shared";39+ /** Runs before tracing starts. The resulting state is cloned only in memory. */40+ readonly preparePrimary: (client: BrowserTestClient) => Promise<void>;41+ }42+ | {43+ readonly mode: "isolated";44+ /** Runs independently for each client before tracing starts. */45+ readonly prepare?: (client: BrowserTestClient) => Promise<void>;46+ };47+48+export interface FailureArtifactOptions {49+ readonly directory: string;50+ /** Literal credential values to remove from retained diagnostics. */51+ readonly secretValues?: readonly string[];52+ /** Locators containing private UI values that screenshots should mask. */53+ readonly mask?: (client: BrowserTestClient) => readonly Locator[];54+}55+56+export interface TwoClientFixtureOptions {57+ readonly baseURL: string;58+ readonly identity: IdentityOptions;59+ readonly browser?: Browser;60+ readonly context?: SafeContextOptions;61+ readonly artifacts?: FailureArtifactOptions;62+ /** Defaults to true. Tracing begins only after identity preparation. */63+ readonly traceOnFailure?: boolean;64+}65+66+export { BrowserUnavailableError };67+68+function validateContextOptions(options: SafeContextOptions | undefined): void {69+ if (options && "storageState" in options) {70+ throw new TypeError(71+ "context.storageState is not accepted; use an identity mode so state stays in memory",72+ );73+ }74+}75+76+export class BrowserTestClient {77+ readonly #diagnostics: BrowserDiagnostic[] = [];78+ #context: BrowserContext;79+ #page: Page;80+ #offline = false;81+ #recording = false;82+ #closed = false;83+84+ constructor(85+ readonly name: ClientName,86+ context: BrowserContext,87+ page: Page,88+ readonly baseURL: string,89+ private readonly createContext: (state?: MemoryStorageState) => Promise<BrowserContext>,90+ private readonly redact: (value: string) => string,91+ private readonly traceOnFailure: boolean,92+ ) {93+ this.#context = context;94+ this.#page = page;95+ }96+97+ get context(): BrowserContext {98+ return this.#context;99+ }100+101+ get page(): Page {102+ return this.#page;103+ }104+105+ get offline(): boolean {106+ return this.#offline;107+ }108+109+ get diagnostics(): readonly BrowserDiagnostic[] {110+ return this.#diagnostics;111+ }112+113+ async startRecording(): Promise<void> {114+ if (this.#recording || this.#closed) return;115+ this.#attachDiagnostics(this.#page);116+ if (this.traceOnFailure) {117+ await this.#context.tracing.start({ screenshots: false, snapshots: false, sources: false });118+ }119+ this.#recording = true;120+ }121+122+ async goOffline(): Promise<void> {123+ await this.#context.setOffline(true);124+ this.#offline = true;125+ }126+127+ async goOnline(): Promise<void> {128+ await this.#context.setOffline(false);129+ this.#offline = false;130+ }131+132+ /** Reload the current page without replacing its browser-side client state. */133+ async reloadPage(): Promise<Page> {134+ await this.#page.reload({ waitUntil: "domcontentloaded" });135+ return this.#page;136+ }137+138+ /** Close and recreate the page inside the same identity/context. */139+ async restartPage(): Promise<Page> {140+ const previous = this.#page;141+ await previous.close();142+ this.#page = await this.#context.newPage();143+ this.#attachDiagnostics(this.#page);144+ await this.#page.goto(this.baseURL, { waitUntil: "domcontentloaded" });145+ return this.#page;146+ }147+148+ /**149+ * Restart the whole context, preserving identity and IndexedDB only in memory.150+ * A new context starts online because service-worker/cache state is not part of151+ * Playwright storageState; use restartPage for an offline PWA restart.152+ */153+ async restartClient(options: { preserveIdentity?: boolean } = {}): Promise<Page> {154+ const preserveIdentity = options.preserveIdentity ?? true;155+ let state: MemoryStorageState | undefined;156+ if (preserveIdentity) {157+ state = await this.#context.storageState({ indexedDB: true });158+ }159+ await this.#discardTrace();160+ await this.#context.close();161+ this.#context = await this.createContext(state ? structuredClone(state) : undefined);162+ state = undefined;163+ this.#page = await this.#context.newPage();164+ this.#offline = false;165+ this.#recording = false;166+ await this.startRecording();167+ await this.#page.goto(this.baseURL, { waitUntil: "domcontentloaded" });168+ return this.#page;169+ }170+171+ async captureTrace(path: string): Promise<boolean> {172+ if (!this.#recording || !this.traceOnFailure) return false;173+ await this.#context.tracing.stop({ path });174+ this.#recording = false;175+ try {176+ await sanitizeTraceArchive(path, this.redact);177+ } catch (error) {178+ await Deno.remove(path).catch(() => undefined);179+ throw error;180+ }181+ return true;182+ }183+184+ async close(): Promise<void> {185+ if (this.#closed) return;186+ this.#closed = true;187+ await this.#discardTrace();188+ await this.#context.close();189+ }190+191+ #attachDiagnostics(page: Page): void {192+ page.on("console", (message: ConsoleMessage) => {193+ this.#diagnostics.push({194+ client: this.name,195+ kind: "console",196+ level: message.type(),197+ message: this.redact(message.text()),198+ url: this.redact(message.location().url),199+ });200+ });201+ page.on("pageerror", (error: Error) => {202+ this.#diagnostics.push({203+ client: this.name,204+ kind: "page-error",205+ message: this.redact(`${error.name}: ${error.message}`),206+ });207+ });208+ page.on("requestfailed", (request: Request) => {209+ this.#diagnostics.push({210+ client: this.name,211+ kind: "request-failed",212+ message: this.redact(request.failure()?.errorText ?? "request failed"),213+ url: this.redact(request.url()),214+ });215+ });216+ }217+218+ async #discardTrace(): Promise<void> {219+ if (!this.#recording || !this.traceOnFailure) return;220+ await this.#context.tracing.stop().catch(() => undefined);221+ this.#recording = false;222+ }223+}224+225+export interface FailureArtifacts {226+ readonly directory: string;227+ readonly files: readonly string[];228+}229+230+export class TwoClientFixture {231+ readonly clients: readonly [BrowserTestClient, BrowserTestClient];232+ #closed = false;233+234+ constructor(235+ readonly browser: Browser,236+ readonly ownsBrowser: boolean,237+ first: BrowserTestClient,238+ second: BrowserTestClient,239+ private readonly artifacts: FailureArtifactOptions | undefined,240+ ) {241+ this.clients = [first, second];242+ }243+244+ get first(): BrowserTestClient {245+ return this.clients[0];246+ }247+248+ get second(): BrowserTestClient {249+ return this.clients[1];250+ }251+252+ async goOffline(): Promise<void> {253+ await Promise.all(this.clients.map((client) => client.goOffline()));254+ }255+256+ async goOnline(): Promise<void> {257+ await Promise.all(this.clients.map((client) => client.goOnline()));258+ }259+260+ async captureFailure(261+ label: string,262+ snapshot?: (client: BrowserTestClient) => Promise<ValueFreeState>,263+ ): Promise<FailureArtifacts | undefined> {264+ if (!this.artifacts) return undefined;265+ const directory = `${this.artifacts.directory}/${artifactName(label)}`;266+ await Deno.mkdir(directory, { recursive: true });267+ const files: string[] = [];268+269+ for (const client of this.clients) {270+ const screenshot = `${directory}/${client.name}.png`;271+ await client.page.screenshot({272+ path: screenshot,273+ fullPage: true,274+ mask: this.artifacts.mask ? [...this.artifacts.mask(client)] : undefined,275+ });276+ files.push(screenshot);277+278+ const trace = `${directory}/${client.name}.trace.zip`;279+ if (await client.captureTrace(trace)) files.push(trace);280+ }281+282+ const diagnostics = `${directory}/diagnostics.json`;283+ await Deno.writeTextFile(284+ diagnostics,285+ `${JSON.stringify(this.clients.flatMap((client) => client.diagnostics), null, 2)}\n`,286+ );287+ files.push(diagnostics);288+289+ if (snapshot) {290+ const states: Record<ClientName, ValueFreeState> = {291+ first: await snapshot(this.first),292+ second: await snapshot(this.second),293+ };294+ assertValueFreeState(states);295+ const statePath = `${directory}/state-shape.json`;296+ await Deno.writeTextFile(statePath, `${JSON.stringify(states, null, 2)}\n`);297+ files.push(statePath);298+ }299+ return { directory, files };300+ }301+302+ async close(): Promise<void> {303+ if (this.#closed) return;304+ this.#closed = true;305+ const results = await Promise.allSettled(this.clients.map((client) => client.close()));306+ if (this.ownsBrowser) await this.browser.close();307+ const rejected = results.find((result) => result.status === "rejected");308+ if (rejected?.status === "rejected") throw rejected.reason;309+ }310+}311+312+export async function createTwoClientFixture(313+ options: TwoClientFixtureOptions,314+): Promise<TwoClientFixture> {315+ validateContextOptions(options.context);316+ let browser = options.browser;317+ const ownsBrowser = !browser;318+ if (!browser) {319+ try {320+ browser = await chromium.launch({ headless: true });321+ } catch (error) {322+ rethrowBrowserLaunchError(error);323+ }324+ }325+326+ const contextOptions = { ...options.context, baseURL: options.baseURL };327+ const createContext = (state?: MemoryStorageState) =>328+ browser.newContext({329+ ...contextOptions,330+ storageState: state ? structuredClone(state) : undefined,331+ });332+ const redact = (value: string) =>333+ redactDiagnosticText(value, options.artifacts?.secretValues ?? []);334+ const traceOnFailure = options.traceOnFailure ?? true;335+ let first: BrowserTestClient | undefined;336+ let second: BrowserTestClient | undefined;337+338+ try {339+ const firstContext = await createContext();340+ const firstPage = await firstContext.newPage();341+ first = new BrowserTestClient(342+ "first",343+ firstContext,344+ firstPage,345+ options.baseURL,346+ createContext,347+ redact,348+ traceOnFailure,349+ );350+ await firstPage.goto(options.baseURL, { waitUntil: "domcontentloaded" });351+352+ let sharedState: MemoryStorageState | undefined;353+ if (options.identity.mode === "shared") {354+ await options.identity.preparePrimary(first);355+ sharedState = await firstContext.storageState({ indexedDB: true });356+ } else if (options.identity.prepare) {357+ await options.identity.prepare(first);358+ }359+360+ const secondContext = await createContext(361+ sharedState ? structuredClone(sharedState) : undefined,362+ );363+ sharedState = undefined;364+ const secondPage = await secondContext.newPage();365+ second = new BrowserTestClient(366+ "second",367+ secondContext,368+ secondPage,369+ options.baseURL,370+ createContext,371+ redact,372+ traceOnFailure,373+ );374+ await secondPage.goto(options.baseURL, { waitUntil: "domcontentloaded" });375+ if (options.identity.mode === "isolated" && options.identity.prepare) {376+ await options.identity.prepare(second);377+ }378+379+ await Promise.all([first.startRecording(), second.startRecording()]);380+ return new TwoClientFixture(browser, ownsBrowser, first, second, options.artifacts);381+ } catch (error) {382+ await Promise.allSettled([first?.close(), second?.close()].filter(Boolean));383+ if (ownsBrowser) await browser.close().catch(() => undefined);384+ throw error;385+ }386+}
A package/testing/fixture_integration.ts +171 -0
@@ -0,0 +1,171 @@
1+import {2+ BrowserUnavailableError,3+ createTwoClientFixture,4+ type TwoClientFixture,5+} from "./fixture.ts";6+import { waitForReady } from "./readiness.ts";7+import { readTraceArchive } from "./trace.ts";8+9+function assert(condition: unknown, message: string): asserts condition {10+ if (!condition) throw new Error(message);11+}12+13+Deno.test("real Chromium fixture isolates or shares identity in memory and captures safe failures", async () => {14+ const artifacts = await Deno.makeTempDir({ dir: "." });15+ const server = Deno.serve(16+ { hostname: "127.0.0.1", port: 0, onListen() {} },17+ () =>18+ new Response(19+ `<!doctype html>20+ <html><body data-ready="yes"><main>fixture</main></body></html>`,21+ { headers: { "content-type": "text/html; charset=utf-8" } },22+ ),23+ );24+ const address = server.addr as Deno.NetAddr;25+ const baseURL = `http://127.0.0.1:${address.port}/`;26+ let shared: TwoClientFixture | undefined;27+ let isolated: TwoClientFixture | undefined;28+29+ try {30+ try {31+ shared = await createTwoClientFixture({32+ baseURL,33+ identity: {34+ mode: "shared",35+ async preparePrimary(client) {36+ await client.page.evaluate(() => localStorage.setItem("identity", "shared-person"));37+ },38+ },39+ artifacts: { directory: artifacts, secretValues: ["literal-secret"] },40+ });41+ } catch (error) {42+ if (error instanceof BrowserUnavailableError) return;43+ throw error;44+ }45+46+ await Promise.all(47+ shared.clients.map((client) =>48+ waitForReady(49+ client.page,50+ (value) => document.body.dataset.ready === value,51+ "yes",52+ { description: `${client.name} boot` },53+ )54+ ),55+ );56+ const sharedIdentities = await Promise.all(57+ shared.clients.map((client) => client.page.evaluate(() => localStorage.getItem("identity"))),58+ );59+ assert(60+ sharedIdentities.join(",") === "shared-person,shared-person",61+ `shared state was not cloned: ${sharedIdentities}`,62+ );63+64+ await shared.first.reloadPage();65+ assert(66+ await shared.first.page.evaluate(() => localStorage.getItem("identity")) === "shared-person",67+ "page reload lost identity",68+ );69+70+ await shared.goOffline();71+ await Promise.all(72+ shared.clients.map((client) => client.page.waitForFunction(() => navigator.onLine === false)),73+ );74+ await shared.goOnline();75+ await Promise.all(76+ shared.clients.map((client) => client.page.waitForFunction(() => navigator.onLine === true)),77+ );78+79+ await shared.first.restartPage();80+ assert(81+ await shared.first.page.evaluate(() => localStorage.getItem("identity")) === "shared-person",82+ "page restart lost identity",83+ );84+ await shared.second.restartClient();85+ assert(86+ await shared.second.page.evaluate(() => localStorage.getItem("identity")) === "shared-person",87+ "client restart lost in-memory identity",88+ );89+90+ isolated = await createTwoClientFixture({91+ baseURL,92+ browser: shared.browser,93+ identity: {94+ mode: "isolated",95+ async prepare(client) {96+ await client.page.evaluate(97+ (identity) => localStorage.setItem("identity", identity),98+ `${client.name}-person`,99+ );100+ },101+ },102+ traceOnFailure: false,103+ });104+ const isolatedIdentities = await Promise.all(105+ isolated.clients.map((client) =>106+ client.page.evaluate(() => localStorage.getItem("identity"))107+ ),108+ );109+ assert(110+ isolatedIdentities.join(",") === "first-person,second-person",111+ `isolated identities crossed: ${isolatedIdentities}`,112+ );113+ await isolated.close();114+ isolated = undefined;115+116+ const consoleEvent = shared.first.page.waitForEvent("console", {117+ predicate: (message) => message.type() === "error",118+ });119+ await shared.first.page.evaluate(() => console.error("token=literal-secret"));120+ await consoleEvent;121+ const pageError = shared.first.page.waitForEvent("pageerror");122+ await shared.first.page.evaluate(() => {123+ queueMicrotask(() => {124+ throw new Error("secret=literal-secret");125+ });126+ });127+ await pageError;128+ const requestFailed = shared.first.page.waitForEvent("requestfailed");129+ await shared.first.page.evaluate(() =>130+ fetch("http://127.0.0.1:1/private?token=literal-secret").catch(() => undefined)131+ );132+ await requestFailed;133+134+ const captured = await shared.captureFailure(135+ "integration failure",136+ (client) => Promise.resolve({ online: !client.offline, itemCount: 0 }),137+ );138+ assert(captured, "failure artifacts were not created");139+ assert(captured.files.some((path) => path.endsWith(".png")), "screenshots were omitted");140+ assert(141+ captured.files.filter((path) => path.endsWith(".trace.zip")).length === 2,142+ "one trace per client was not retained",143+ );144+ assert(145+ captured.files.every((path) => !path.toLowerCase().includes("storage")),146+ `storage state leaked into artifact names: ${captured.files}`,147+ );148+ const diagnostics = await Deno.readTextFile(`${captured.directory}/diagnostics.json`);149+ assert(!diagnostics.includes("literal-secret"), diagnostics);150+ assert(diagnostics.includes("[redacted]"), diagnostics);151+ for (const tracePath of captured.files.filter((path) => path.endsWith(".trace.zip"))) {152+ const entries = await readTraceArchive(tracePath);153+ for (const bytes of Object.values(entries)) {154+ assert(155+ !new TextDecoder().decode(bytes).includes("literal-secret"),156+ `${tracePath} retained a secret`,157+ );158+ }159+ }160+161+ const browser = shared.browser;162+ await shared.close();163+ shared = undefined;164+ assert(!browser.isConnected(), "owned Chromium process remained connected after close");165+ } finally {166+ await isolated?.close().catch(() => undefined);167+ await shared?.close().catch(() => undefined);168+ await server.shutdown().catch(() => undefined);169+ await Deno.remove(artifacts, { recursive: true });170+ }171+});
A package/testing/mod.ts +22 -0
A package/testing/readiness.ts +40 -0
A package/testing/readiness_test.ts +48 -0
@@ -0,0 +1,48 @@
1+import type { Page } from "npm:playwright@1.61.1";2+import { ReadinessError, waitForReady } from "./readiness.ts";3+4+function assert(condition: unknown, message: string): asserts condition {5+ if (!condition) throw new Error(message);6+}7+8+Deno.test("waitForReady delegates polling and timeout to Playwright", async () => {9+ let call: unknown[] | undefined;10+ const page = {11+ waitForFunction(...args: unknown[]) {12+ call = args;13+ return Promise.resolve({});14+ },15+ } as unknown as Page;16+ const predicate = (expected: string) => document.body.dataset.ready === expected;17+18+ await waitForReady(page, predicate, "yes", {19+ description: "reference app boot",20+ timeoutMs: 321,21+ polling: 17,22+ });23+24+ assert(call?.[0] === predicate, "predicate was replaced");25+ assert(call?.[1] === "yes", "predicate argument was replaced");26+ assert(27+ JSON.stringify(call?.[2]) === JSON.stringify({ polling: 17, timeout: 321 }),28+ `unexpected Playwright options: ${JSON.stringify(call?.[2])}`,29+ );30+});31+32+Deno.test("waitForReady adds the app condition to timeout failures", async () => {33+ const cause = new Error("playwright timeout");34+ const page = {35+ waitForFunction() {36+ return Promise.reject(cause);37+ },38+ } as unknown as Page;39+40+ try {41+ await waitForReady(page, () => false, undefined, { description: "sync indicator" });42+ throw new Error("expected readiness to fail");43+ } catch (error) {44+ assert(error instanceof ReadinessError, `unexpected error: ${error}`);45+ assert(error.message.includes("sync indicator"), "condition was missing from the error");46+ assert(error.cause === cause, "Playwright failure was not retained as the cause");47+ }48+});
A package/testing/safety.ts +73 -0
@@ -0,0 +1,73 @@
1+export type ValueFreeState =2+ | null3+ | boolean4+ | number5+ | { readonly [key: string]: ValueFreeState }6+ | readonly ValueFreeState[];7+8+const JSON_SECRET_ASSIGNMENT =9+ /(\"(?:password|passwd|secret|token|authorization|cookie|credential|session)\"\s*:\s*)\"(?:\\.|[^\"])*\"/gi;10+const SECRET_ASSIGNMENT =11+ /\b(password|passwd|secret|token|authorization|cookie|credential|session)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;"']+)/gi;12+const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;13+const URL_VALUE = /https?:\/\/[^\s"'<>]+/gi;14+15+function safeUrl(value: string): string {16+ try {17+ const url = new URL(value);18+ url.username = "";19+ url.password = "";20+ url.search = "";21+ url.hash = "";22+ return url.toString();23+ } catch {24+ return value;25+ }26+}27+28+/** Redacts common credential forms before diagnostics are retained in memory. */29+export function redactDiagnosticText(30+ value: string,31+ secretValues: readonly string[] = [],32+): string {33+ let redacted = value34+ .replaceAll(URL_VALUE, (url) => safeUrl(url))35+ .replaceAll(JSON_SECRET_ASSIGNMENT, '$1"[redacted]"')36+ .replaceAll(BEARER_VALUE, "Bearer [redacted]")37+ .replaceAll(SECRET_ASSIGNMENT, (_match, key: string) => `${key}=[redacted]`);38+ for (const secret of secretValues) {39+ if (secret.length > 0) redacted = redacted.replaceAll(secret, "[redacted]");40+ }41+ return redacted;42+}43+44+/**45+ * State artifacts intentionally reject strings and non-finite numbers. Callers46+ * can record shape, counts and booleans, but not user values or credentials.47+ */48+export function assertValueFreeState(49+ value: unknown,50+ path = "state",51+): asserts value is ValueFreeState {52+ if (value === null || typeof value === "boolean") return;53+ if (typeof value === "number" && Number.isFinite(value)) return;54+ if (typeof value === "string") {55+ throw new TypeError(`${path} contains a string value; state artifacts must be value-free`);56+ }57+ if (Array.isArray(value)) {58+ value.forEach((entry, index) => assertValueFreeState(entry, `${path}[${index}]`));59+ return;60+ }61+ if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {62+ for (const [key, entry] of Object.entries(value)) {63+ assertValueFreeState(entry, `${path}.${key}`);64+ }65+ return;66+ }67+ throw new TypeError(`${path} contains an unsupported value`);68+}69+70+export function artifactName(value: string): string {71+ const safe = value.toLowerCase().replaceAll(/[^a-z0-9._-]+/g, "-").replaceAll(/^-+|-+$/g, "");72+ return safe || "failure";73+}
A package/testing/safety_test.ts +81 -0
@@ -0,0 +1,81 @@
1+import { BrowserUnavailableError, rethrowBrowserLaunchError } from "./browser_error.ts";2+import { assertValueFreeState, redactDiagnosticText } from "./safety.ts";3+import { readTraceArchive, sanitizeTraceArchive, writeTraceArchive } from "./trace.ts";4+5+function assert(condition: unknown, message: string): asserts condition {6+ if (!condition) throw new Error(message);7+}8+9+Deno.test("value-free snapshots retain shape and reject values", () => {10+ assertValueFreeState({ online: false, itemCount: 2, clients: [true, null] });11+ for (12+ const invalid of [13+ { title: "private value" },14+ { value: Number.NaN },15+ new Date(),16+ ]17+ ) {18+ let rejected = false;19+ try {20+ assertValueFreeState(invalid);21+ } catch {22+ rejected = true;23+ }24+ assert(rejected, `unsafe state was accepted: ${String(invalid)}`);25+ }26+});27+28+Deno.test("missing Chromium reports the exact install command", () => {29+ const cause = new Error("browser executable doesn't exist");30+ try {31+ rethrowBrowserLaunchError(cause);32+ } catch (error) {33+ assert(error instanceof BrowserUnavailableError, `unexpected error: ${error}`);34+ assert(35+ error.message.includes("deno run -A npm:playwright@1.61.1 install chromium"),36+ error.message,37+ );38+ assert(error.cause === cause, "browser launch failure was not retained");39+ return;40+ }41+ throw new Error("missing browser error was not thrown");42+});43+44+Deno.test("diagnostic redaction strips URL, JSON and literal credentials", () => {45+ const input =46+ 'request https://user:pass@example.test/path?token=query#fragment {"authorization":"Bearer auth-value"} token=plain literal-value';47+ const output = redactDiagnosticText(input, ["literal-value"]);48+ assert(!output.includes("user:pass"), output);49+ assert(!output.includes("query"), output);50+ assert(!output.includes("fragment"), output);51+ assert(!output.includes("auth-value"), output);52+ assert(!output.includes("plain"), output);53+ assert(!output.includes("literal-value"), output);54+ assert(output.includes("https://example.test/path"), output);55+});56+57+Deno.test("trace archives are sanitized without losing their structure", async () => {58+ const directory = await Deno.makeTempDir({ dir: "." });59+ const path = `${directory}/trace.zip`;60+ try {61+ const trace =62+ '{"type":"console","text":"token=trace-secret","authorization":"Bearer auth-secret"}\n';63+ await Deno.writeFile(64+ path,65+ writeTraceArchive({66+ "trace.trace": new TextEncoder().encode(trace),67+ "resources/private.dat": new TextEncoder().encode("literal-private-resource"),68+ }),69+ );70+ await sanitizeTraceArchive(path, (value) => redactDiagnosticText(value, ["trace-secret"]));71+ const entries = await readTraceArchive(path);72+ const result = new TextDecoder().decode(entries["trace.trace"]);73+ assert(!result.includes("trace-secret"), result);74+ assert(!result.includes("auth-secret"), result);75+ assert(result.includes("[redacted]"), result);76+ JSON.parse(result);77+ assert(!("resources/private.dat" in entries), "private trace resource was retained");78+ } finally {79+ await Deno.remove(directory, { recursive: true });80+ }81+});
A package/testing/trace.ts +140 -0
@@ -0,0 +1,140 @@
1+const LOCAL_FILE = 0x04034b50;2+const CENTRAL_FILE = 0x02014b50;3+const END_OF_CENTRAL_DIRECTORY = 0x06054b50;4+const UTF8 = new TextEncoder();5+const TEXT = new TextDecoder();6+7+function concatenate(parts: readonly Uint8Array[]): Uint8Array {8+ const length = parts.reduce((total, part) => total + part.length, 0);9+ const result = new Uint8Array(length);10+ let offset = 0;11+ for (const part of parts) {12+ result.set(part, offset);13+ offset += part.length;14+ }15+ return result;16+}17+18+function header(length: number): { bytes: Uint8Array; view: DataView } {19+ const bytes = new Uint8Array(length);20+ return { bytes, view: new DataView(bytes.buffer) };21+}22+23+function crc32(bytes: Uint8Array): number {24+ let crc = 0xffffffff;25+ for (const byte of bytes) {26+ crc ^= byte;27+ for (let bit = 0; bit < 8; bit++) {28+ crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);29+ }30+ }31+ return (crc ^ 0xffffffff) >>> 0;32+}33+34+function endOffset(bytes: Uint8Array): number {35+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);36+ for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65_557); offset--) {37+ if (view.getUint32(offset, true) === END_OF_CENTRAL_DIRECTORY) return offset;38+ }39+ throw new Error("Playwright trace is not a ZIP archive");40+}41+42+async function inflateRaw(bytes: Uint8Array): Promise<Uint8Array> {43+ const input = new Blob([bytes.slice()]).stream();44+ const output = input.pipeThrough(new DecompressionStream("deflate-raw"));45+ return new Uint8Array(await new Response(output).arrayBuffer());46+}47+48+export async function readTraceArchive(path: string): Promise<Record<string, Uint8Array>> {49+ const bytes = await Deno.readFile(path);50+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);51+ const end = endOffset(bytes);52+ const count = view.getUint16(end + 10, true);53+ let centralOffset = view.getUint32(end + 16, true);54+ const entries: Record<string, Uint8Array> = {};55+56+ for (let index = 0; index < count; index++) {57+ if (view.getUint32(centralOffset, true) !== CENTRAL_FILE) {58+ throw new Error("Playwright trace has an invalid central directory");59+ }60+ const flags = view.getUint16(centralOffset + 8, true);61+ if (flags & 1) throw new Error("Encrypted trace entries cannot be sanitized");62+ const method = view.getUint16(centralOffset + 10, true);63+ const compressedSize = view.getUint32(centralOffset + 20, true);64+ const nameLength = view.getUint16(centralOffset + 28, true);65+ const extraLength = view.getUint16(centralOffset + 30, true);66+ const commentLength = view.getUint16(centralOffset + 32, true);67+ const localOffset = view.getUint32(centralOffset + 42, true);68+ const name = TEXT.decode(bytes.subarray(centralOffset + 46, centralOffset + 46 + nameLength));69+ if (view.getUint32(localOffset, true) !== LOCAL_FILE) {70+ throw new Error(`Playwright trace entry ${name} has an invalid local header`);71+ }72+ const localNameLength = view.getUint16(localOffset + 26, true);73+ const localExtraLength = view.getUint16(localOffset + 28, true);74+ const dataOffset = localOffset + 30 + localNameLength + localExtraLength;75+ const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize);76+ if (method === 0) entries[name] = compressed.slice();77+ else if (method === 8) entries[name] = await inflateRaw(compressed);78+ else throw new Error(`Playwright trace entry ${name} uses unsupported compression ${method}`);79+ centralOffset += 46 + nameLength + extraLength + commentLength;80+ }81+ return entries;82+}83+84+export function writeTraceArchive(entries: Readonly<Record<string, Uint8Array>>): Uint8Array {85+ const localParts: Uint8Array[] = [];86+ const centralParts: Uint8Array[] = [];87+ let localOffset = 0;88+89+ for (const [name, contents] of Object.entries(entries)) {90+ const nameBytes = UTF8.encode(name);91+ const checksum = crc32(contents);92+ const local = header(30);93+ local.view.setUint32(0, LOCAL_FILE, true);94+ local.view.setUint16(4, 20, true);95+ local.view.setUint32(14, checksum, true);96+ local.view.setUint32(18, contents.length, true);97+ local.view.setUint32(22, contents.length, true);98+ local.view.setUint16(26, nameBytes.length, true);99+ localParts.push(local.bytes, nameBytes, contents);100+101+ const central = header(46);102+ central.view.setUint32(0, CENTRAL_FILE, true);103+ central.view.setUint16(4, 20, true);104+ central.view.setUint16(6, 20, true);105+ central.view.setUint32(16, checksum, true);106+ central.view.setUint32(20, contents.length, true);107+ central.view.setUint32(24, contents.length, true);108+ central.view.setUint16(28, nameBytes.length, true);109+ central.view.setUint32(42, localOffset, true);110+ centralParts.push(central.bytes, nameBytes);111+ localOffset += local.bytes.length + nameBytes.length + contents.length;112+ }113+114+ const central = concatenate(centralParts);115+ const end = header(22);116+ end.view.setUint32(0, END_OF_CENTRAL_DIRECTORY, true);117+ end.view.setUint16(8, Object.keys(entries).length, true);118+ end.view.setUint16(10, Object.keys(entries).length, true);119+ end.view.setUint32(12, central.length, true);120+ end.view.setUint32(16, localOffset, true);121+ return concatenate([...localParts, central, end.bytes]);122+}123+124+/**125+ * Sanitize an intentionally snapshot-free trace archive. Response/source126+ * resources are omitted because they can contain arbitrary private bytes; the127+ * action and network metadata remain useful for failure diagnosis.128+ */129+export async function sanitizeTraceArchive(130+ path: string,131+ redact: (value: string) => string,132+): Promise<void> {133+ const entries = await readTraceArchive(path);134+ const sanitized: Record<string, Uint8Array> = {};135+ for (const [name, bytes] of Object.entries(entries)) {136+ if (name.startsWith("resources/")) continue;137+ sanitized[name] = UTF8.encode(redact(TEXT.decode(bytes)));138+ }139+ await Deno.writeFile(path, writeTraceArchive(sanitized));140+}
A package/tooling/diagnostics.ts +132 -0
@@ -0,0 +1,132 @@
1+import { LOFI_VERSION } from "../version.ts";2+import { type EnvironmentValidation, loadEnvironment, validateEnvironment } from "./environment.ts";3+import { type ProjectCheck, projectChecks } from "./project.ts";4+5+export type Diagnostic = {6+ name: string;7+ status: "ok" | "pending" | "blocker";8+ detail: string;9+ remediation?: string;10+};11+12+export type DoctorReport = {13+ version: string;14+ validation: EnvironmentValidation;15+ diagnostics: Diagnostic[];16+ blocked: boolean;17+};18+19+function denoSupported(version: string): boolean {20+ const [major = 0, minor = 0] = version.split(".").map(Number);21+ return major > 2 || (major === 2 && minor >= 9);22+}23+24+function projectDiagnostics(checks: readonly ProjectCheck[]): Diagnostic[] {25+ return checks.map((check) => ({26+ name: check.name,27+ status: check.status,28+ detail: check.detail,29+ remediation: check.remediation,30+ }));31+}32+33+export async function doctorReport(34+ options: {35+ root?: string;36+ environment?: Readonly<Record<string, string>>;37+ denoVersion?: string;38+ } = {},39+): Promise<DoctorReport> {40+ const root = options.root ?? Deno.cwd();41+ const environment = options.environment ?? await loadEnvironment(`${root}/.env`);42+ const validation = validateEnvironment(environment);43+ const denoVersion = options.denoVersion ?? Deno.version.deno;44+ const diagnostics: Diagnostic[] = [45+ {46+ name: "Package",47+ status: "ok",48+ detail: `@nzip/lofi ${LOFI_VERSION}`,49+ },50+ denoSupported(denoVersion) ? { name: "Deno", status: "ok", detail: denoVersion } : {51+ name: "Deno",52+ status: "blocker",53+ detail: `${denoVersion} is older than the supported 2.9 line`,54+ remediation: "upgrade Deno, then rerun `deno task doctor`",55+ },56+ ...projectDiagnostics(await projectChecks(root)),57+ ];58+59+ if (validation.ok) {60+ diagnostics.push({61+ name: "Environment",62+ status: "ok",63+ detail: validation.mode,64+ });65+ } else {66+ for (const error of validation.errors) {67+ diagnostics.push({68+ name: "Environment",69+ status: "blocker",70+ detail: error,71+ remediation: "edit `.env` once, then rerun `deno task doctor`",72+ });73+ }74+ }75+ diagnostics.push(76+ {77+ name: "Storage",78+ status: "pending",79+ detail: "OPFS durable driver requested; browser capability check runs at application boot",80+ remediation: "open the printed local or stable HTTPS URL in a supported browser",81+ },82+ {83+ name: "Persistence",84+ status: "pending",85+ detail: "browser eviction protection has not been queried",86+ remediation: "use the in-app device gate to request and verify persistence",87+ },88+ {89+ name: "Identity",90+ status: "ok",91+ detail: "device-local key; passkey backup blocked by alpha security review",92+ },93+ {94+ name: "Sync",95+ status: validation.mode === "cloud-configured" ? "ok" : "pending",96+ detail: validation.mode === "cloud-configured"97+ ? "managed adapter configured; live connection detail unavailable"98+ : "local-only; no transport convergence claim",99+ },100+ {101+ name: "PWA",102+ status: "pending",103+ detail: "development service worker disabled; production activation checked in browser",104+ },105+ {106+ name: "Device URL",107+ status: "pending",108+ detail: "not configured; stable HTTPS device preview graduates in M3",109+ },110+ );111+112+ return {113+ version: LOFI_VERSION,114+ validation,115+ diagnostics,116+ blocked: diagnostics.some((diagnostic) => diagnostic.status === "blocker"),117+ };118+}119+120+export function printDoctorReport(report: DoctorReport): void {121+ console.log(`lofi doctor ${report.version}`);122+ const width = Math.max(...report.diagnostics.map((diagnostic) => diagnostic.name.length));123+ for (const diagnostic of report.diagnostics) {124+ console.log(125+ `${diagnostic.name.padEnd(width)} ${diagnostic.status.padEnd(7)} ${diagnostic.detail}`,126+ );127+ if (diagnostic.remediation) {128+ console.log(`${"".padEnd(width)} action ${diagnostic.remediation}`);129+ }130+ }131+ for (const warning of report.validation.warnings) console.warn(`warning: ${warning}`);132+}
A package/tooling/diagnostics_test.ts +82 -0
@@ -0,0 +1,82 @@
1+import { join } from "node:path";2+import { createProject } from "../create_core.ts";3+import { doctorReport } from "./diagnostics.ts";4+5+function assert(condition: unknown, message: string): asserts condition {6+ if (!condition) throw new Error(message);7+}8+9+function makeTestRoot(): Promise<string> {10+ return Deno.makeTempDir({ dir: ".", prefix: ".lofi-doctor-test-" });11+}12+13+Deno.test("doctor reports value-free local capability states without booting Astro", async () => {14+ const cwd = await makeTestRoot();15+ try {16+ const project = await createProject({ cwd, name: "starter" });17+ const report = await doctorReport({18+ root: project.destination,19+ environment: {},20+ denoVersion: "2.9.0",21+ });22+ assert(!report.blocked, "valid generated project should not be blocked");23+ assert(report.validation.mode === "local-only", "empty environment should be local-only");24+ assert(25+ report.diagnostics.some((item) => item.name === "Storage" && item.status === "pending"),26+ "doctor must defer browser-only storage facts",27+ );28+ assert(29+ report.diagnostics.some((item) => item.name === "Device URL" && item.status === "pending"),30+ "doctor must report the stable device URL gap",31+ );32+ } finally {33+ await Deno.remove(cwd, { recursive: true });34+ }35+});36+37+Deno.test("doctor blocks partial configuration without exposing values", async () => {38+ const cwd = await makeTestRoot();39+ const secretValue = "must-not-appear-in-doctor-output";40+ try {41+ const project = await createProject({ cwd, name: "starter" });42+ const report = await doctorReport({43+ root: project.destination,44+ environment: {45+ JAZZ_APP_ID: secretValue,46+ JAZZ_ADMIN_SECRET: secretValue,47+ },48+ denoVersion: "2.9.0",49+ });50+ assert(report.blocked, "partial cloud configuration must block boot");51+ assert(report.validation.mode === "invalid", "partial cloud configuration must be invalid");52+ assert(53+ !JSON.stringify(report).includes(secretValue),54+ "doctor report exposed an environment value",55+ );56+ assert(57+ report.diagnostics.some((item) => item.remediation?.includes("edit `.env` once")),58+ "configuration blocker must include a one-edit remediation",59+ );60+ } finally {61+ await Deno.remove(cwd, { recursive: true });62+ }63+});64+65+Deno.test("doctor blocks unsupported Deno and incomplete generated layouts", async () => {66+ const root = await makeTestRoot();67+ try {68+ await Deno.writeTextFile(join(root, "deno.json"), "{}\n");69+ const report = await doctorReport({ root, environment: {}, denoVersion: "2.8.9" });70+ assert(report.blocked, "unsupported runtime and missing files must block boot");71+ assert(72+ report.diagnostics.some((item) => item.name === "Deno" && item.status === "blocker"),73+ "old Deno was not reported",74+ );75+ assert(76+ report.diagnostics.some((item) => item.name === "Project" && item.status === "blocker"),77+ "missing project files were not reported",78+ );79+ } finally {80+ await Deno.remove(root, { recursive: true });81+ }82+});
A package/tooling/environment.ts +167 -0
@@ -0,0 +1,167 @@
1+export const clientEnvironmentNames = ["JAZZ_APP_ID", "JAZZ_SERVER_URL"] as const;2+export const serverEnvironmentNames = ["JAZZ_ADMIN_SECRET", "BACKEND_SECRET"] as const;3+export const environmentNames = [...clientEnvironmentNames, ...serverEnvironmentNames] as const;4+5+interface EnvironmentResultBase {6+ presentClientNames: string[];7+ presentServerNames: string[];8+ warnings: string[];9+}10+11+export type EnvironmentValidation =12+ & EnvironmentResultBase13+ & (14+ | {15+ ok: true;16+ mode: "local-only";17+ client: Record<string, never>;18+ }19+ | {20+ ok: true;21+ mode: "cloud-configured";22+ client: { JAZZ_APP_ID: string; JAZZ_SERVER_URL: string };23+ }24+ | {25+ ok: false;26+ mode: "invalid";27+ errors: string[];28+ }29+ );30+31+function isPresent(env: Readonly<Record<string, string>>, name: string): boolean {32+ return (env[name] ?? "").trim().length > 0;33+}34+35+export function validateEnvironment(36+ env: Readonly<Record<string, string>>,37+): EnvironmentValidation {38+ const presentClientNames = clientEnvironmentNames.filter((name) => isPresent(env, name));39+ const presentServerNames = serverEnvironmentNames.filter((name) => isPresent(env, name));40+ const errors: string[] = [];41+ const warnings: string[] = [];42+43+ if (44+ presentClientNames.length > 0 && presentClientNames.length !== clientEnvironmentNames.length45+ ) {46+ const missing = clientEnvironmentNames.filter((name) => !isPresent(env, name));47+ errors.push(48+ `Cloud sync cannot start because configuration is incomplete; set ${49+ missing.join(", ")50+ } or remove the partial JAZZ_APP_ID/JAZZ_SERVER_URL pair to run local-only.`,51+ );52+ }53+54+ if (isPresent(env, "JAZZ_SERVER_URL")) {55+ try {56+ const url = new URL(env.JAZZ_SERVER_URL);57+ if (!new Set(["http:", "https:"]).has(url.protocol)) {58+ errors.push(59+ "Cloud sync cannot start because JAZZ_SERVER_URL must use http or https; correct the URL or remove the cloud configuration to run local-only.",60+ );61+ }62+ } catch {63+ errors.push(64+ "Cloud sync cannot start because JAZZ_SERVER_URL is not a valid URL; correct it or remove the cloud configuration to run local-only.",65+ );66+ }67+ }68+69+ const base: EnvironmentResultBase = {70+ presentClientNames: [...presentClientNames],71+ presentServerNames: [...presentServerNames],72+ warnings,73+ };74+ if (errors.length > 0) return { ...base, ok: false, mode: "invalid", errors };75+ if (presentClientNames.length === 0) {76+ if (presentServerNames.length > 0) {77+ warnings.push(78+ "Server-only credentials are present without cloud client configuration; they remain isolated and unused by the client.",79+ );80+ }81+ return { ...base, ok: true, mode: "local-only", client: {} };82+ }83+ return {84+ ...base,85+ ok: true,86+ mode: "cloud-configured",87+ client: {88+ JAZZ_APP_ID: env.JAZZ_APP_ID.trim(),89+ JAZZ_SERVER_URL: env.JAZZ_SERVER_URL.trim(),90+ },91+ };92+}93+94+export function parseDotenv(source: string): Record<string, string> {95+ const parsed: Record<string, string> = {};96+ for (const originalLine of source.split(/\r?\n/)) {97+ let line = originalLine.trim();98+ if (line.length === 0 || line.startsWith("#")) continue;99+ if (line.startsWith("export ")) line = line.slice("export ".length).trimStart();100+ const separator = line.indexOf("=");101+ if (separator < 1) continue;102+ const name = line.slice(0, separator).trim();103+ if (!environmentNames.includes(name as (typeof environmentNames)[number])) continue;104+ let value = line.slice(separator + 1).trim();105+ if (106+ value.length >= 2 &&107+ ((value.startsWith('"') && value.endsWith('"')) ||108+ (value.startsWith("'") && value.endsWith("'")))109+ ) {110+ value = value.slice(1, -1);111+ }112+ parsed[name] = value;113+ }114+ return parsed;115+}116+117+export async function loadEnvironment(118+ path = ".env",119+ processLookup: (name: string) => string | undefined = (name) => Deno.env.get(name),120+): Promise<Record<string, string>> {121+ let fileEnvironment: Record<string, string> = {};122+ try {123+ fileEnvironment = parseDotenv(await Deno.readTextFile(path));124+ } catch (error) {125+ if (!(error instanceof Deno.errors.NotFound)) throw error;126+ }127+ return Object.fromEntries(128+ environmentNames.map((name) => [name, processLookup(name) ?? fileEnvironment[name] ?? ""]),129+ );130+}131+132+const childEnvironmentAllowlist = [133+ "CI",134+ "DENO_DIR",135+ "HOME",136+ "LANG",137+ "LC_ALL",138+ "NIX_SSL_CERT_FILE",139+ "NO_PROXY",140+ "PATH",141+ "PLAYWRIGHT_BROWSERS_PATH",142+ "SSL_CERT_FILE",143+ "TEMP",144+ "TMP",145+ "TMPDIR",146+] as const;147+148+export function childEnvironment(149+ validation: Exclude<EnvironmentValidation, { ok: false }>,150+ source: Record<string, string> = Deno.env.toObject(),151+): Record<string, string> {152+ const environment: Record<string, string> = {};153+ for (const name of childEnvironmentAllowlist) {154+ const value = source[name];155+ if (value) environment[name] = value;156+ }157+ if (validation.mode === "cloud-configured") {158+ environment.JAZZ_APP_ID = validation.client.JAZZ_APP_ID;159+ environment.JAZZ_SERVER_URL = validation.client.JAZZ_SERVER_URL;160+ } else {161+ environment.JAZZ_APP_ID = "";162+ environment.JAZZ_SERVER_URL = "";163+ }164+ environment.JAZZ_ADMIN_SECRET = "";165+ environment.BACKEND_SECRET = "";166+ return environment;167+}
A package/tooling/environment_test.ts +22 -0
A package/tooling/process.ts +55 -0
@@ -0,0 +1,55 @@
1+export type DenoRunStatus = Deno.CommandStatus & {2+ forwardedSignal: Deno.Signal | null;3+};4+5+export async function runDenoStatus(6+ args: readonly string[],7+ environment: Readonly<Record<string, string>>,8+ cwd = Deno.cwd(),9+): Promise<DenoRunStatus> {10+ const child = new Deno.Command(Deno.execPath(), {11+ args: [...args],12+ cwd,13+ clearEnv: true,14+ env: { ...environment },15+ stdin: "inherit",16+ stdout: "piped",17+ stderr: "piped",18+ }).spawn();19+ let forwardedSignal: Deno.Signal | null = null;20+ const forward = (21+ stream: ReadableStream<Uint8Array>,22+ target: WritableStream<Uint8Array>,23+ ) => stream.pipeTo(target, { preventClose: true });24+ const forwardSignal = (signal: Deno.Signal) => {25+ try {26+ child.kill(signal);27+ forwardedSignal ??= signal;28+ } catch (error) {29+ if (!(error instanceof Deno.errors.NotFound)) throw error;30+ }31+ };32+ const onInterrupt = () => forwardSignal("SIGINT");33+ const onTerminate = () => forwardSignal("SIGTERM");34+ Deno.addSignalListener("SIGINT", onInterrupt);35+ Deno.addSignalListener("SIGTERM", onTerminate);36+ try {37+ const [status] = await Promise.all([38+ child.status,39+ forward(child.stdout, Deno.stdout.writable),40+ forward(child.stderr, Deno.stderr.writable),41+ ]);42+ return { ...status, forwardedSignal };43+ } finally {44+ Deno.removeSignalListener("SIGINT", onInterrupt);45+ Deno.removeSignalListener("SIGTERM", onTerminate);46+ }47+}48+49+export async function runDeno(50+ args: readonly string[],51+ environment: Readonly<Record<string, string>>,52+ cwd = Deno.cwd(),53+): Promise<number> {54+ return (await runDenoStatus(args, environment, cwd)).code;55+}
A package/tooling/process_test.ts +68 -0
@@ -0,0 +1,68 @@
1+import { runDenoStatus } from "./process.ts";2+3+function assert(condition: unknown, message: string): asserts condition {4+ if (!condition) throw new Error(message);5+}6+7+Deno.test({8+ name: "runDenoStatus distinguishes a forwarded shutdown signal from a child failure",9+ ignore: Deno.build.os === "windows",10+ async fn() {11+ const moduleUrl = new URL("./process.ts", import.meta.url).href;12+ const wrapperSource = `13+ import { runDenoStatus } from ${JSON.stringify(moduleUrl)};14+ const status = await runDenoStatus(15+ ["eval", "console.log('READY'); await new Promise(() => {})"],16+ {},17+ );18+ console.log("STATUS " + JSON.stringify(status));19+ if (status.forwardedSignal !== "SIGTERM") Deno.exit(1);20+ `;21+ const wrapper = new Deno.Command(Deno.execPath(), {22+ args: ["eval", wrapperSource],23+ clearEnv: true,24+ stdout: "piped",25+ stderr: "piped",26+ }).spawn();27+ const reader = wrapper.stdout.pipeThrough(new TextDecoderStream()).getReader();28+ let stdout = "";29+ try {30+ while (!stdout.includes("READY")) {31+ const next = await Promise.race([32+ reader.read(),33+ new Promise<never>((_, reject) =>34+ setTimeout(() => reject(new Error("signal test child did not become ready")), 5_000)35+ ),36+ ]);37+ if (next.done) throw new Error(`signal test child exited before readiness: ${stdout}`);38+ stdout += next.value;39+ }40+ wrapper.kill("SIGTERM");41+ while (true) {42+ const next = await reader.read();43+ if (next.done) break;44+ stdout += next.value;45+ }46+ const [status, stderr] = await Promise.all([47+ wrapper.status,48+ new Response(wrapper.stderr).text(),49+ ]);50+ assert(status.success, `signal test wrapper failed (${status.code}): ${stderr}`);51+ assert(52+ stdout.includes('"forwardedSignal":"SIGTERM"'),53+ `forwarded signal was not retained: ${stdout}`,54+ );55+ } finally {56+ reader.releaseLock();57+ try {58+ wrapper.kill("SIGKILL");59+ } catch {60+ // The expected path already exited cleanly.61+ }62+ }63+64+ const unexpected = await runDenoStatus(["eval", "Deno.exit(17)"], {});65+ assert(unexpected.code === 17, `unexpected child code changed: ${unexpected.code}`);66+ assert(unexpected.forwardedSignal === null, "ordinary child failure was marked as a shutdown");67+ },68+});
A package/tooling/project.ts +164 -0
@@ -0,0 +1,164 @@
1+import { extname, join, relative, resolve } from "node:path";2+3+const ignoredDirectories = new Set([4+ ".astro",5+ ".git",6+ ".playwright-cli",7+ ".vite",8+ "coverage",9+ "node_modules",10+ "playwright-report",11+ "test-results",12+]);13+14+export type ProjectCheck = {15+ name: string;16+ status: "ok" | "blocker";17+ detail: string;18+ remediation?: string;19+};20+21+export async function projectChecks(root = Deno.cwd()): Promise<ProjectCheck[]> {22+ const required = [23+ "deno.json",24+ "astro.config.ts",25+ "src/schema.ts",26+ "src/permissions.ts",27+ "src/app.ts",28+ "src/pages/index.astro",29+ "src/islands/ChecklistIsland.tsx",30+ "src/_lofi/boot.ts",31+ "public/manifest.webmanifest",32+ "public/sw.js",33+ ];34+ const missing: string[] = [];35+ for (const path of required) {36+ try {37+ if (!(await Deno.stat(join(root, path))).isFile) missing.push(path);38+ } catch (error) {39+ if (error instanceof Deno.errors.NotFound) missing.push(path);40+ else throw error;41+ }42+ }43+ return missing.length === 044+ ? [{ name: "Project", status: "ok", detail: "generated layout complete" }]45+ : [{46+ name: "Project",47+ status: "blocker",48+ detail: `missing ${missing.join(", ")}`,49+ remediation: "restore the generated files or create a fresh project and move author files",50+ }];51+}52+53+export async function walkFiles(54+ root = Deno.cwd(),55+ options: { includeDist?: boolean; extensions?: ReadonlySet<string> } = {},56+): Promise<string[]> {57+ const absoluteRoot = resolve(root);58+ const files: string[] = [];59+ async function visit(path: string) {60+ const entries = [...Deno.readDirSync(path)].sort((left, right) =>61+ left.name.localeCompare(right.name)62+ );63+ for (const entry of entries) {64+ const child = join(path, entry.name);65+ if (66+ entry.name === ".env" || (entry.name.startsWith(".env.") && entry.name !== ".env.example")67+ ) {68+ continue;69+ }70+ if (entry.isDirectory) {71+ if (ignoredDirectories.has(entry.name) && !(entry.name === "dist" && options.includeDist)) {72+ continue;73+ }74+ await visit(join(path, entry.name));75+ } else if (entry.isFile) {76+ if (!options.extensions || options.extensions.has(extname(child))) {77+ files.push(relative(absoluteRoot, child));78+ }79+ } else {80+ throw new Error(81+ `project contains unsupported symbolic link: ${relative(absoluteRoot, child)}; ` +82+ "replace it with a regular file or directory, then rerun the command",83+ );84+ }85+ }86+ }87+ await visit(absoluteRoot);88+ return files.sort();89+}90+91+export async function sourceFingerprint(root = Deno.cwd()): Promise<string> {92+ const files = await walkFiles(root, {93+ extensions: new Set([94+ ".astro",95+ ".css",96+ ".js",97+ ".json",98+ ".svg",99+ ".ts",100+ ".tsx",101+ ".webmanifest",102+ ]),103+ });104+ const encoder = new TextEncoder();105+ const chunks: Uint8Array[] = [];106+ let length = 0;107+ for (const path of files) {108+ const header = encoder.encode(`${path}\0`);109+ const content = await Deno.readFile(join(root, path));110+ chunks.push(header, content);111+ length += header.length + content.length;112+ }113+ const input = new Uint8Array(length);114+ let offset = 0;115+ for (const chunk of chunks) {116+ input.set(chunk, offset);117+ offset += chunk.length;118+ }119+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));120+ return Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");121+}122+123+const precacheExcludedPaths = new Set([124+ "lofi-build.json",125+ "lofi-precache.json",126+ "sw.js",127+]);128+129+export function precacheUrls(paths: readonly string[]): string[] {130+ return paths131+ .map((path) => path.replaceAll("\\", "/"))132+ .filter((path) => !precacheExcludedPaths.has(path))133+ .map((path) => path === "index.html" ? "./" : `./${path}`)134+ .sort();135+}136+137+function contains(haystack: Uint8Array, needle: Uint8Array): boolean {138+ if (needle.length === 0 || haystack.length < needle.length) return false;139+ outer:140+ for (let offset = 0; offset <= haystack.length - needle.length; offset++) {141+ for (let index = 0; index < needle.length; index++) {142+ if (haystack[offset + index] !== needle[index]) continue outer;143+ }144+ return true;145+ }146+ return false;147+}148+149+export async function scanSecrets(150+ secrets: Readonly<Record<string, string>>,151+ root = Deno.cwd(),152+): Promise<{ scanned: number; leaks: Array<{ name: string; path: string }> }> {153+ const values = Object.entries(secrets).filter(([, value]) => value.trim().length > 0);154+ const files = await walkFiles(root, { includeDist: true });155+ const encoder = new TextEncoder();156+ const leaks: Array<{ name: string; path: string }> = [];157+ for (const path of files) {158+ const content = await Deno.readFile(join(root, path));159+ for (const [name, value] of values) {160+ if (contains(content, encoder.encode(value))) leaks.push({ name, path });161+ }162+ }163+ return { scanned: files.length, leaks };164+}
A package/tooling/project_test.ts +120 -0

Not shown: this page already shows 6000 lines of diff.

A package/version.ts +5 -0

Not shown: this page already shows 6000 lines of diff.

M tools/artifact_files_test.ts +3 -3

Not shown: this page already shows 6000 lines of diff.

A tools/assert.ts +3 -0

Not shown: this page already shows 6000 lines of diff.

A tools/check_migrations.ts +161 -0

Not shown: this page already shows 6000 lines of diff.

A tools/golden_path.ts +1220 -0

Not shown: this page already shows 6000 lines of diff.

A tools/golden_path_checkout.ts +18 -0

Not shown: this page already shows 6000 lines of diff.

A tools/golden_path_core.ts +707 -0

Not shown: this page already shows 6000 lines of diff.

A tools/golden_path_core_test.ts +340 -0

Not shown: this page already shows 6000 lines of diff.

A tools/golden_path_create.ts +79 -0

Not shown: this page already shows 6000 lines of diff.

M tools/preview.ts +1 -1

Not shown: this page already shows 6000 lines of diff.

R tools/run_prototype.ts → tools/run_reference.ts +9 -9

Not shown: this page already shows 6000 lines of diff.