feat: graduate M2 reference golden path
- commit
- b054684f4fd13794a9e0ac2354a4795b4f975739
- parent
- 33fdd3593240e7abf4bd92eab0c3c38d85472333
- parent
- 9477d596af447e63454c937f0fcb241d7a5abfbf
- author
- FelineStateMachine <dami.etoile@gmail.com>
@@ -0,0 +1,35 @@
1+name: Milestone task2+description: Implementation or documentation work that advances a milestone3+body:4+ - type: textarea5+ id: outcome6+ attributes:7+ label: Outcome8+ description: What exists (and is observable) when this issue closes.9+ validations:10+ required: true11+ - type: input12+ id: contract-ids13+ attributes:14+ label: Contract IDs15+ description: >-16+ Promises from docs/devx-contract.md this work implements or changes. Write "none" for17+ mechanical changes.18+ placeholder: DX-CMD-01, DX-CREATE-0119+ validations:20+ required: true21+ - type: textarea22+ id: acceptance23+ attributes:24+ label: Acceptance criteria25+ description: Checkable statements a reviewer can verify without re-deriving the work.26+ placeholder: |27+ - [ ] `deno task doctor` prints a versioned capability table without values28+ - [ ] Nonzero exit for blockers29+ validations:30+ required: true31+ - type: textarea32+ id: out-of-scope33+ attributes:34+ label: Out of scope35+ description: Adjacent work this issue deliberately does not claim.@@ -0,0 +1,50 @@
1+name: Spike2+description: A time-boxed experiment that must end in an explicit decision3+labels: ["type: spike"]4+body:5+ - type: input6+ id: question7+ attributes:8+ label: Question9+ description: The falsifiable choice or platform claim this spike resolves.10+ placeholder: Does Jazz 2 OPFS persistence survive process kill on iOS Safari 16.4?11+ validations:12+ required: true13+ - type: input14+ id: contract-ids15+ attributes:16+ label: Contract IDs17+ description: Promises from docs/devx-contract.md this spike exercises.18+ placeholder: DX-DUR-01, DX-ERROR-0119+ validations:20+ required: true21+ - type: textarea22+ id: control23+ attributes:24+ label: Control25+ description: The smallest vendor-supported baseline the experiment is compared against.26+ validations:27+ required: true28+ - type: textarea29+ id: kill-criteria30+ attributes:31+ label: Decision / kill criteria32+ description: What result adopts, revises, defers, or rejects — decided before running.33+ validations:34+ required: true35+ - type: textarea36+ id: evidence-plan37+ attributes:38+ label: Evidence plan39+ description: >-40+ What will be retained (paths, measurements, device logs/screenshots, commit SHA) so someone41+ else can repeat it. A demo rendering once is not evidence.42+ validations:43+ required: true44+ - type: input45+ id: timebox46+ attributes:47+ label: Timebox48+ placeholder: 2 days49+ validations:50+ required: true@@ -0,0 +1,20 @@
1+## Summary2+3+<!-- What changed and why, in terms a reviewer can verify. Link the issues this closes. -->4+5+## Contract impact6+7+<!-- Contract IDs implemented or changed (e.g. DX-LOCAL-01), with status transitions8+ (proposed → validated / revised / rejected). Write "none" for mechanical changes. -->9+10+## Evidence11+12+<!-- What was run and observed; where retained evidence lives (paths, measurements,13+ decision record). "Worked once" is not evidence. -->14+15+## Checklist16+17+- [ ] `deno task check` passes locally18+- [ ] Contract table and decision records updated if any promise changed19+- [ ] No server-only secret value appears in code, examples, logs, or build output20+- [ ] Product UI stays inside the author boundary (no raw Jazz / workers / transports / Workbox)@@ -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.@@ -0,0 +1,133 @@
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 and merged; M2 (DevX vertical slice) is in active development. See the14+> [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 (M1 journey)44+45+The generator does not exist yet; the graduated prototype runs from a checkout:46+47+```sh48+git clone https://github.com/FelineStateMachine/lofi.git49+cd lofi50+deno task dev51+```52+53+- **Deno 2.9+** is the only required global runtime.54+- **No `.env` means local-only mode**, stated explicitly at boot. For cloud sync, copy55+ `.env.example` and set the public `JAZZ_APP_ID`/`JAZZ_SERVER_URL` pair. Server-only secrets never56+ reach client output — this is tested, not hoped.57+- From `deno task dev` you should reach a retained local write in under 60 seconds58+ ([DX-PROTOTYPE-01](docs/devx-contract.md#measurable-promises)).59+60+### Command surface61+62+| Command | What it does today |63+| ------------------- | ------------------------------------------------------------------------ |64+| `deno task dev` | Serves the prototype; prints URL plus storage, identity, and sync state. |65+| `deno task check` | Format, lint, type, Astro, env-contract, and secret-leak checks. |66+| `deno task test` | Deterministic local-first tests (no hand-timed sleeps). |67+| `deno task build` | Production build through the supported Deno path. |68+| `deno task preview` | Serves the production build. |69+70+`create` and `doctor` graduate in M2 — their output contracts are already specified in the71+[DevX contract](docs/devx-contract.md#canonical-command-surface-and-output-contract).72+73+## How this repository works74+75+Development here is **contract-driven**: the product is a set of falsifiable promises, and code76+graduates only by producing evidence against them.77+78+1. **[The DevX contract](docs/devx-contract.md)** defines measurable promises with IDs (like79+ `DX-LOCAL-01`), budgets, and statuses (`proposed → validated / revised / rejected`).80+2. **[Decision records](docs/decisions/)** capture each experiment: question, exact versions,81+ control, procedure, retained evidence, and the resulting contract delta. A spike is complete when82+ it supports a decision — including a negative one — not when a demo renders once.83+3. **[Milestones](https://github.com/FelineStateMachine/lofi/milestones)** gate scope. Issues carry84+ `area:*`, `type:*`, and `priority:p0–p2` labels; p0 blocks the golden path.85+86+Notable honest outcomes so far: the Jazz 2 alpha passkey-backup ceremony was **rejected** on87+security grounds rather than demoed88+([decision 0004](docs/decisions/0004-reject-alpha53-passkey-backup.md)), and transport reconnection89+is reported as _unavailable_ rather than faked, because the pinned API exposes no such signal.90+91+## Roadmap92+93+| Milestone | Goal | Status |94+| ----------------------------- | -------------------------------------------------------------------------------------------------- | -------------- |95+| **M0 — DevX contract** | Define the golden path, command surface, environment contract, and measurable acceptance criteria. | ✅ Done |96+| **M1 — Feasibility spikes** | Prove Jazz 2, Preact, OPFS, identity, Astro islands, and Deno behavior with go/no-go decisions. | ✅ Done |97+| **M2 — DevX vertical slice** | `create`, `dev`, `doctor`, diagnostics, inspector, and testing workflows around a reference app. | 🚧 In progress |98+| **M3 — Mobile PWA hardening** | Install, persistence, offline cold start, lifecycle recovery, and physical-device validation. | ⏳ Queued |99+| **M4 — Framework extraction** | Extract proven seams into `@nzip/lofi` subpath exports; validate them through a second app. | ⏳ Queued |100+101+The definition of done for the whole prototype is the [north star](#the-north-star) journey passing102+its contract budgets end-to-end, on real devices.103+104+## Repository map105+106+```text107+lofi/108+├── docs/109+│ ├── devx-contract.md # The product contract: promises, budgets, statuses110+│ └── decisions/ # Evidence-backed decision records (ADR-style)111+├── apps/reference/ # The integrated app graduated from M1 and reshaped in M2112+├── spikes/ # M1 feasibility experiments, kept for evidence113+├── tools/ # Deno tasks: env contract, secret scanning, runners114+├── init.md # Original research plan — input, not the contract115+└── deno.json # Workspace tasks and pinned toolchain116+```117+118+## Stack119+120+| Layer | Choice | Pinned at |121+| ---------- | --------------------------------------------- | ------------------- |122+| Data/sync | jazz-tools 2.0 alpha (exact pin), OPFS | `2.0.0-alpha.53` |123+| UI runtime | Preact islands, `client:only`, thin adapter | Preact 10 / Astro 7 |124+| Shell | Astro, fully prerendered, static-through-Deno | Astro 7 |125+| Toolchain | Deno tasks + npm-compat | Deno 2.9 |126+127+Version pins are deliberate: the data layer is an alpha, so every bump is a reviewed decision, and128+`docs/decisions/` records what each pin was verified to do.129+130+## Contributing131+132+See [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow: how promises, evidence, and decision133+records fit together, what `deno task check` enforces, and how issues and milestones are organized.@@ -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-}@@ -1,35 +0,0 @@
1----2-import NotesIsland from "../components/NotesIsland";3-import DeviceStatus from "../components/DeviceStatus";4-import "../styles/global.css";5----6-7-<!doctype html>8-<html lang="en">9- <head>10- <meta charset="UTF-8" />11- <meta name="viewport" content="width=device-width" />12- <meta name="theme-color" content="#18231f" />13- <link rel="icon" href="./favicon.svg" type="image/svg+xml" />14- <link rel="manifest" href="./manifest.webmanifest" />15- <title>lofi prototype</title>16- </head>17- <body>18- <main>19- <header class="hero">20- <p class="eyebrow">M1 graduated candidate</p>21- <h1>One local-first runtime.<br />Two Astro islands.</h1>22- <p>23- Writes are local first, storage is explicit, and framework machinery stays behind the24- component boundary.25- </p>26- </header>27- <div class="island-grid">28- <NotesIsland label="North island" client:load />29- <NotesIsland label="South island" client:load />30- </div>31- <DeviceStatus client:load />32- <footer>Thin Preact adapter · static Astro shell · Deno command surface</footer>33- </main>34- </body>35-</html>@@ -1,9 +0,0 @@
1-import { schema as s } from "jazz-tools";2-import { app } from "./schema.ts";3-4-export default s.definePermissions(app, ({ policy, session }) => {5- policy.notes.allowRead.where({ $createdBy: session.user_id });6- policy.notes.allowInsert.always();7- policy.notes.allowUpdate.where({ $createdBy: session.user_id });8- policy.notes.allowDelete.where({ $createdBy: session.user_id });9-});@@ -1,16 +0,0 @@
1-/// <reference path="../env.d.ts" />2-3-import type { DbConfig } from "jazz-tools";4-5-const LOCAL_APP_ID = "00000000-0000-0000-0000-00000000f153";6-export const appId = import.meta.env?.VITE_JAZZ_APP_ID ?? LOCAL_APP_ID;7-export const serverUrl = import.meta.env?.VITE_JAZZ_SERVER_URL;8-9-export function databaseConfig(secret: string): DbConfig {10- return {11- appId,12- ...(serverUrl ? { serverUrl } : {}),13- secret,14- driver: { type: "persistent", dbName: `lofi-prototype-${appId}` },15- };16-}@@ -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-});@@ -1,34 +0,0 @@
1-import {2- getRuntime,3- getRuntimeDiagnostics,4- recreateRuntime,5- runtimeRecreatedEvent,6- shutdownRuntime,7-} from "./lofi-runtime.ts";8-9-export type PrototypeProbe = {10- diagnostics: typeof getRuntimeDiagnostics;11- add(body: string): Promise<void>;12- update(id: string, body: string): Promise<void>;13- recreate(): Promise<void>;14- shutdown(): Promise<void>;15-};16-17-declare global {18- interface Window {19- __LOFI_PROTOTYPE__?: PrototypeProbe;20- }21-}22-23-if (typeof document !== "undefined") {24- (globalThis as typeof globalThis & Window).__LOFI_PROTOTYPE__ = {25- diagnostics: getRuntimeDiagnostics,26- add: async (body) => await (await getRuntime()).notes.add(body),27- update: async (id, body) => await (await getRuntime()).notes.update(id, body),28- recreate: async () => {29- await recreateRuntime();30- globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));31- },32- shutdown: shutdownRuntime,33- };34-}@@ -0,0 +1,27 @@
1+# lofi reference application2+3+This application proves the author-facing lofi workflow before the public `@nzip/lofi` package is4+extracted. It is deliberately split into two ownership zones.5+6+## Author-owned files7+8+- `src/schema.ts` — application data model9+- `src/permissions.ts` — application access policy10+- `src/app.ts` — named application configuration11+- `src/pages/` — product pages and shell content12+- `src/islands/` — product interaction and rendering13+- `src/styles/` — product styling14+- `tests/` — product behavior and author-boundary tests15+16+## Generated and runtime-owned files17+18+- `astro.config.ts`, `tsconfig.json`, and `src/env.d.ts` — compatible toolchain wiring19+- `public/manifest.webmanifest`, `public/sw.js`, and `public/favicon.svg` — PWA plumbing20+- `src/_lofi/` — storage, identity, synchronization, lifecycle, diagnostics, and framework adapter21+22+Application work should not require edits in `src/_lofi/`. Its local path is a temporary extraction23+seam: later M2 work may replace it with generated files or `@nzip/lofi` subpath imports without24+changing product UI.25+26+Passkey backup and recovery are intentionally absent. The pinned Jazz alpha exposes a rejected27+credential design, so this reference uses a device-local identity and does not imply recoverability.@@ -16,6 +16,11 @@ if (Boolean(cloudAppId) !== Boolean(cloudServerUrl)) {
1616 "Cloud mode requires both JAZZ_APP_ID and JAZZ_SERVER_URL; set the missing name or remove the partial pair.",1717 );1818 }19+20+// Jazz's managed Vite plugin reads the VITE_ pair when selecting the server21+// that receives schema, migration, and permission publication. The reference22+// runtime still receives explicit build constants below, so only this public23+// pair enters Vite's client-visible namespace and server secrets remain private.1924 if (cloudAppId && cloudServerUrl) {2025 process.env.VITE_JAZZ_APP_ID = cloudAppId;2126 process.env.VITE_JAZZ_SERVER_URL = cloudServerUrl;@@ -35,6 +40,10 @@ export default defineConfig({
3540 server: {3641 fs: { allow: [workspaceRoot] },3742 },43+ define: {44+ __LOFI_JAZZ_APP_ID__: JSON.stringify(cloudAppId ?? ""),45+ __LOFI_JAZZ_SERVER_URL__: JSON.stringify(cloudServerUrl ?? ""),46+ },3847 resolve: {3948 noExternal: ["@astrojs/preact"],4049 },No content changes.
@@ -1,7 +1,7 @@
11 {2- "name": "lofi device contract",2+ "name": "lofi checklist",33 "short_name": "lofi",4- "description": "M1 local-first durability and identity contract harness",4+ "description": "A durable local-first checklist reference application",55 "start_url": "./",66 "scope": "./",77 "display": "standalone",No content changes.
@@ -1,5 +1,5 @@
1-import { useDeviceCapabilities } from "../runtime/use-device-capabilities.ts";2-import { settleUiMutation } from "../runtime/ui-mutation.ts";1+import { useDeviceCapabilities } from "./use-device-capabilities.ts";2+import { settleUiMutation } from "./ui-mutation.ts";33 44 export default function DeviceStatus() {55 const { report, requestPersistence } = useDeviceCapabilities();@@ -56,7 +56,7 @@ export default function DeviceStatus() {
5656 </p>5757 <p>5858 Clearing site data destroys the device-local identity unless a separate recovery mechanism59- has been verified. This prototype does not claim passkey recovery.59+ has been verified. This reference does not claim passkey recovery.6060 </p>6161 </section>6262 );@@ -0,0 +1,25 @@
1+---2+export interface Props {3+ title: string;4+}5+6+const { title } = Astro.props;7+---8+9+<!doctype html>10+<html lang="en">11+ <head>12+ <meta charset="UTF-8" />13+ <meta name="viewport" content="width=device-width" />14+ <meta name="theme-color" content="#18231f" />15+ <link rel="icon" href="./favicon.svg" type="image/svg+xml" />16+ <link rel="manifest" href="./manifest.webmanifest" />17+ <title>{title}</title>18+ </head>19+ <body>20+ <main><slot /></main>21+ <script>22+ import "./boot.ts";23+ </script>24+ </body>25+</html>No content changes.
@@ -1,11 +1,17 @@
11 import type { Db, MutationErrorEvent } from "jazz-tools";2-import { app } from "../schema.ts";2+import { referenceApp } from "../app.ts";33 import { serverUrl } from "./config.ts";44 5-export type Note = { id: string; body: string; createdAt: Date };6-export type NotesSnapshot = {5+export type ChecklistTask = {6+ id: string;7+ text: string;8+ completed: boolean;9+ createdAt: Date;10+};11+12+export type ChecklistSnapshot = {713 status: "loading" | "ready" | "error";8- notes: Note[];14+ tasks: ChecklistTask[];915 durability: "none" | "local" | "global" | "failed";1016 error: string | null;1117 };@@ -25,25 +31,31 @@ export type RuntimeDiagnostics = {
2531 };2632 2733 type Listener = () => void;34+type MutationHandle = {35+ wait(options: { tier: "local" | "global" }): Promise<unknown>;36+};2837 29-export class NotesStore {38+export class ChecklistStore {3039 readonly #db: Db;3140 readonly #diagnostics: RuntimeDiagnostics;41+ readonly #syncConfigured: boolean;3242 readonly #listeners = new Set<Listener>();3343 #vendorUnsubscribe: (() => void) | null = null;34- #snapshot: NotesSnapshot = {44+ #writeGeneration = 0;45+ #snapshot: ChecklistSnapshot = {3546 status: "loading",36- notes: [],47+ tasks: [],3748 durability: "none",3849 error: null,3950 };4051 41- constructor(db: Db, diagnostics: RuntimeDiagnostics) {52+ constructor(db: Db, diagnostics: RuntimeDiagnostics, syncConfigured = Boolean(serverUrl)) {4253 this.#db = db;4354 this.#diagnostics = diagnostics;55+ this.#syncConfigured = syncConfigured;4456 }4557 46- getSnapshot = (): NotesSnapshot => this.#snapshot;58+ getSnapshot = (): ChecklistSnapshot => this.#snapshot;4759 4860 subscribe = (listener: Listener): () => void => {4961 this.#listeners.add(listener);@@ -61,54 +73,79 @@ export class NotesStore {
6173 };6274 };6375 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- }76+ async create(text: string): Promise<void> {77+ const now = new Date();78+ await this.#settle(this.#db.insert(referenceApp.schema.tasks, {79+ text,80+ completed: false,81+ createdAt: now,82+ }));8083 }8184 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- }85+ async update(id: string, text: string): Promise<void> {86+ await this.#settle(87+ this.#db.update(referenceApp.schema.tasks, id, { text }),88+ );89+ }90+91+ async setCompleted(id: string, completed: boolean): Promise<void> {92+ await this.#settle(93+ this.#db.update(referenceApp.schema.tasks, id, { completed }),94+ );95+ }96+97+ async delete(id: string): Promise<void> {98+ await this.#settle(this.#db.delete(referenceApp.schema.tasks, id));9199 }92100 93- reportMutationError(event: MutationErrorEvent) {101+ reportMutationError(event: MutationErrorEvent): void {94102 this.#diagnostics.mutationErrors += 1;95103 this.#fail(new Error(`${event.code}: ${event.reason}`));96104 }97105 98- close() {106+ close(): void {99107 this.#listeners.clear();100108 this.#diagnostics.activeConsumers = 0;101109 this.#closeVendorSubscription();102110 }103111 104- #openVendorSubscription() {112+ async #settle(mutation: MutationHandle): Promise<void> {113+ const generation = ++this.#writeGeneration;114+ this.#set({ status: "ready", durability: "none", error: null });115+ try {116+ await mutation.wait({ tier: "local" });117+ this.#diagnostics.localWaitCalls += 1;118+ if (generation === this.#writeGeneration) {119+ this.#set({ durability: "local", error: null });120+ }121+ if (this.#syncConfigured) {122+ void mutation.wait({ tier: "global" }).then(123+ () => {124+ if (generation === this.#writeGeneration) {125+ this.#set({ durability: "global", error: null });126+ }127+ },128+ (error) => {129+ this.#diagnostics.mutationErrors += 1;130+ this.#fail(error);131+ },132+ );133+ }134+ } catch (error) {135+ this.#diagnostics.mutationErrors += 1;136+ this.#fail(error);137+ throw error;138+ }139+ }140+141+ #openVendorSubscription(): void {105142 if (this.#vendorUnsubscribe) return;106143 try {107- this.#vendorUnsubscribe = this.#db.subscribeAll(app.notes, (delta) => {144+ this.#vendorUnsubscribe = this.#db.subscribeAll(referenceApp.schema.tasks, (delta) => {108145 this.#snapshot = {109146 ...this.#snapshot,110147 status: "ready",111- notes: delta.all as Note[],148+ tasks: delta.all as ChecklistTask[],112149 error: null,113150 };114151 this.#emit();@@ -120,7 +157,7 @@ export class NotesStore {
120157 }121158 }122159 123- #closeVendorSubscription() {160+ #closeVendorSubscription(): void {124161 if (!this.#vendorUnsubscribe) return;125162 this.#vendorUnsubscribe();126163 this.#diagnostics.unsubscribeCalls += 1;@@ -128,12 +165,12 @@ export class NotesStore {
128165 this.#diagnostics.activeVendorSubscriptions -= 1;129166 }130167 131- #set(change: Partial<NotesSnapshot>) {168+ #set(change: Partial<ChecklistSnapshot>): void {132169 this.#snapshot = { ...this.#snapshot, ...change };133170 this.#emit();134171 }135172 136- #fail(error: unknown) {173+ #fail(error: unknown): void {137174 this.#snapshot = {138175 ...this.#snapshot,139176 status: "error",@@ -143,7 +180,7 @@ export class NotesStore {
143180 this.#emit();144181 }145182 146- #emit() {183+ #emit(): void {147184 for (const listener of this.#listeners) listener();148185 }149186 }@@ -0,0 +1,241 @@
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+ mutationErrors: 0,22+ };23+}24+25+function fakeDb(options: { allowGlobal?: boolean } = {}) {26+ let subscriber: ((delta: { all: unknown[] }) => void) | null = null;27+ let unsubscribeCalls = 0;28+ let rejectNextWrite: Error | null = null;29+ let deferredNextWrite: {30+ promise: Promise<void>;31+ resolve(): void;32+ reject(error: Error): void;33+ } | null = null;34+ let globalWaitCalls = 0;35+ const writes: Array<{ operation: "insert" | "update" | "delete"; value: unknown }> = [];36+37+ function handle() {38+ const deferredWrite = deferredNextWrite;39+ deferredNextWrite = null;40+ return {41+ wait: ({ tier }: { tier: "local" | "global" }) => {42+ if (tier === "global") {43+ globalWaitCalls += 1;44+ if (!options.allowGlobal) {45+ return Promise.reject(new Error("unconfigured stores must not request global waits"));46+ }47+ return Promise.resolve();48+ }49+ if (tier === "local" && rejectNextWrite) {50+ const error = rejectNextWrite;51+ rejectNextWrite = null;52+ return Promise.reject(error);53+ }54+ if (tier === "local" && deferredWrite) return deferredWrite.promise;55+ return Promise.resolve();56+ },57+ };58+ }59+60+ const value = {61+ subscribeAll(_collection: unknown, callback: (delta: { all: unknown[] }) => void) {62+ subscriber = callback;63+ return () => {64+ unsubscribeCalls += 1;65+ subscriber = null;66+ };67+ },68+ insert(_collection: unknown, next: unknown) {69+ writes.push({ operation: "insert", value: next });70+ return handle();71+ },72+ update(_collection: unknown, id: string, next: unknown) {73+ writes.push({ operation: "update", value: { id, next } });74+ return handle();75+ },76+ delete(_collection: unknown, id: string) {77+ writes.push({ operation: "delete", value: id });78+ return handle();79+ },80+ } as unknown as Db;81+82+ return {83+ value,84+ publish(all: unknown[]) {85+ assert(subscriber, "expected an active vendor subscription");86+ subscriber({ all });87+ },88+ failNextWrite(message: string) {89+ rejectNextWrite = new Error(message);90+ },91+ deferNextWrite() {92+ let resolve!: () => void;93+ let reject!: (error: Error) => void;94+ const promise = new Promise<void>((accept, decline) => {95+ resolve = accept;96+ reject = decline;97+ });98+ deferredNextWrite = { promise, resolve, reject };99+ return deferredNextWrite;100+ },101+ writes,102+ globalWaitCalls: () => globalWaitCalls,103+ unsubscribeCalls: () => unsubscribeCalls,104+ };105+}106+107+test("two consumers share one task subscription and clean up idempotently", () => {108+ const db = fakeDb();109+ const counts = diagnostics();110+ const store = new ChecklistStore(db.value, counts);111+ let firstUpdates = 0;112+ let secondUpdates = 0;113+114+ const stopFirst = store.subscribe(() => firstUpdates += 1);115+ const stopSecond = store.subscribe(() => secondUpdates += 1);116+ assertCount(counts.activeConsumers, 2, "both component consumers must be counted");117+ assertCount(counts.activeVendorSubscriptions, 1, "one query should have one subscription");118+ assertCount(counts.totalVendorSubscriptions, 1, "the underlying query should open once");119+120+ db.publish([{121+ id: "task-1",122+ text: "shared",123+ completed: false,124+ createdAt: new Date(0),125+ }]);126+ assert(store.getSnapshot().status === "ready", "the first query result must end loading");127+ assert(store.getSnapshot().tasks.length === 1, "the query result must be published");128+ assert(firstUpdates > 0 && secondUpdates > 0, "both roots must receive reactive updates");129+130+ stopFirst();131+ assertCount(counts.activeConsumers, 1, "unmounting one root must leave the other active");132+ assertCount(counts.activeVendorSubscriptions, 1, "the remaining consumer retains the query");133+ stopFirst();134+ assertCount(counts.activeConsumers, 1, "unsubscribe must be idempotent");135+ stopSecond();136+ assertCount(counts.activeConsumers, 0, "the final unmount must remove all consumers");137+ assertCount(counts.activeVendorSubscriptions, 0, "the final unmount must close the query");138+ assertCount(db.unsubscribeCalls(), 1, "the vendor unsubscribe must run exactly once");139+ assertCount(counts.unsubscribeCalls, 1, "cleanup must remain externally diagnosable");140+});141+142+test("every checklist mutation waits for local durability", async () => {143+ const db = fakeDb();144+ const counts = diagnostics();145+ const store = new ChecklistStore(db.value, counts);146+147+ await store.create("first");148+ await store.update("task-1", "edited");149+ await store.setCompleted("task-1", true);150+ await store.delete("task-1");151+152+ assertCount(counts.localWaitCalls, 4, "each accepted mutation must await local durability");153+ assert(154+ db.writes.map((write) => write.operation).join(",") === "insert,update,update,delete",155+ "create, edit, complete, and delete must reach the Jazz boundary",156+ );157+ assert(store.getSnapshot().durability === "local", "the UI may claim local after the wait");158+ assertCount(159+ db.globalWaitCalls(),160+ 0,161+ "an unconfigured store must never request global durability",162+ );163+});164+165+test("configured sync advances the latest write from local to global durability", async () => {166+ const db = fakeDb({ allowGlobal: true });167+ const store = new ChecklistStore(db.value, diagnostics(), true);168+ await store.create("sync after reconnect");169+ await Promise.resolve();170+ assert(171+ store.getSnapshot().durability === "global",172+ "the public mutation handle must make completed global durability visible",173+ );174+ assertCount(db.globalWaitCalls(), 1, "configured sync must request global durability");175+});176+177+test("local write failures remain visible and reject the mutation", async () => {178+ const db = fakeDb();179+ const store = new ChecklistStore(db.value, diagnostics());180+ db.failNextWrite("disk full");181+ let rejected = false;182+ try {183+ await store.create("cannot persist");184+ } catch {185+ rejected = true;186+ }187+ assert(rejected, "a failed local wait must reject the public mutation");188+ assert(store.getSnapshot().status === "error", "a failed local wait must reach UI state");189+ assert(store.getSnapshot().error === "disk full", "the durable-write cause must remain visible");190+});191+192+test("a superseded local-write failure remains visible", async () => {193+ const db = fakeDb();194+ const counts = diagnostics();195+ const store = new ChecklistStore(db.value, counts);196+ const firstWrite = db.deferNextWrite();197+198+ const firstMutation = store.create("first");199+ await Promise.resolve();200+ await store.create("newer");201+ firstWrite.reject(new Error("first write lost"));202+203+ let rejected = false;204+ try {205+ await firstMutation;206+ } catch {207+ rejected = true;208+ }209+ assert(rejected, "the superseded mutation promise must still reject");210+ assert(store.getSnapshot().status === "error", "the stale rejection must reach UI state");211+ assert(store.getSnapshot().durability === "failed", "the stale rejection must be durable state");212+ assert(store.getSnapshot().error === "first write lost", "the stale cause must remain visible");213+ assertCount(counts.mutationErrors, 1, "the stale failure must remain diagnosable");214+});215+216+test("retrying after failure clears the error status immediately", async () => {217+ const db = fakeDb();218+ const store = new ChecklistStore(db.value, diagnostics());219+ db.failNextWrite("disk full");220+ await store.create("fails").catch(() => undefined);221+ const retryWrite = db.deferNextWrite();222+223+ const retry = store.create("retry");224+ await Promise.resolve();225+ assert(store.getSnapshot().status === "ready", "retry must leave the failed list status");226+ assert(store.getSnapshot().error === null, "retry must not render a null error message");227+ assert(store.getSnapshot().durability === "none", "retry is pending until local durability");228+ retryWrite.resolve();229+ await retry;230+});231+232+test("public mutation errors update observable diagnostics", () => {233+ const counts = diagnostics();234+ const store = new ChecklistStore(fakeDb().value, counts);235+ store.reportMutationError({236+ code: "WriteRejected",237+ reason: "permission denied",238+ } as MutationErrorEvent);239+ assertCount(counts.mutationErrors, 1, "mutation errors must be counted");240+ assert(store.getSnapshot().error?.includes("permission denied"), "the reason must reach the UI");241+});@@ -0,0 +1,21 @@
1+/// <reference path="../env.d.ts" />2+3+import type { DbConfig } from "jazz-tools";4+import { referenceApp } from "../app.ts";5+6+const LOCAL_APP_ID = "00000000-0000-0000-0000-00000000f153";7+const configuredAppId = typeof __LOFI_JAZZ_APP_ID__ === "string" ? __LOFI_JAZZ_APP_ID__ : "";8+const configuredServerUrl = typeof __LOFI_JAZZ_SERVER_URL__ === "string"9+ ? __LOFI_JAZZ_SERVER_URL__10+ : "";11+export const appId = configuredAppId || LOCAL_APP_ID;12+export const serverUrl = configuredServerUrl || undefined;13+14+export function databaseConfig(secret: string): DbConfig {15+ return {16+ appId,17+ ...(serverUrl ? { serverUrl } : {}),18+ secret,19+ driver: { type: "persistent", dbName: `${referenceApp.databaseName}-${appId}` },20+ };21+}No content changes.
@@ -0,0 +1,43 @@
1+import {2+ getRuntime,3+ getRuntimeDiagnostics,4+ recreateRuntime,5+ runtimeRecreatedEvent,6+ shutdownRuntime,7+} from "./runtime.ts";8+9+export type ReferenceProbe = {10+ diagnostics: typeof getRuntimeDiagnostics;11+ create(text: string): Promise<void>;12+ update(id: string, text: string): Promise<void>;13+ setCompleted(id: string, completed: boolean): Promise<void>;14+ delete(id: string): Promise<void>;15+ disconnect(): Promise<void>;16+ reconnect(): Promise<void>;17+ recreate(): Promise<void>;18+ shutdown(): Promise<void>;19+};20+21+declare global {22+ interface Window {23+ __LOFI_REFERENCE__?: ReferenceProbe;24+ }25+}26+27+if (typeof document !== "undefined") {28+ (globalThis as typeof globalThis & Window).__LOFI_REFERENCE__ = {29+ diagnostics: getRuntimeDiagnostics,30+ create: async (text) => await (await getRuntime()).checklist.create(text),31+ update: async (id, text) => await (await getRuntime()).checklist.update(id, text),32+ setCompleted: async (id, completed) =>33+ await (await getRuntime()).checklist.setCompleted(id, completed),34+ delete: async (id) => await (await getRuntime()).checklist.delete(id),35+ disconnect: async () => await (await getRuntime()).db.disconnect(),36+ reconnect: async () => await (await getRuntime()).db.reconnect(),37+ recreate: async () => {38+ await recreateRuntime();39+ globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));40+ },41+ shutdown: shutdownRuntime,42+ };43+}No content changes.
No content changes.
No content changes.
@@ -6,19 +6,12 @@ import {
66 recreateResource,77 shutdownResource,88 } from "./resource-lifecycle.ts";9+import { assert, assertCount } from "./test-assert.ts";910 1011 const test = (globalThis as unknown as {1112 Deno: { test(name: string, body: () => void | Promise<void>): void };1213 }).Deno.test;1314 14-function assert(condition: unknown, message: string): asserts condition {15- if (!condition) throw new Error(message);16-}17-18-function assertCount(actual: number, expected: number, message: string) {19- if (actual !== expected) throw new Error(`${message}: expected ${expected}, received ${actual}`);20-}21-2215 test("concurrent recreation is single-flight and shutdown is idempotent", async () => {2316 const state = createSerializedResourceState<{ id: number }>();2417 let created = 0;@@ -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 };@@ -73,19 +73,19 @@ let recreationPromise: Promise<LofiRuntime> | null = null;
7373 let shutdownPromise: Promise<void> | null = null;7474 7575 function attachRuntime(state: RuntimeSlot, db: Db): LofiRuntime {76- const notes = new NotesStore(db, state.diagnostics);77- const stopMutationErrors = db.onMutationError((event) => notes.reportMutationError(event));76+ const checklist = new ChecklistStore(db, state.diagnostics);77+ const stopMutationErrors = db.onMutationError((event) => checklist.reportMutationError(event));7878 state.diagnostics.activeMutationListeners += 1;7979 state.diagnostics.totalMutationListeners += 1;8080 let active = true;8181 const runtime: LofiRuntime = {8282 db,83- notes,83+ checklist,8484 diagnostics: state.diagnostics,8585 shutdown() {8686 if (!active) return Promise.resolve();8787 active = false;88- notes.close();88+ checklist.close();8989 stopMutationErrors();9090 state.diagnostics.activeMutationListeners -= 1;9191 return Promise.resolve();@@ -0,0 +1,7 @@
1+export function assert(condition: unknown, message: string): asserts condition {2+ if (!condition) throw new Error(message);3+}4+5+export function assertCount(actual: number, expected: number, message: string): void {6+ if (actual !== expected) throw new Error(`${message}: expected ${expected}, received ${actual}`);7+}No content changes.
No content changes.
@@ -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 }No content changes.
@@ -0,0 +1,13 @@
1+import { app as schema } from "./schema.ts";2+3+/** Author-owned composition. Vendor URLs and browser drivers remain in generated runtime code. */4+export const referenceApp = {5+ name: "lofi checklist",6+ // Keep the M1 OPFS namespace so the notes-to-tasks migration can open and7+ // transform existing device data instead of presenting an empty database.8+ databaseName: "lofi-prototype",9+ schema,10+ storage: "durable" as const,11+ identity: "device-local" as const,12+ sync: { adapter: "jazz" as const },13+};@@ -8,3 +8,6 @@ interface ImportMetaEnv {
88 interface ImportMeta {99 readonly env: ImportMetaEnv;1010 }11+12+declare const __LOFI_JAZZ_APP_ID__: string;13+declare const __LOFI_JAZZ_SERVER_URL__: string;@@ -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+}@@ -0,0 +1,28 @@
1+import { schema as s } from "jazz-tools";2+3+export default s.defineMigration({4+ renameTables: {5+ tasks: s.renameTableFrom("notes"),6+ },7+ migrate: {8+ tasks: {9+ text: s.renameFrom("body"),10+ completed: s.add.boolean({ default: false }),11+ },12+ },13+ fromHash: "6c62fec42c35",14+ toHash: "ff85ac1d97ee",15+ from: {16+ notes: s.table({17+ body: s.string(),18+ createdAt: s.timestamp(),19+ }),20+ },21+ to: {22+ tasks: s.table({23+ text: s.string(),24+ completed: s.boolean(),25+ createdAt: s.timestamp(),26+ }),27+ },28+});@@ -0,0 +1,27 @@
1+{2+ "tasks": {3+ "columns": [4+ {5+ "name": "text",6+ "column_type": {7+ "type": "Text"8+ },9+ "nullable": false10+ },11+ {12+ "name": "completed",13+ "column_type": {14+ "type": "Boolean"15+ },16+ "nullable": false17+ },18+ {19+ "name": "createdAt",20+ "column_type": {21+ "type": "Timestamp"22+ },23+ "nullable": false24+ }25+ ]26+ }27+}@@ -0,0 +1,27 @@
1+---2+import ChecklistIsland from "../islands/ChecklistIsland";3+import DeviceStatus from "../_lofi/DeviceStatus";4+import ReferenceShell from "../_lofi/ReferenceShell.astro";5+import "../styles/global.css";6+---7+8+<ReferenceShell title="lofi checklist">9+ <header class="hero">10+ <p class="eyebrow">lofi reference application</p>11+ <h1>A checklist that starts local.</h1>12+ <p>13+ Create, edit, complete, and delete work without waiting for the network. Both independent14+ islands render the same durable local data.15+ </p>16+ </header>17+ <div class="island-grid">18+ <ChecklistIsland label="North island" client:load />19+ <ChecklistIsland label="South island" client:load />20+ </div>21+ <aside class="identity-note" aria-label="Identity and recovery">22+ <strong>Identity stays on this device.</strong> Clearing site data removes access because23+ passkey backup and recovery are not enabled in this reference application.24+ </aside>25+ <DeviceStatus client:load />26+ <footer>Thin Preact adapter · static Astro shell · Deno command surface</footer>27+</ReferenceShell>@@ -0,0 +1,9 @@
1+import { schema as s } from "jazz-tools";2+import { app } from "./schema.ts";3+4+export default s.definePermissions(app, ({ policy, session }) => {5+ policy.tasks.allowRead.where({ $createdBy: session.user_id });6+ policy.tasks.allowInsert.always();7+ policy.tasks.allowUpdate.where({ $createdBy: session.user_id });8+ policy.tasks.allowDelete.where({ $createdBy: session.user_id });9+});@@ -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 }@@ -0,0 +1,10 @@
1+export const checklistUi = {2+ newItem: "New item",3+ addFrom: (island: string) => `Add from ${island}`,4+ edit: (body: string) => `Edit ${body}`,5+ save: (body: string) => `Save ${body}`,6+ delete: (body: string) => `Delete ${body}`,7+ complete: (body: string) => `Complete ${body}`,8+ writeFailed: "Write failed:",9+ lastWrite: (tier: "local" | "global") => `last write ${tier}`,10+} as const;@@ -0,0 +1,31 @@
1+const test = (globalThis as unknown as {2+ Deno: { test(name: string, body: () => void | Promise<void>): void };3+}).Deno.test;4+5+const authorUiFiles = [6+ new URL("../src/pages/index.astro", import.meta.url),7+ new URL("../src/islands/ChecklistIsland.tsx", import.meta.url),8+];9+10+const forbidden = [11+ { pattern: /jazz-tools/, name: "raw Jazz import" },12+ { pattern: /(?:Shared|Service|Web)?Worker\s*\(/, name: "worker construction" },13+ { pattern: /(?:wss?|https?):\/\//, name: "transport URL" },14+ { pattern: /workbox/i, name: "Workbox configuration" },15+ { pattern: /navigator\.|globalThis\.(?:isSecureContext|SharedWorker)/, name: "browser branch" },16+ {17+ pattern: /_lofi\/(?:boot|config|device-capabilities|probe|pwa|runtime)/,18+ name: "runtime plumbing import",19+ },20+];21+22+test("author UI stays behind the lofi checklist boundary", async () => {23+ for (const file of authorUiFiles) {24+ const source = await Deno.readTextFile(file);25+ for (const rule of forbidden) {26+ if (rule.pattern.test(source)) {27+ throw new Error(`${file.pathname} exposes forbidden ${rule.name}`);28+ }29+ }30+ }31+});No content changes.
@@ -6,6 +6,7 @@
66 "astro/config": "npm:astro@7.0.9/config",77 "jazz-tools": "npm:jazz-tools@2.0.0-alpha.53",88 "jazz-tools/dev/vite": "npm:jazz-tools@2.0.0-alpha.53/dev/vite",9+ "playwright": "npm:playwright@1.61.1",910 "preact": "npm:preact@10.29.7",1011 "preact/hooks": "npm:preact@10.29.7/hooks",1112 "preact/jsx-dev-runtime": "npm:preact@10.29.7/jsx-dev-runtime",@@ -22,12 +23,18 @@
2223 "tasks": {2324 "check:env": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env tools/check_env.ts",2425 "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",26+ "check:migrations": "deno run --allow-read=apps/reference/src/migrations --allow-run tools/check_migrations.ts",27+ "check:astro": "LOFI_SKIP_JAZZ_MANAGED=1 JAZZ_ADMIN_SECRET= BACKEND_SECRET= deno run -A npm:astro@7.0.9 check --root apps/reference",28+ "check": "deno fmt --check && deno lint && deno check 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",29+ "test:unit": "deno test --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools apps/reference/src/_lofi apps/reference/tests",30+ "test": "deno task test:unit",31+ "test:browser:install": "deno run -A npm:playwright@1.61.1 install chromium",32+ "test:golden:checkout": "deno run -A tools/golden_path_checkout.ts",33+ "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",34+ "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",35+ "schema:validate": "deno run -A npm:jazz-tools@2.0.0-alpha.53 validate --schema-dir apps/reference/src",36+ "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",37+ "preview": "deno run --allow-net --allow-read=apps/reference/dist tools/preview.ts",3138 "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",3239 "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",3340 "spike:jazz2:build": "deno run -A npm:vite@8.0.1 build spikes/jazz2-baseline --config spikes/jazz2-baseline/vite.config.ts",@@ -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"@@ -26,7 +26,7 @@ indicative floor rather than an equivalent-UI bundle claim.
2626 2727 ## Consequences2828 29-- Jazz alpha churn is isolated to `apps/prototype/src/runtime` and schema/config files.29+- Jazz alpha churn is isolated to `apps/reference/src/_lofi` and schema/config files.3030 - Component authors get context-free hooks that work across independent Astro islands.3131 - The adapter owns query snapshot identity, cleanup, mutation error mapping, and HMR lifecycle.3232 - Deferred asynchronous subscription failures remain limited by Jazz's public API and must not be@@ -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@@ -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 M1; M2 implementation 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,9 +18,9 @@ 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 @@ -30,11 +30,16 @@ This is the eventual product experience. The generator itself is M2 work and is
3030 deliverable.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 planned JSR surface is one package, `@nzip/lofi`, with one version and publish operation.39+Framework and tooling boundaries are subpath exports—starting with `./create`, `./core`, `./sync`,40+`./auth`, `./ui`, `./pwa`, and `./testing`—rather than lockstep packages. See41+[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 @@ -116,15 +121,15 @@ These are product outputs, not claims that every command is implemented at Contr
116121 prototype must implement `dev`, `check`, `test`, `build`, and `preview`; `create` and the complete117122 `doctor` experience graduate in M2.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. |@@ -268,9 +277,40 @@ 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.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.274284 275285 Package extraction, the generator, full inspector/testing SDK, and production PWA hardening remain276286 M2 or later work.287+288+## M2 layered graduation289+290+M2 integrates into the `m2` branch as three separately reviewed layers. Each layer must pass its own291+checks and independent pull-request review before merge. The combined branch is re-reviewed before292+it may merge into `dev`.293+294+Layer verification runs outside the product and framework surface. An independent agent checks the295+exact pushed head in a detached Git worktree, runs `deno task check` plus the layer's applicable296+golden journey, and confirms that the retained report names the tested commit. The pull request is a297+communication channel for the monitoring reviewer, not a reason to impose a permanent per-push CI298+workflow on the framework or generated applications. A layer may merge only when both the clean-room299+result and independent review accept that exact head. The final `m2` combination repeats the same300+clean-room and review boundary before promotion to `dev`.301+302+| Layer | Issues | Contract result |303+| ---------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------- |304+| 1 — reference and checkout journey | #9; tracks #11 | Honest checklist CRUD, explicit author/generated boundary, and reusable checkout-mode golden-path evidence. |305+| 2 — public command path | #13, #14; completes #11 | Non-interactive creation plus actionable `dev`/`doctor`; the same journey runs against generated output. |306+| 3 — testing and inspection | #12, #10 | Reusable offline/convergence controls and truthful developer diagnostics without leaking machinery into product UI. |307+308+Layer 1 does not close #11: checkout-mode evidence is necessary, but the generated-project journey309+cannot pass until Layer 2 implements the public create command. Remaining abstraction leaks are310+tracked in `docs/m2-abstraction-leaks.md` rather than hidden behind premature package APIs.311+312+Layer 1 keeps M1's `lofi-prototype-<appId>` OPFS namespace so existing device rows remain visible to313+the reviewed notes-to-tasks lens. Jazz discovers that lens and its snapshots beside `schema.ts` in314+`apps/reference/src/migrations`. `deno task check:migrations` couples the current schema hash,315+incoming migration edge, and snapshot; `deno task schema:deploy` is the explicit cloud publication316+step for the schema, migration, and permissions bundle.@@ -0,0 +1,19 @@
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 own `dev`, `build`, preview, environment loading, and secret scans. | Extracting commands before the reference journey is executable would freeze an untested project shape. | #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+| Runtime cardinality and lifecycle evidence use a development-only global probe. | Product UI must stay clean while the browser journey still needs deterministic evidence. | #10 | The inspector exposes the same truthful signals through a supported development surface and is absent from production. |15+| The checkout golden path owns process/browser orchestration at repository scope. | Layer 1 proves the journey before defining a reusable testing package. | #12 | Offline and multi-client helpers are callable from generated-project tests with readiness-based APIs. |16+| Cloud/global settlement is observable per write, but live socket state is not. | Jazz alpha.53 exposes mutation durability but no supported connection-state signal. | #10, #12 | Inspector/tests distinguish configured mode, local durability, global durability, and unavailable live detail. |17+18+Deleting an entry requires retained evidence in the owning issue or a decision record accepting the19+behavior as an intentional public boundary.@@ -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@@ -7,14 +7,14 @@ function assertEquals<T>(actual: T, expected: T) {
77 Deno.test("nested ignored dist artifacts remain visible to the secret scanner", async () => {88 const root = await Deno.makeTempDir({ dir: ".", prefix: ".artifact-files-test-" });99 try {10- await Deno.mkdir(`${root}/apps/prototype/dist/assets`, { recursive: true });10+ await Deno.mkdir(`${root}/apps/reference/dist/assets`, { recursive: true });1111 await Deno.mkdir(`${root}/node_modules/vendor/dist`, { recursive: true });12- await Deno.writeTextFile(`${root}/apps/prototype/dist/assets/client.js`, "built");12+ await Deno.writeTextFile(`${root}/apps/reference/dist/assets/client.js`, "built");1313 await Deno.writeTextFile(`${root}/node_modules/vendor/dist/vendor.js`, "ignored");1414 1515 const artifacts = await collectBuildArtifactFiles(root);1616 assertEquals(artifacts.length, 1);17- assertEquals(artifacts[0], `${root}/apps/prototype/dist/assets/client.js`);17+ assertEquals(artifacts[0], `${root}/apps/reference/dist/assets/client.js`);1818 } finally {1919 await Deno.remove(root, { recursive: true });2020 }@@ -0,0 +1,161 @@
1+import { schema as s } from "jazz-tools";2+import { referenceApp } from "../apps/reference/src/app.ts";3+import migration from "../apps/reference/src/migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts";4+5+const SCHEMA_DIR = "apps/reference/src";6+const MIGRATIONS_DIR = `${SCHEMA_DIR}/migrations`;7+const SNAPSHOTS_DIR = `${MIGRATIONS_DIR}/snapshots`;8+const JAZZ_TOOLS = "npm:jazz-tools@2.0.0-alpha.53";9+10+function fail(message: string): never {11+ throw new Error(`migration contract: ${message}`);12+}13+14+async function jazz(args: string[]): Promise<string> {15+ const output = await new Deno.Command(Deno.execPath(), {16+ args: ["run", "-A", JAZZ_TOOLS, ...args, "--schema-dir", SCHEMA_DIR],17+ stdout: "piped",18+ stderr: "piped",19+ }).output();20+ if (!output.success) {21+ const error = new TextDecoder().decode(output.stderr).trim();22+ fail(`Jazz CLI failed: ${error || `exit ${output.code}`}`);23+ }24+ return new TextDecoder().decode(output.stdout).trim();25+}26+27+async function jazzSnapshot(hash: string): Promise<unknown> {28+ const isolatedEnvironment: Record<string, string> = {};29+ for (30+ const name of [31+ "JAZZ_APP_ID",32+ "JAZZ_SERVER_URL",33+ "JAZZ_ADMIN_SECRET",34+ "BACKEND_SECRET",35+ "VITE_JAZZ_APP_ID",36+ "VITE_JAZZ_SERVER_URL",37+ "PUBLIC_JAZZ_APP_ID",38+ "PUBLIC_JAZZ_SERVER_URL",39+ "NEXT_PUBLIC_JAZZ_APP_ID",40+ "NEXT_PUBLIC_JAZZ_SERVER_URL",41+ "EXPO_PUBLIC_JAZZ_APP_ID",42+ "EXPO_PUBLIC_JAZZ_SERVER_URL",43+ ]44+ ) isolatedEnvironment[name] = "";45+ const output = await new Deno.Command(Deno.execPath(), {46+ args: [47+ "run",48+ "-A",49+ JAZZ_TOOLS,50+ "schema",51+ "export",52+ "--schema-hash",53+ hash,54+ "--migrations-dir",55+ MIGRATIONS_DIR,56+ ],57+ env: isolatedEnvironment,58+ stdout: "piped",59+ stderr: "piped",60+ }).output();61+ if (!output.success) {62+ const error = new TextDecoder().decode(output.stderr).trim();63+ fail(`snapshot ${hash} does not hash to its filename: ${error || `exit ${output.code}`}`);64+ }65+ return JSON.parse(new TextDecoder().decode(output.stdout));66+}67+68+function hashField(source: string, field: "fromHash" | "toHash"): string {69+ const match = source.match(new RegExp(`${field}:\\s*["']([0-9a-f]{12})["']`));70+ if (!match) fail(`reviewed migration is missing a 12-character ${field}`);71+ return match[1];72+}73+74+const hashOutput = await jazz(["schema", "hash"]);75+const currentHash = hashOutput.match(/Current schema hash:\s*([0-9a-f]{12})/)?.[1];76+if (!currentHash) fail(`could not read the current hash from Jazz CLI output: ${hashOutput}`);77+78+const migrationNames: string[] = [];79+for await (const entry of Deno.readDir(MIGRATIONS_DIR)) {80+ if (entry.isFile && entry.name.endsWith(".ts")) migrationNames.push(entry.name);81+}82+if (migrationNames.length === 0) fail(`no reviewed migrations found in ${MIGRATIONS_DIR}`);83+84+let currentEdgeFound = false;85+for (const name of migrationNames.sort()) {86+ const source = await Deno.readTextFile(`${MIGRATIONS_DIR}/${name}`);87+ const fromHash = hashField(source, "fromHash");88+ const toHash = hashField(source, "toHash");89+ if (!name.endsWith(`-${fromHash}-${toHash}.ts`)) {90+ fail(`${name} does not encode its declared ${fromHash} -> ${toHash} edge`);91+ }92+ for (const hash of [fromHash, toHash]) {93+ const snapshotNames = [];94+ for await (const entry of Deno.readDir(SNAPSHOTS_DIR)) {95+ if (entry.isFile && entry.name.endsWith(`-${hash}.json`)) snapshotNames.push(entry.name);96+ }97+ if (snapshotNames.length !== 1) {98+ fail(`${name} requires exactly one ${hash} snapshot; found ${snapshotNames.length}`);99+ }100+ const snapshot = JSON.parse(101+ await Deno.readTextFile(`${SNAPSHOTS_DIR}/${snapshotNames[0]}`),102+ );103+ if (JSON.stringify(await jazzSnapshot(hash)) !== JSON.stringify(snapshot)) {104+ fail(`${snapshotNames[0]} does not round-trip through Jazz's hash loader`);105+ }106+ }107+ if (toHash === currentHash) currentEdgeFound = true;108+}109+if (!currentEdgeFound) {110+ fail(`current schema ${currentHash} has no reviewed incoming migration edge`);111+}112+113+const currentSnapshots: string[] = [];114+for await (const entry of Deno.readDir(SNAPSHOTS_DIR)) {115+ if (entry.isFile && entry.name.endsWith(`-${currentHash}.json`)) {116+ currentSnapshots.push(entry.name);117+ }118+}119+if (currentSnapshots.length !== 1) {120+ fail(`current schema ${currentHash} must have exactly one snapshot`);121+}122+123+const currentSnapshot = JSON.parse(124+ await Deno.readTextFile(`${SNAPSHOTS_DIR}/${currentSnapshots[0]}`),125+);126+if (JSON.stringify(referenceApp.schema.wasmSchema) !== JSON.stringify(currentSnapshot)) {127+ fail(`current snapshot ${currentSnapshots[0]} does not match schema.ts`);128+}129+130+const previousSnapshotName = [];131+for await (const entry of Deno.readDir(SNAPSHOTS_DIR)) {132+ if (entry.isFile && entry.name.endsWith("-6c62fec42c35.json")) {133+ previousSnapshotName.push(entry.name);134+ }135+}136+if (previousSnapshotName.length !== 1) fail("the M1 source snapshot is missing or ambiguous");137+const previousSnapshot = JSON.parse(138+ await Deno.readTextFile(`${SNAPSHOTS_DIR}/${previousSnapshotName[0]}`),139+);140+if (JSON.stringify(s.defineApp(migration.from).wasmSchema) !== JSON.stringify(previousSnapshot)) {141+ fail("the reviewed lens source does not match the M1 snapshot");142+}143+if (JSON.stringify(s.defineApp(migration.to).wasmSchema) !== JSON.stringify(currentSnapshot)) {144+ fail("the reviewed lens target does not match the current snapshot");145+}146+147+const expectedForward = [{148+ table: "tasks",149+ renamedFrom: "notes",150+ operations: [151+ { type: "rename", column: "body", value: "text" },152+ { type: "introduce", column: "completed", sqlType: "BOOLEAN", value: false },153+ ],154+}];155+if (JSON.stringify(migration.forward) !== JSON.stringify(expectedForward)) {156+ fail("the reviewed lens must rename notes/body and add completed=false");157+}158+159+console.log(160+ `migration contract: ${migrationNames.length} reviewed edge(s), current schema ${currentHash}`,161+);@@ -0,0 +1,664 @@
1+import { join, resolve } from "node:path";2+import { type Browser, type BrowserContext, chromium, type Page } from "playwright";3+import { environmentNames, validateEnvironment } from "./env_contract.ts";4+import {5+ artifactPaths,6+ type CapturedProcess,7+ type CommandRecord,8+ initializeFixtureGit,9+ installNodeSentinel,10+ type JourneyAssertion,11+ type JourneyReport,12+ type JourneySource,13+ materializeCleanProject,14+ probeHttpReady,15+ reserveLocalPort,16+ runCapturedCommand,17+ safeChildEnvironment,18+ scanAuthorBoundary,19+ startReadyProcess,20+ writeJourneyReport,21+} from "./golden_path_core.ts";22+import { loadEnvironment } from "./load_env.ts";23+import { checklistUi } from "../apps/reference/src/ui-contract.ts";24+import { assert } from "./assert.ts";25+26+export type GoldenPathCommands = {27+ dev: string;28+ check: string;29+ test: string;30+ build: string;31+ preview: string;32+};33+34+export type GoldenPathOptions = {35+ source: JourneySource;36+ /** Repository working tree for checkout mode, or the already-created project for create mode. */37+ projectRoot?: string;38+ artifactRoot?: string;39+ authorPaths?: string[];40+ commands?: Partial<GoldenPathCommands>;41+ cacheMode?: "existing" | "cold";42+ environmentMode?: "isolated-local" | "cloud-from-root";43+ timeoutMs?: number;44+ islandLabels?: [string, string];45+};46+47+const defaultCommands: GoldenPathCommands = {48+ dev: "dev",49+ check: "check",50+ test: "test",51+ build: "build",52+ preview: "preview",53+};54+55+const defaultAuthorPaths = [56+ "apps/reference/src/app.ts",57+ "apps/reference/src/islands",58+ "apps/reference/src/pages",59+ "apps/reference/src/permissions.ts",60+ "apps/reference/src/schema.ts",61+ "apps/reference/src/styles",62+];63+64+const retainedBody = "Golden path retained item";65+const updatedBody = "Golden path retained item updated";66+const disposableBody = "Golden path disposable item";67+const offlineDevelopmentBody = "Golden path development offline item";68+const offlineProductionBody = "Golden path production offline item";69+const offlineProductionSyncedBody = "Golden path production offline item synced";70+71+function taskArgs(task: string, extra: string[] = []): string[] {72+ return ["task", task, ...(extra.length > 0 ? ["--", ...extra] : [])];73+}74+75+function escaped(value: string): string {76+ return value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");77+}78+79+async function waitForItem(80+ page: Page,81+ islandLabels: readonly string[],82+ body: string,83+): Promise<void> {84+ for (const [index, label] of islandLabels.entries()) {85+ const island = page.locator("[data-island]").filter({ hasText: label });86+ const item = island.getByRole("listitem").filter({ hasText: body });87+ if (index === 0) {88+ const failure = island.getByRole("status").filter({ hasText: checklistUi.writeFailed });89+ await item.or(failure).waitFor({ state: "visible" });90+ if (await failure.isVisible()) {91+ throw new Error((await failure.textContent()) ?? "Write failed");92+ }93+ } else {94+ await item.waitFor({ state: "visible" });95+ }96+ }97+}98+99+async function waitForItemAbsent(100+ page: Page,101+ islandLabels: readonly string[],102+ body: string,103+): Promise<void> {104+ for (const label of islandLabels) {105+ const island = page.locator("[data-island]").filter({ hasText: label });106+ await island.getByRole("listitem").filter({ hasText: body }).waitFor({ state: "detached" });107+ }108+}109+110+async function addItem(page: Page, island: string, body: string): Promise<void> {111+ const section = page.locator("[data-island]").filter({ hasText: island });112+ const textbox = section.getByRole("textbox", { name: checklistUi.newItem });113+ try {114+ await textbox.waitFor({ state: "visible" });115+ } catch (error) {116+ throw new Error(117+ `reference UI is missing accessible textbox "${checklistUi.newItem}" in ${island}; #9 must expose CRUD controls`,118+ { cause: error },119+ );120+ }121+ await textbox.fill(body);122+ const add = section.getByRole("button", { name: checklistUi.addFrom(island) });123+ try {124+ await add.waitFor({ state: "visible" });125+ } catch (error) {126+ throw new Error(127+ `reference UI is missing accessible button "${128+ checklistUi.addFrom(island)129+ }"; #9 must expose CRUD controls`,130+ { cause: error },131+ );132+ }133+ await add.click();134+}135+136+async function waitForLocalDurability(page: Page): Promise<void> {137+ await page.getByRole("status").filter({138+ hasText: new RegExp(139+ `(?:${escaped(checklistUi.lastWrite("local"))}|${escaped(checklistUi.lastWrite("global"))})`,140+ ),141+ }).first().waitFor({ state: "visible" });142+}143+144+async function assertRuntimeCardinality(145+ page: Page,146+ islandLabels: readonly string[],147+): Promise<void> {148+ assert(islandLabels.length === 2, "golden path cardinality requires two mounted islands");149+ await page.waitForFunction(() => {150+ const probe = (globalThis as typeof globalThis & {151+ __LOFI_REFERENCE__?: {152+ diagnostics(): {153+ storageState: string;154+ activeClients: number;155+ activeConsumers: number;156+ activeVendorSubscriptions: number;157+ activeMutationListeners: number;158+ };159+ };160+ }).__LOFI_REFERENCE__;161+ const diagnostics = probe?.diagnostics();162+ return diagnostics?.storageState === "persistent-driver-open" &&163+ diagnostics.activeClients === 1 &&164+ diagnostics.activeConsumers === 2 &&165+ diagnostics.activeVendorSubscriptions === 1 &&166+ diagnostics.activeMutationListeners === 1;167+ });168+}169+170+async function setReferenceConnection(page: Page, connected: boolean): Promise<void> {171+ await page.evaluate(async (shouldConnect) => {172+ const probe = (globalThis as typeof globalThis & {173+ __LOFI_REFERENCE__?: {174+ disconnect(): Promise<void>;175+ reconnect(): Promise<void>;176+ };177+ }).__LOFI_REFERENCE__;178+ if (!probe) throw new Error("development reference probe is unavailable");179+ if (shouldConnect) await probe.reconnect();180+ else await probe.disconnect();181+ }, connected);182+}183+184+async function observeReconnectSettlement(185+ page: Page,186+ timeoutMs: number,187+ required: boolean,188+): Promise<JourneyAssertion> {189+ if (!required) {190+ return {191+ name: "development reconnect settlement",192+ status: "blocked",193+ detail:194+ "Local-only mode has no global durability endpoint; offline retention passed, but this run makes no transport convergence claim.",195+ };196+ }197+ try {198+ const global = page.getByRole("status").filter({199+ hasText: checklistUi.lastWrite("global"),200+ }).first();201+ const failure = page.getByRole("status").filter({ hasText: checklistUi.writeFailed }).first();202+ await global.or(failure).waitFor({203+ state: "visible",204+ timeout: Math.min(timeoutMs, 10_000),205+ });206+ if (await failure.isVisible()) {207+ throw new Error((await failure.textContent()) ?? "Write failed");208+ }209+ return {210+ name: "development reconnect settlement",211+ status: "passed",212+ detail: "The configured development server exposed global settlement after network return.",213+ };214+ } catch {215+ throw new Error(216+ "cloud evidence failed: reconnect did not expose global settlement before the readiness deadline",217+ );218+ }219+}220+221+async function journeyEnvironment(222+ sourceRoot: string,223+ mode: "isolated-local" | "cloud-from-root",224+ artifactRoot: string,225+): Promise<Record<string, string>> {226+ const isolated = safeChildEnvironment();227+ if (mode === "isolated-local") return await installNodeSentinel(isolated, artifactRoot);228+229+ const configured = await loadEnvironment(join(sourceRoot, ".env"));230+ const validation = validateEnvironment(configured);231+ if (!validation.ok) throw new Error(validation.errors.join(" "));232+ if (validation.mode !== "cloud-configured") {233+ throw new Error(234+ "cloud evidence requires the complete JAZZ_APP_ID/JAZZ_SERVER_URL pair in the root .env or process environment",235+ );236+ }237+ const allowlisted = { ...isolated };238+ for (const name of environmentNames) allowlisted[name] = configured[name]?.trim() ?? "";239+ return await installNodeSentinel(allowlisted, artifactRoot);240+}241+242+async function updateItem(page: Page, body: string, nextBody: string): Promise<void> {243+ const edit = page.getByRole("button", { name: checklistUi.edit(body), exact: true }).first();244+ try {245+ await edit.click();246+ } catch (error) {247+ throw new Error(`reference UI is missing accessible button "${checklistUi.edit(body)}"`, {248+ cause: error,249+ });250+ }251+ const textbox = page.getByRole("textbox", { name: checklistUi.edit(body), exact: true }).first();252+ try {253+ await textbox.fill(nextBody);254+ } catch (error) {255+ throw new Error(`reference UI is missing accessible textbox "${checklistUi.edit(body)}"`, {256+ cause: error,257+ });258+ }259+ const save = page.getByRole("button", { name: checklistUi.save(body), exact: true }).first();260+ try {261+ await save.click();262+ } catch (error) {263+ throw new Error(`reference UI is missing accessible button "${checklistUi.save(body)}"`, {264+ cause: error,265+ });266+ }267+}268+269+async function completeItem(page: Page, body: string): Promise<void> {270+ const name = checklistUi.complete(body);271+ const checkboxes = page.getByRole("checkbox", { name });272+ try {273+ await checkboxes.first().waitFor({ state: "visible" });274+ } catch (error) {275+ throw new Error(276+ `reference UI needs checkbox "${checklistUi.complete(body)}"`,277+ { cause: error },278+ );279+ }280+ await checkboxes.first().click();281+ await page.waitForFunction((accessibleName) => {282+ const matches = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]283+ .filter((element) => element.getAttribute("aria-label") === accessibleName);284+ return matches.length === 2 && matches.every((element) => element.checked);285+ }, checklistUi.complete(body));286+}287+288+async function assertCompleted(page: Page, body: string): Promise<void> {289+ const name = checklistUi.complete(body);290+ const checkboxes = page.getByRole("checkbox", { name });291+ await checkboxes.first().waitFor({ state: "visible" });292+ assert(await checkboxes.count() === 2, `expected two completion controls for ${body}`);293+ assert(294+ await checkboxes.evaluateAll((elements) =>295+ elements.every((element) => (element as HTMLInputElement).checked)296+ ),297+ `completion for ${body} did not survive reload in both islands`,298+ );299+}300+301+async function deleteItem(page: Page, body: string): Promise<void> {302+ const button = page.getByRole("button", { name: checklistUi.delete(body), exact: true }).first();303+ try {304+ await button.click();305+ } catch (error) {306+ throw new Error(`reference UI is missing accessible button "${checklistUi.delete(body)}"`, {307+ cause: error,308+ });309+ }310+}311+312+async function waitForProductionPwa(page: Page, timeoutMs: number): Promise<void> {313+ await page.waitForFunction(314+ () => {315+ return (globalThis as typeof globalThis & { __LOFI_PWA_STATE__?: string })316+ .__LOFI_PWA_STATE__ === "ready";317+ },318+ undefined,319+ { timeout: timeoutMs },320+ );321+ await page.waitForFunction(() => navigator.serviceWorker.controller !== null, undefined, {322+ timeout: timeoutMs,323+ });324+}325+326+async function screenshotFailure(page: Page | null, path: string): Promise<void> {327+ if (!page || page.isClosed()) return;328+ try {329+ await page.screenshot({ path, fullPage: true });330+ } catch {331+ // Process logs and the trace remain authoritative when the page itself is unavailable.332+ }333+}334+335+function commandFailure(record: CommandRecord): Error {336+ return new Error(337+ `${record.name} failed with exit ${record.exitCode}; rerun ${Deno.execPath()} ${338+ record.args.join(" ")339+ } and inspect ${record.stderrPath}`,340+ );341+}342+343+async function sourceRevision(344+ sourceRoot: string,345+ environment: Record<string, string>,346+): Promise<string> {347+ const output = await new Deno.Command("git", {348+ args: ["rev-parse", "HEAD"],349+ cwd: sourceRoot,350+ clearEnv: true,351+ env: environment,352+ stdout: "piped",353+ stderr: "null",354+ }).output();355+ return output.success ? new TextDecoder().decode(output.stdout).trim() : "unavailable";356+}357+358+export async function runJourney(options: GoldenPathOptions): Promise<JourneyReport> {359+ const sourceRoot = resolve(options.projectRoot ?? Deno.cwd());360+ const environmentMode = options.environmentMode ?? "isolated-local";361+ const artifacts = artifactPaths(362+ resolve(363+ options.artifactRoot ??364+ join(365+ sourceRoot,366+ environmentMode === "cloud-from-root"367+ ? "test-results/golden-path-cloud"368+ : "test-results/golden-path",369+ ),370+ ),371+ );372+ await Deno.remove(artifacts.root, { recursive: true }).catch((error) => {373+ if (!(error instanceof Deno.errors.NotFound)) throw error;374+ });375+ await Deno.mkdir(artifacts.root, { recursive: true });376+377+ const startedAt = new Date();378+ const timeoutMs = options.timeoutMs ?? 60_000;379+ const commands = { ...defaultCommands, ...options.commands };380+ const islandLabels = options.islandLabels ?? ["North island", "South island"];381+ const environment = await journeyEnvironment(sourceRoot, environmentMode, artifacts.root);382+ const redactValues = environmentNames.map((name) => environment[name]).filter(Boolean);383+ const commit = await sourceRevision(sourceRoot, environment);384+ const commandRecords: CommandRecord[] = [];385+ const assertions: JourneyAssertion[] = [];386+ let projectRoot = sourceRoot;387+ let browser: Browser | null = null;388+ let context: BrowserContext | null = null;389+ let page: Page | null = null;390+ let dev: CapturedProcess | null = null;391+ let preview: CapturedProcess | null = null;392+ let browserVersion = "not launched";393+ let devReadyMs: number | undefined;394+ let firstRetainedWriteMs: number | undefined;395+ let failure: unknown;396+397+ const report: JourneyReport = {398+ schemaVersion: 1,399+ source: options.source,400+ environmentMode: environmentMode === "cloud-from-root" ? "cloud-allowlisted" : "isolated-local",401+ status: "failed",402+ startedAt: startedAt.toISOString(),403+ completedAt: startedAt.toISOString(),404+ runtime: {405+ deno: Deno.version.deno,406+ typescript: Deno.version.typescript,407+ v8: Deno.version.v8,408+ os: Deno.build.os,409+ arch: Deno.build.arch,410+ browser: browserVersion,411+ commit,412+ },413+ cacheMode: options.cacheMode ?? "existing",414+ measurements: {415+ developerCommandCount: 3,416+ },417+ commands: commandRecords,418+ assertions,419+ authorBoundaryViolations: [],420+ artifacts,421+ };422+423+ try {424+ if (options.source === "checkout") {425+ projectRoot = await materializeCleanProject(sourceRoot);426+ commandRecords.push(427+ ...await initializeFixtureGit(projectRoot, environment, artifacts.root, redactValues),428+ );429+ }430+431+ report.authorBoundaryViolations = await scanAuthorBoundary(432+ projectRoot,433+ options.authorPaths ?? defaultAuthorPaths,434+ );435+ assert(436+ report.authorBoundaryViolations.length === 0,437+ `author boundary leaked framework plumbing: ${438+ report.authorBoundaryViolations.map((item) => `${item.path}:${item.line} ${item.rule}`)439+ .join(440+ ", ",441+ )442+ }`,443+ );444+ assertions.push({445+ name: "author boundary",446+ status: "passed",447+ detail: "No forbidden runtime, worker, transport, Workbox, or capability plumbing found.",448+ });449+450+ try {451+ browser = await chromium.launch({ headless: true });452+ } catch (error) {453+ throw new Error(454+ "Chromium is unavailable; install the pinned browser through the repository's explicit Deno browser-install task.",455+ { cause: error },456+ );457+ }458+ browserVersion = browser.version();459+ report.runtime.browser = browserVersion;460+ context = await browser.newContext();461+ await context.tracing.start({ screenshots: true, snapshots: true, sources: true });462+ page = await context.newPage();463+464+ const port = reserveLocalPort();465+ const devStarted = performance.now();466+ dev = startReadyProcess({467+ cwd: projectRoot,468+ args: taskArgs(commands.dev, ["--host", "127.0.0.1", "--port", String(port)]),469+ environment,470+ artifactRoot: artifacts.root,471+ name: "dev",472+ readyPattern: /(http:\/\/(?:127\.0\.0\.1|localhost):\d+\/?)/,473+ readyCheck: probeHttpReady,474+ timeoutMs,475+ redactValues,476+ });477+ const devUrl = await dev.ready;478+ devReadyMs = Math.round(performance.now() - devStarted);479+ await page.goto(devUrl, { waitUntil: "domcontentloaded", timeout: timeoutMs });480+ await page.getByRole("textbox", { name: checklistUi.newItem }).first().waitFor({481+ state: "visible",482+ });483+ await assertRuntimeCardinality(page, islandLabels);484+ await page.getByRole("status").filter({ hasText: /\d+ item\(s\)/ }).first().waitFor({485+ state: "visible",486+ });487+ devReadyMs = Math.round(performance.now() - devStarted);488+ assertions.push({489+ name: "development readiness",490+ status: "passed",491+ detail: `Hydrated author UI and the persistent runtime were usable in ${devReadyMs}ms.`,492+ });493+494+ await addItem(page, islandLabels[0], retainedBody);495+ await waitForItem(page, islandLabels, retainedBody);496+ await waitForLocalDurability(page);497+ firstRetainedWriteMs = Math.round(performance.now() - devStarted);498+ await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });499+ await waitForItem(page, islandLabels, retainedBody);500+ await assertRuntimeCardinality(page, islandLabels);501+ assertions.push({502+ name: "retained development write",503+ status: "passed",504+ detail:505+ "The local write survived reload and both islands kept one shared runtime subscription.",506+ });507+508+ await updateItem(page, retainedBody, updatedBody);509+ await waitForItem(page, islandLabels, updatedBody);510+ await completeItem(page, updatedBody);511+ await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });512+ await waitForItem(page, islandLabels, updatedBody);513+ await assertCompleted(page, updatedBody);514+515+ await addItem(page, islandLabels[0], disposableBody);516+ await waitForItem(page, islandLabels, disposableBody);517+ await deleteItem(page, disposableBody);518+ await waitForItemAbsent(page, islandLabels, disposableBody);519+ assertions.push({520+ name: "accessible CRUD",521+ status: "passed",522+ detail: "Create, update, complete, and delete worked through named author-facing controls.",523+ });524+525+ if (environmentMode === "cloud-from-root") await setReferenceConnection(page, false);526+ await context.setOffline(true);527+ await addItem(page, islandLabels[1], offlineDevelopmentBody);528+ await waitForItem(page, islandLabels, offlineDevelopmentBody);529+ await waitForLocalDurability(page);530+ await context.setOffline(false);531+ if (environmentMode === "cloud-from-root") await setReferenceConnection(page, true);532+ assertions.push(533+ await observeReconnectSettlement(page, timeoutMs, environmentMode === "cloud-from-root"),534+ );535+ await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });536+ await waitForItem(page, islandLabels, offlineDevelopmentBody);537+ assertions.push({538+ name: "development offline write",539+ status: "passed",540+ detail: "A loaded page accepted a local write offline and retained it after network return.",541+ });542+543+ await dev.stop();544+ dev = null;545+ for (546+ const [name, task] of [547+ ["check", commands.check],548+ ["test", commands.test],549+ ["build", commands.build],550+ ] as const551+ ) {552+ const record = await runCapturedCommand({553+ cwd: projectRoot,554+ args: taskArgs(task),555+ environment,556+ artifactRoot: artifacts.root,557+ name,558+ redactValues,559+ });560+ commandRecords.push(record);561+ if (record.exitCode !== 0) throw commandFailure(record);562+ }563+ assertions.push({564+ name: "public verification commands",565+ status: "passed",566+ detail: "check, test, and build completed through Deno tasks.",567+ });568+569+ preview = startReadyProcess({570+ cwd: projectRoot,571+ args: taskArgs(commands.preview, ["--port", String(port)]),572+ environment,573+ artifactRoot: artifacts.root,574+ name: "preview",575+ readyPattern: /(http:\/\/127\.0\.0\.1:\d+\/)/,576+ readyCheck: probeHttpReady,577+ timeoutMs,578+ redactValues,579+ });580+ const previewUrl = await preview.ready;581+ assert(new URL(previewUrl).origin === new URL(devUrl).origin, "preview must reuse dev origin");582+ await page.goto(previewUrl, { waitUntil: "domcontentloaded", timeout: timeoutMs });583+ await waitForItem(page, islandLabels, updatedBody);584+ await waitForProductionPwa(page, timeoutMs);585+ await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });586+ await waitForProductionPwa(page, timeoutMs);587+588+ await context.setOffline(true);589+ await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });590+ await waitForItem(page, islandLabels, updatedBody);591+ await addItem(page, islandLabels[0], offlineProductionBody);592+ await waitForItem(page, islandLabels, offlineProductionBody);593+ await waitForLocalDurability(page);594+ await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });595+ await waitForItem(page, islandLabels, offlineProductionBody);596+ await context.setOffline(false);597+ if (environmentMode === "cloud-from-root") {598+ await updateItem(page, offlineProductionBody, offlineProductionSyncedBody);599+ await waitForItem(page, islandLabels, offlineProductionSyncedBody);600+ assertions.push(await observeReconnectSettlement(page, timeoutMs, true));601+ }602+ await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });603+ await waitForItem(604+ page,605+ islandLabels,606+ environmentMode === "cloud-from-root" ? offlineProductionSyncedBody : offlineProductionBody,607+ );608+ assertions.push({609+ name: "production offline cold start",610+ status: "passed",611+ detail: "The service-worker shell and OPFS data survived cold offline reload and editing.",612+ });613+614+ if (environmentMode === "cloud-from-root") {615+ for (const body of [updatedBody, offlineDevelopmentBody, offlineProductionSyncedBody]) {616+ await deleteItem(page, body);617+ await waitForItemAbsent(page, islandLabels, body);618+ assertions.push(await observeReconnectSettlement(page, timeoutMs, true));619+ }620+ assertions.push({621+ name: "cloud fixture cleanup",622+ status: "passed",623+ detail:624+ "All golden-path rows were deleted through the public UI and the delete mutations reached global durability before the throwaway browser identity closed.",625+ });626+ }627+628+ report.status = "passed";629+ } catch (error) {630+ failure = error;631+ assertions.push({632+ name: "golden journey",633+ status: "failed",634+ detail: error instanceof Error ? error.message : String(error),635+ });636+ await screenshotFailure(page, artifacts.screenshot);637+ } finally {638+ if (context) {639+ await context.setOffline(false).catch(() => undefined);640+ await context.tracing.stop({ path: artifacts.trace }).catch(() => undefined);641+ }642+ await preview?.stop().catch(() => undefined);643+ await dev?.stop().catch(() => undefined);644+ await context?.close().catch(() => undefined);645+ await browser?.close().catch(() => undefined);646+ if (options.source === "checkout" && projectRoot !== sourceRoot) {647+ await Deno.remove(resolve(projectRoot, ".."), { recursive: true }).catch(() => {648+ assertions.push({649+ name: "clean checkout cleanup",650+ status: "blocked",651+ detail: `Temporary checkout cleanup failed for ${projectRoot}.`,652+ });653+ });654+ }655+ report.completedAt = new Date().toISOString();656+ report.measurements.devReadyMs = devReadyMs;657+ report.measurements.firstRetainedWriteMs = firstRetainedWriteMs;658+ report.runtime.browser = browserVersion;659+ await writeJourneyReport(report);660+ }661+662+ if (failure) throw failure;663+ return report;664+}@@ -0,0 +1,18 @@
1+import { runJourney } from "./golden_path.ts";2+3+try {4+ const cloud = Deno.args.includes("--cloud");5+ const report = await runJourney({6+ source: "checkout",7+ environmentMode: cloud ? "cloud-from-root" : "isolated-local",8+ });9+ console.log(`lofi golden path: passed (${report.artifacts.report})`);10+} catch (error) {11+ console.error(`lofi golden path: failed: ${error instanceof Error ? error.message : error}`);12+ console.error(13+ Deno.args.includes("--cloud")14+ ? "rerun: deno task test:golden:checkout -- --cloud"15+ : "rerun: deno task test:golden:checkout",16+ );17+ Deno.exit(1);18+}@@ -0,0 +1,702 @@
1+import { basename, dirname, join, relative, resolve } from "node:path";2+import { environmentNames } from "./env_contract.ts";3+4+export type JourneySource = "checkout" | "create";5+6+export type CommandRecord = {7+ name: string;8+ args: string[];9+ durationMs: number;10+ exitCode: number;11+ stdoutPath: string;12+ stderrPath: string;13+};14+15+export type JourneyAssertion = {16+ name: string;17+ status: "passed" | "failed" | "blocked";18+ detail: string;19+};20+21+export type AuthorBoundaryViolation = {22+ rule: string;23+ path: string;24+ line: number;25+ excerpt: string;26+};27+28+export type JourneyReport = {29+ schemaVersion: 1;30+ source: JourneySource;31+ environmentMode: "isolated-local" | "cloud-allowlisted";32+ status: "passed" | "failed";33+ startedAt: string;34+ completedAt: string;35+ runtime: {36+ deno: string;37+ typescript: string;38+ v8: string;39+ os: string;40+ arch: string;41+ browser: string;42+ commit: string;43+ };44+ cacheMode: "existing" | "cold";45+ measurements: {46+ developerCommandCount: number;47+ devReadyMs?: number;48+ firstRetainedWriteMs?: number;49+ };50+ commands: CommandRecord[];51+ assertions: JourneyAssertion[];52+ authorBoundaryViolations: AuthorBoundaryViolation[];53+ artifacts: {54+ root: string;55+ report: string;56+ trace: string;57+ screenshot: string;58+ };59+};60+61+export type BoundaryRule = {62+ id: string;63+ pattern: RegExp;64+ allow?: (path: string) => boolean;65+};66+67+export const defaultBoundaryRules: readonly BoundaryRule[] = [68+ {69+ id: "raw-jazz-runtime-import",70+ pattern: /(?:from\s+|import\s*\()["']jazz-tools(?:\/[^"']*)?["']/,71+ // The M1 contract still explicitly permits the pinned Jazz schema DSL in schema.ts.72+ allow: (path) => basename(path) === "schema.ts" || basename(path) === "permissions.ts",73+ },74+ {75+ id: "raw-jazz-client-api",76+ pattern: /\b(?:BrowserAuthSecretStore|createDb|JazzProvider|useJazzContext)\b/,77+ },78+ {79+ id: "worker-plumbing",80+ pattern: /\b(?:SharedWorker|navigator\.serviceWorker|new\s+Worker)\b/,81+ },82+ {83+ id: "transport-plumbing",84+ pattern: /\b(?:JAZZ_SERVER_URL|VITE_JAZZ_[A-Z0-9_]*|transportUrl)\b/,85+ },86+ { id: "workbox-plumbing", pattern: /\bworkbox\b/i },87+ {88+ id: "browser-capability-branch",89+ pattern: /\b(?:navigator\.storage|navigator\.locks|isSecureContext)\b/,90+ },91+];92+93+const ignoredDirectoryNames = new Set([94+ ".astro",95+ ".git",96+ ".playwright-cli",97+ ".vite",98+ "coverage",99+ "dist",100+ "node_modules",101+ "output",102+ "playwright-report",103+ "test-results",104+]);105+106+const childEnvironmentAllowlist = [107+ "CI",108+ "DENO_DIR",109+ "GITHUB_ACTIONS",110+ "LANG",111+ "LC_ALL",112+ "NIX_SSL_CERT_FILE",113+ "NO_PROXY",114+ "PATH",115+ "PLAYWRIGHT_BROWSERS_PATH",116+ "RUNNER_ARCH",117+ "RUNNER_OS",118+ "SSL_CERT_FILE",119+ "TEMP",120+ "TMP",121+ "TMPDIR",122+] as const;123+124+function assertWithin(root: string, path: string) {125+ const child = relative(resolve(root), resolve(path));126+ if (child === "" || (!child.startsWith("..") && !child.startsWith("/"))) return;127+ throw new Error(`path escapes project root: ${path}`);128+}129+130+function shouldExclude(name: string, isDirectory: boolean, relativePath: string): boolean {131+ if (isDirectory && (ignoredDirectoryNames.has(name) || name.startsWith(".nzip-"))) return true;132+ // The M2 reference application supersedes this ignored M1 working artifact. A working-tree copy133+ // must not accidentally resurrect it just because another agent is moving tracked paths.134+ if (isDirectory && relativePath === join("apps", "prototype")) return true;135+ if (name === ".env.example") return false;136+ if (name === ".env" || name.startsWith(".env.")) return true;137+ return name.endsWith(".sqlite") || name.includes(".sqlite-");138+}139+140+async function copyDirectory(141+ sourceRoot: string,142+ source: string,143+ destination: string,144+): Promise<void> {145+ await Deno.mkdir(destination, { recursive: true });146+ const entries: Deno.DirEntry[] = [];147+ for await (const entry of Deno.readDir(source)) entries.push(entry);148+ entries.sort((left, right) => left.name.localeCompare(right.name));149+150+ for (const entry of entries) {151+ const from = join(source, entry.name);152+ const to = join(destination, entry.name);153+ const displayPath = relative(sourceRoot, from);154+ if (shouldExclude(entry.name, entry.isDirectory, displayPath)) continue;155+ if (entry.isDirectory) {156+ await copyDirectory(sourceRoot, from, to);157+ } else if (entry.isFile) {158+ await Deno.copyFile(from, to);159+ } else if (entry.isSymlink) {160+ throw new Error(`clean checkout refuses symlink: ${relative(source, from)}`);161+ }162+ }163+}164+165+async function trackedFiles(source: string): Promise<string[] | null> {166+ const rootOutput = await new Deno.Command("git", {167+ args: ["-C", source, "rev-parse", "--show-toplevel"],168+ stdout: "piped",169+ stderr: "null",170+ }).output();171+ if (!rootOutput.success) return null;172+ const repositoryRoot = new TextDecoder().decode(rootOutput.stdout).trim();173+ if (resolve(repositoryRoot) !== resolve(source)) return null;174+175+ const filesOutput = await new Deno.Command("git", {176+ args: ["-C", source, "ls-files", "-z"],177+ stdout: "piped",178+ stderr: "null",179+ }).output();180+ if (!filesOutput.success) return null;181+ return new TextDecoder().decode(filesOutput.stdout).split("\0").filter(Boolean).sort();182+}183+184+async function copyTrackedFiles(185+ source: string,186+ destination: string,187+ files: readonly string[],188+): Promise<void> {189+ await Deno.mkdir(destination, { recursive: true });190+ for (const displayPath of files) {191+ const segments = displayPath.split(/[\\/]/);192+ if (193+ segments.some((name, index) =>194+ shouldExclude(name, index < segments.length - 1, segments.slice(0, index + 1).join("/"))195+ )196+ ) continue;197+ const from = join(source, displayPath);198+ const to = join(destination, displayPath);199+ const stat = await Deno.lstat(from);200+ if (stat.isSymlink) throw new Error(`clean checkout refuses symlink: ${displayPath}`);201+ if (!stat.isFile) continue;202+ await Deno.mkdir(dirname(to), { recursive: true });203+ await Deno.copyFile(from, to);204+ }205+}206+207+export async function materializeCleanProject(208+ sourceRoot: string,209+ destinationRoot?: string,210+): Promise<string> {211+ const source = resolve(sourceRoot);212+ const destination = destinationRoot213+ ? resolve(destinationRoot)214+ : join(await Deno.makeTempDir({ prefix: "lofi-golden-" }), "project");215+ if (source === destination) throw new Error("clean project destination must differ from source");216+ const files = await trackedFiles(source);217+ if (files) await copyTrackedFiles(source, destination, files);218+ else await copyDirectory(source, source, destination);219+ return destination;220+}221+222+export function safeChildEnvironment(223+ source: Record<string, string> = Deno.env.toObject(),224+): Record<string, string> {225+ const environment: Record<string, string> = {};226+ for (const name of childEnvironmentAllowlist) {227+ const value = source[name];228+ if (value) environment[name] = value;229+ }230+ environment.CI = "1";231+ environment.NO_COLOR = "1";232+ // Explicit empties prevent a parent shell's sync credentials from influencing local mode.233+ for (const name of environmentNames) environment[name] = "";234+ return environment;235+}236+237+export async function installNodeSentinel(238+ environment: Record<string, string>,239+ artifactRoot: string,240+): Promise<Record<string, string>> {241+ if (Deno.build.os === "windows") return { ...environment };242+ const directory = join(artifactRoot, "node-sentinel");243+ const executable = join(directory, "node");244+ await Deno.mkdir(directory, { recursive: true });245+ await Deno.writeTextFile(246+ executable,247+ '#!/bin/sh\necho "error: hidden Node invocation is forbidden by the lofi golden path" >&2\nexit 97\n',248+ );249+ await Deno.chmod(executable, 0o755);250+ return {251+ ...environment,252+ PATH: environment.PATH ? `${directory}:${environment.PATH}` : directory,253+ };254+}255+256+async function sourceFiles(path: string): Promise<string[]> {257+ const stat = await Deno.stat(path);258+ if (stat.isFile) return [path];259+ const files: string[] = [];260+ const entries: Deno.DirEntry[] = [];261+ for await (const entry of Deno.readDir(path)) entries.push(entry);262+ entries.sort((left, right) => left.name.localeCompare(right.name));263+ for (const entry of entries) {264+ const child = join(path, entry.name);265+ if (entry.isDirectory) files.push(...await sourceFiles(child));266+ else if (entry.isFile && /\.(?:astro|css|js|jsx|ts|tsx)$/.test(entry.name)) files.push(child);267+ }268+ return files;269+}270+271+export async function scanAuthorBoundary(272+ projectRoot: string,273+ authorPaths: readonly string[],274+ rules: readonly BoundaryRule[] = defaultBoundaryRules,275+): Promise<AuthorBoundaryViolation[]> {276+ const root = resolve(projectRoot);277+ const files: string[] = [];278+ for (const authorPath of authorPaths) {279+ const path = resolve(root, authorPath);280+ assertWithin(root, path);281+ try {282+ files.push(...await sourceFiles(path));283+ } catch (error) {284+ if (!(error instanceof Deno.errors.NotFound)) throw error;285+ }286+ }287+288+ const violations: AuthorBoundaryViolation[] = [];289+ for (const file of [...new Set(files)].sort()) {290+ const displayPath = relative(root, file);291+ const lines = (await Deno.readTextFile(file)).split("\n");292+ for (let index = 0; index < lines.length; index += 1) {293+ for (const rule of rules) {294+ rule.pattern.lastIndex = 0;295+ if (rule.allow?.(displayPath) || !rule.pattern.test(lines[index])) continue;296+ violations.push({297+ rule: rule.id,298+ path: displayPath,299+ line: index + 1,300+ excerpt: lines[index].trim().slice(0, 160),301+ });302+ }303+ }304+ }305+ return violations;306+}307+308+export async function withDeadline<T>(309+ operation: Promise<T>,310+ timeoutMs: number,311+ description: string,312+): Promise<T> {313+ let timeout: ReturnType<typeof setTimeout> | undefined;314+ const deadline = new Promise<never>((_, reject) => {315+ timeout = setTimeout(316+ () => reject(new Error(`${description} did not become ready within ${timeoutMs}ms`)),317+ timeoutMs,318+ );319+ });320+ try {321+ return await Promise.race([operation, deadline]);322+ } finally {323+ clearTimeout(timeout);324+ }325+}326+327+export type CapturedProcess = {328+ child: Deno.ChildProcess;329+ ready: Promise<string>;330+ completed: Promise<Deno.CommandStatus>;331+ stdoutPath: string;332+ stderrPath: string;333+ stop(): Promise<void>;334+};335+336+function isAlreadyTerminated(error: unknown): boolean {337+ return error instanceof Deno.errors.NotFound ||338+ (error instanceof TypeError && error.message.includes("already terminated"));339+}340+341+async function captureLines(342+ stream: ReadableStream<Uint8Array>,343+ path: string,344+ onLine: (line: string) => void,345+ redact: (text: string) => string,346+): Promise<void> {347+ const file = await Deno.open(path, { create: true, truncate: true, write: true });348+ const writer = file.writable.getWriter();349+ const decoder = new TextDecoder();350+ const encoder = new TextEncoder();351+ let pending = "";352+ try {353+ for await (const chunk of stream) {354+ pending += decoder.decode(chunk, { stream: true });355+ const lines = pending.split(/\r?\n/);356+ pending = lines.pop() ?? "";357+ for (const line of lines) {358+ const redacted = redact(line);359+ await writer.write(encoder.encode(`${redacted}\n`));360+ onLine(redacted);361+ }362+ }363+ pending += decoder.decode();364+ if (pending) {365+ const redacted = redact(pending);366+ await writer.write(encoder.encode(redacted));367+ onLine(redacted);368+ }369+ } finally {370+ await writer.close();371+ }372+}373+374+export function redactText(text: string, values: readonly string[]): string {375+ return createRedactor(values)(text);376+}377+378+function createRedactor(values: readonly string[]): (text: string) => string {379+ const candidates = [...new Set(values.map((value) => value.trim()).filter(Boolean))]380+ .sort((left, right) => right.length - left.length);381+ return (text) => {382+ let redacted = text;383+ for (const value of candidates) redacted = redacted.split(value).join("[redacted]");384+ return redacted;385+ };386+}387+388+function delay(milliseconds: number): Promise<void> {389+ return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));390+}391+392+export async function probeHttpReady(url: string): Promise<boolean> {393+ try {394+ const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });395+ await response.body?.cancel();396+ return response.status >= 200 && response.status < 400;397+ } catch {398+ return false;399+ }400+}401+402+async function descendantProcessIds(rootPid: number): Promise<number[]> {403+ if (Deno.build.os === "windows") return [];404+ const output = await new Deno.Command("ps", {405+ args: ["-axo", "pid=,ppid="],406+ stdout: "piped",407+ stderr: "null",408+ }).output();409+ if (!output.success) return [];410+ const children = new Map<number, number[]>();411+ for (const line of new TextDecoder().decode(output.stdout).split("\n")) {412+ const [pidText, parentText] = line.trim().split(/\s+/);413+ const pid = Number(pidText);414+ const parent = Number(parentText);415+ if (!Number.isInteger(pid) || !Number.isInteger(parent)) continue;416+ const siblings = children.get(parent) ?? [];417+ siblings.push(pid);418+ children.set(parent, siblings);419+ }420+ const descendants: number[] = [];421+ const visit = (pid: number) => {422+ for (const child of children.get(pid) ?? []) visit(child);423+ if (pid !== rootPid) descendants.push(pid);424+ };425+ visit(rootPid);426+ return descendants;427+}428+429+function signalProcess(pid: number, signal: Deno.Signal): void {430+ try {431+ Deno.kill(pid, signal);432+ } catch (error) {433+ if (!isAlreadyTerminated(error)) throw error;434+ }435+}436+437+async function waitForPortRelease(url: string, timeoutMs = 2_000): Promise<void> {438+ const parsed = new URL(url);439+ const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));440+ const hostname = parsed.hostname;441+ const started = performance.now();442+ while (performance.now() - started < timeoutMs) {443+ try {444+ const connection = await Deno.connect({ hostname, port });445+ connection.close();446+ await delay(50);447+ } catch {448+ return;449+ }450+ }451+ throw new Error(`process shutdown left ${parsed.origin} accepting connections`);452+}453+454+export function startReadyProcess(options: {455+ executable?: string;456+ cwd: string;457+ args: string[];458+ environment: Record<string, string>;459+ artifactRoot: string;460+ name: string;461+ readyPattern: RegExp;462+ readyCheck?: (value: string) => Promise<boolean>;463+ timeoutMs: number;464+ redactValues?: readonly string[];465+}): CapturedProcess {466+ const stdoutPath = join(options.artifactRoot, `${options.name}.stdout.log`);467+ const stderrPath = join(options.artifactRoot, `${options.name}.stderr.log`);468+ const redact = createRedactor(options.redactValues ?? []);469+ const child = new Deno.Command(options.executable ?? Deno.execPath(), {470+ args: options.args,471+ cwd: options.cwd,472+ clearEnv: true,473+ env: options.environment,474+ stdin: "null",475+ stdout: "piped",476+ stderr: "piped",477+ }).spawn();478+479+ let resolveReady!: (value: string) => void;480+ let rejectReady!: (error: Error) => void;481+ let settled = false;482+ let advertisedValue: string | null = null;483+ let terminalStatus: Deno.CommandStatus | null = null;484+ const stderrTail: string[] = [];485+ const knownDescendants = new Set<number>();486+ const rememberDescendants = async () => {487+ for (const pid of await descendantProcessIds(child.pid)) knownDescendants.add(pid);488+ };489+ const diagnostics = (reason: string, status = terminalStatus): Error => {490+ const processState = status491+ ? `exited with ${status.code}${status.signal ? ` (${status.signal})` : ""}`492+ : "is still running";493+ const tail = stderrTail.length > 0 ? stderrTail.join("\n") : "(empty)";494+ return new Error(495+ `${options.name} ${reason}; process ${processState}; stderr tail:\n${tail}\nretained stderr: ${stderrPath}`,496+ );497+ };498+ const readiness = new Promise<string>((resolveReadyPromise, rejectReadyPromise) => {499+ resolveReady = resolveReadyPromise;500+ rejectReady = rejectReadyPromise;501+ });502+ const verifyAdvertisedValue = async (value: string) => {503+ if (!options.readyCheck) {504+ if (!settled) {505+ settled = true;506+ resolveReady(value);507+ }508+ return;509+ }510+ while (!settled) {511+ if (await options.readyCheck(value)) {512+ settled = true;513+ resolveReady(value);514+ return;515+ }516+ await delay(50);517+ }518+ };519+ const inspectLine = (line: string) => {520+ options.readyPattern.lastIndex = 0;521+ const match = options.readyPattern.exec(line);522+ if (match && !advertisedValue) {523+ advertisedValue = match[1] ?? match[0];524+ void rememberDescendants();525+ void verifyAdvertisedValue(advertisedValue).catch((error) => {526+ if (!settled) {527+ settled = true;528+ rejectReady(error instanceof Error ? error : new Error(String(error)));529+ }530+ });531+ }532+ };533+ const stdout = captureLines(child.stdout, stdoutPath, inspectLine, redact);534+ const stderr = captureLines(child.stderr, stderrPath, (line) => {535+ stderrTail.push(line);536+ if (stderrTail.length > 20) stderrTail.shift();537+ inspectLine(line);538+ }, redact);539+ const completed = (async () => {540+ const status = await child.status;541+ terminalStatus = status;542+ if (!settled) {543+ // Give the capture loops one turn to drain buffered stderr without letting an544+ // inherited grandchild pipe delay the exit diagnosis indefinitely.545+ await Promise.race([Promise.all([stdout, stderr]), delay(100)]);546+ settled = true;547+ rejectReady(diagnostics("exited before its advertised URL accepted requests", status));548+ }549+ await Promise.all([stdout, stderr]);550+ return status;551+ })();552+ const ready = withDeadline(readiness, options.timeoutMs, `${options.name} URL`).catch((error) => {553+ if (!settled) settled = true;554+ if (error instanceof Error && error.message.includes("did not become ready")) {555+ throw diagnostics(556+ advertisedValue557+ ? "printed a URL that never accepted HTTP requests before the readiness deadline"558+ : "did not print a usable URL before the readiness deadline",559+ );560+ }561+ throw error;562+ });563+564+ return {565+ child,566+ ready,567+ completed,568+ stdoutPath,569+ stderrPath,570+ async stop() {571+ await rememberDescendants();572+ for (const pid of knownDescendants) signalProcess(pid, "SIGTERM");573+ try {574+ child.kill("SIGTERM");575+ } catch (error) {576+ if (!isAlreadyTerminated(error)) throw error;577+ }578+ try {579+ await withDeadline(completed, 5_000, `${options.name} shutdown`);580+ } catch {581+ await rememberDescendants();582+ for (const pid of knownDescendants) signalProcess(pid, "SIGKILL");583+ try {584+ child.kill("SIGKILL");585+ } catch (error) {586+ if (!isAlreadyTerminated(error)) throw error;587+ }588+ await completed;589+ }590+ if (advertisedValue?.startsWith("http://") || advertisedValue?.startsWith("https://")) {591+ await waitForPortRelease(advertisedValue);592+ }593+ },594+ };595+}596+597+export async function runCapturedCommand(options: {598+ executable?: string;599+ cwd: string;600+ args: string[];601+ environment: Record<string, string>;602+ artifactRoot: string;603+ name: string;604+ redactValues?: readonly string[];605+}): Promise<CommandRecord> {606+ const stdoutPath = join(options.artifactRoot, `${options.name}.stdout.log`);607+ const stderrPath = join(options.artifactRoot, `${options.name}.stderr.log`);608+ const started = performance.now();609+ const output = await new Deno.Command(options.executable ?? Deno.execPath(), {610+ args: options.args,611+ cwd: options.cwd,612+ clearEnv: true,613+ env: options.environment,614+ stdin: "null",615+ stdout: "piped",616+ stderr: "piped",617+ }).output();618+ const decoder = new TextDecoder();619+ await Promise.all([620+ Deno.writeTextFile(621+ stdoutPath,622+ redactText(decoder.decode(output.stdout), options.redactValues ?? []),623+ ),624+ Deno.writeTextFile(625+ stderrPath,626+ redactText(decoder.decode(output.stderr), options.redactValues ?? []),627+ ),628+ ]);629+ return {630+ name: options.name,631+ args: options.args,632+ durationMs: Math.round(performance.now() - started),633+ exitCode: output.code,634+ stdoutPath,635+ stderrPath,636+ };637+}638+639+export async function initializeFixtureGit(640+ projectRoot: string,641+ environment: Record<string, string>,642+ artifactRoot: string,643+ redactValues: readonly string[] = [],644+): Promise<CommandRecord[]> {645+ // Deno does not proxy arbitrary git subcommands. Execute git directly, but retain the same646+ // structured evidence as every public Deno command.647+ const records: CommandRecord[] = [];648+ for (649+ const [name, args] of [650+ ["fixture-git-init", ["init", "-q"]],651+ ["fixture-git-add", ["add", "-A"]],652+ [653+ "fixture-git-commit",654+ [655+ "-c",656+ "user.name=lofi golden path",657+ "-c",658+ "user.email=golden-path@invalid",659+ "-c",660+ "commit.gpgsign=false",661+ "commit",662+ "-qm",663+ "clean checkout fixture",664+ ],665+ ],666+ ] as const667+ ) {668+ const record = await runCapturedCommand({669+ executable: "git",670+ args: [...args],671+ cwd: projectRoot,672+ name,673+ environment,674+ artifactRoot,675+ redactValues,676+ });677+ records.push(record);678+ if (record.exitCode !== 0) throw new Error(`${name} failed; see ${record.stderrPath}`);679+ }680+ return records;681+}682+683+export function reserveLocalPort(): number {684+ const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });685+ const address = listener.addr as Deno.NetAddr;686+ listener.close();687+ return address.port;688+}689+690+export function artifactPaths(root: string) {691+ return {692+ root,693+ report: join(root, "report.json"),694+ trace: join(root, "trace.zip"),695+ screenshot: join(root, "failure.png"),696+ };697+}698+699+export async function writeJourneyReport(report: JourneyReport): Promise<void> {700+ await Deno.mkdir(dirname(report.artifacts.report), { recursive: true });701+ await Deno.writeTextFile(report.artifacts.report, `${JSON.stringify(report, null, 2)}\n`);702+}@@ -0,0 +1,340 @@
1+import { join, resolve } from "node:path";2+import { assert } from "./assert.ts";3+import {4+ installNodeSentinel,5+ materializeCleanProject,6+ probeHttpReady,7+ redactText,8+ reserveLocalPort,9+ runCapturedCommand,10+ safeChildEnvironment,11+ scanAuthorBoundary,12+ startReadyProcess,13+ withDeadline,14+} from "./golden_path_core.ts";15+16+async function exists(path: string): Promise<boolean> {17+ try {18+ await Deno.stat(path);19+ return true;20+ } catch (error) {21+ if (error instanceof Deno.errors.NotFound) return false;22+ throw error;23+ }24+}25+26+async function workspaceTemp(prefix: string): Promise<string> {27+ return await Deno.makeTempDir({ dir: ".", prefix: `.lofi-${prefix}-` });28+}29+30+async function removeWorkspaceTemp(path: string): Promise<void> {31+ await Deno.remove(path, { recursive: true }).catch((error) => {32+ if (!(error instanceof Deno.errors.NotFound)) throw error;33+ });34+}35+36+Deno.test("safe child environment removes ambient application configuration", () => {37+ const environment = safeChildEnvironment({38+ PATH: "/test/bin",39+ DENO_DIR: "/test/deno",40+ JAZZ_APP_ID: "public-id",41+ JAZZ_SERVER_URL: "https://sync.invalid",42+ JAZZ_ADMIN_SECRET: "admin-secret",43+ BACKEND_SECRET: "backend-secret",44+ UNRELATED_SECRET: "also-secret",45+ });46+47+ assert(environment.PATH === "/test/bin", "PATH should remain available to Deno tasks");48+ assert(environment.DENO_DIR === "/test/deno", "the explicit Deno cache should remain available");49+ assert(environment.CI === "1", "clean-room commands should have deterministic CI output");50+ assert(environment.JAZZ_APP_ID === "", "public application configuration must be cleared");51+ assert(environment.JAZZ_ADMIN_SECRET === "", "server configuration must be cleared");52+ assert(!("UNRELATED_SECRET" in environment), "unrelated ambient secrets must not be inherited");53+});54+55+Deno.test("retained evidence redacts allowlisted configuration values", () => {56+ const output = redactText(57+ "app id public-id server https://sync.invalid secret server-secret",58+ ["public-id", "https://sync.invalid", "server-secret"],59+ );60+ assert(!output.includes("public-id"), "the app id must be redacted");61+ assert(!output.includes("sync.invalid"), "the server URL must be redacted");62+ assert(!output.includes("server-secret"), "server secrets must be redacted");63+ assert(output.match(/\[redacted\]/g)?.length === 3, "every configured value must be replaced");64+});65+66+Deno.test("Node sentinel is first on PATH", async () => {67+ if (Deno.build.os === "windows") return;68+ const root = await workspaceTemp("node-sentinel");69+ try {70+ const environment = await installNodeSentinel({ PATH: "/allowed/bin" }, root);71+ const sentinel = environment.PATH.split(":")[0];72+ assert(73+ resolve(sentinel).startsWith(resolve(root)),74+ "the sentinel directory must be first on PATH",75+ );76+ assert(77+ (await Deno.readTextFile(join(sentinel, "node"))).includes("hidden Node invocation"),78+ "the sentinel must fail with an actionable message",79+ );80+ } finally {81+ await removeWorkspaceTemp(root);82+ }83+});84+85+Deno.test("clean project copy excludes secrets caches and the retired prototype", async () => {86+ const source = await workspaceTemp("golden-source");87+ const destination = await workspaceTemp("golden-destination");88+ try {89+ await Deno.mkdir(join(source, "apps/prototype"), { recursive: true });90+ await Deno.mkdir(join(source, "apps/reference/src"), { recursive: true });91+ await Deno.mkdir(join(source, "apps/reference/node_modules/package"), { recursive: true });92+ await Deno.mkdir(join(source, "apps/reference/dist"), { recursive: true });93+ await Deno.mkdir(join(source, ".nzip-site"), { recursive: true });94+ await Promise.all([95+ Deno.writeTextFile(join(source, ".env"), "BACKEND_SECRET=do-not-copy\n"),96+ Deno.writeTextFile(join(source, ".env.local"), "JAZZ_APP_ID=do-not-copy\n"),97+ Deno.writeTextFile(join(source, ".env.example"), "JAZZ_APP_ID=\n"),98+ Deno.writeTextFile(join(source, "apps/prototype/retired.ts"), "retired\n"),99+ Deno.writeTextFile(join(source, "apps/reference/src/app.ts"), "export const app = {};\n"),100+ Deno.writeTextFile(join(source, "apps/reference/node_modules/package/index.js"), "cache\n"),101+ Deno.writeTextFile(join(source, "apps/reference/dist/index.html"), "build\n"),102+ Deno.writeTextFile(join(source, ".nzip-site/index.html"), "hosted\n"),103+ ]);104+105+ await materializeCleanProject(source, destination);106+ assert(await exists(join(destination, ".env.example")), ".env.example should be copied");107+ assert(108+ await exists(join(destination, "apps/reference/src/app.ts")),109+ "source should be copied",110+ );111+ assert(!await exists(join(destination, ".env")), ".env must be excluded");112+ assert(!await exists(join(destination, ".env.local")), ".env.local must be excluded");113+ assert(114+ !await exists(join(destination, "apps/prototype")),115+ "retired prototype must be excluded",116+ );117+ assert(118+ !await exists(join(destination, "apps/reference/node_modules")),119+ "node_modules must be excluded",120+ );121+ assert(!await exists(join(destination, "apps/reference/dist")), "dist must be excluded");122+ assert(!await exists(join(destination, ".nzip-site")), "hosted artifacts must be excluded");123+ } finally {124+ await Promise.all([removeWorkspaceTemp(source), removeWorkspaceTemp(destination)]);125+ }126+});127+128+Deno.test("clean project materialization copies only tracked files from a repository", async () => {129+ const source = await workspaceTemp("tracked-source");130+ const destination = await workspaceTemp("tracked-destination");131+ try {132+ await Deno.writeTextFile(join(source, "tracked.ts"), "export const tracked = true;\n");133+ await Deno.writeTextFile(join(source, "untracked.ts"), "export const untracked = true;\n");134+ for (const args of [["init", "-q"], ["add", "tracked.ts"]]) {135+ const output = await new Deno.Command("git", { args, cwd: source }).output();136+ assert(output.success, `git ${args[0]} fixture setup failed`);137+ }138+139+ await materializeCleanProject(source, destination);140+ assert(await exists(join(destination, "tracked.ts")), "tracked source must be copied");141+ assert(!await exists(join(destination, "untracked.ts")), "untracked source must be excluded");142+ } finally {143+ await Promise.all([144+ removeWorkspaceTemp(source),145+ removeWorkspaceTemp(destination),146+ ]);147+ }148+});149+150+Deno.test("author boundary permits schema declarations and reports UI plumbing", async () => {151+ const root = await workspaceTemp("golden-boundary");152+ try {153+ await Deno.mkdir(join(root, "src/islands"), { recursive: true });154+ await Promise.all([155+ Deno.writeTextFile(156+ join(root, "src/schema.ts"),157+ 'import { co } from "jazz-tools";\nexport const schema = co;\n',158+ ),159+ Deno.writeTextFile(160+ join(root, "src/permissions.ts"),161+ 'import type { Group } from "jazz-tools";\nexport type Permission = Group;\n',162+ ),163+ Deno.writeTextFile(164+ join(root, "src/islands/App.tsx"),165+ 'import { createDb } from "jazz-tools";\nconst ready = navigator.storage.persisted();\n',166+ ),167+ ]);168+169+ const violations = await scanAuthorBoundary(root, ["src"]);170+ assert(violations.length === 3, `expected three violations, found ${violations.length}`);171+ assert(172+ violations.every((violation) => violation.path === "src/islands/App.tsx"),173+ "schema and permissions should retain the explicit M1 exception",174+ );175+ assert(176+ violations.some((violation) => violation.rule === "raw-jazz-runtime-import"),177+ "raw imports in product UI should be reported",178+ );179+ assert(180+ violations.some((violation) => violation.rule === "raw-jazz-client-api"),181+ "raw client construction should be reported",182+ );183+ assert(184+ violations.some((violation) => violation.rule === "browser-capability-branch"),185+ "capability branching should be reported",186+ );187+ } finally {188+ await removeWorkspaceTemp(root);189+ }190+});191+192+Deno.test("process readiness resolves from output rather than a fixed sleep", async () => {193+ const artifacts = await workspaceTemp("golden-process");194+ try {195+ const process = startReadyProcess({196+ executable: "git",197+ cwd: Deno.cwd(),198+ args: ["--version"],199+ environment: safeChildEnvironment({}),200+ artifactRoot: artifacts,201+ name: "ready-control",202+ readyPattern: /(git version [^\n]+)/,203+ timeoutMs: 5_000,204+ });205+ assert(206+ (await process.ready).startsWith("git version "),207+ "the emitted control line should satisfy readiness",208+ );209+ assert((await process.completed).success, "the readiness control should exit successfully");210+ await process.stop();211+ } finally {212+ await removeWorkspaceTemp(artifacts);213+ }214+});215+216+Deno.test("HTTP readiness waits until an early advertised URL accepts requests", async () => {217+ const artifacts = await workspaceTemp("golden-http-ready");218+ const port = reserveLocalPort();219+ const url = `http://127.0.0.1:${port}/`;220+ const started = performance.now();221+ const process = startReadyProcess({222+ executable: Deno.execPath(),223+ cwd: Deno.cwd(),224+ args: [225+ "eval",226+ `console.log("READY ${url}"); await new Promise((resolve) => setTimeout(resolve, 200)); Deno.serve({ hostname: "127.0.0.1", port: ${port} }, () => new Response("ready")); await new Promise(() => {});`,227+ ],228+ environment: safeChildEnvironment({}),229+ artifactRoot: artifacts,230+ name: "early-banner",231+ readyPattern: /READY (http:\/\/127\.0\.0\.1:\d+\/)/,232+ readyCheck: probeHttpReady,233+ timeoutMs: 5_000,234+ });235+ try {236+ assert(await process.ready === url, "the accepting URL should satisfy readiness");237+ assert(238+ performance.now() - started >= 150,239+ "a printed banner must not resolve before the listener accepts requests",240+ );241+ } finally {242+ await process.stop();243+ await removeWorkspaceTemp(artifacts);244+ }245+});246+247+Deno.test("post-banner exit rejects with retained diagnostics", async () => {248+ const artifacts = await workspaceTemp("golden-http-exit");249+ const port = reserveLocalPort();250+ const process = startReadyProcess({251+ executable: Deno.execPath(),252+ cwd: Deno.cwd(),253+ args: [254+ "eval",255+ `console.log("READY http://127.0.0.1:${port}/"); console.error("unique post-banner failure"); Deno.exit(42);`,256+ ],257+ environment: safeChildEnvironment({}),258+ artifactRoot: artifacts,259+ name: "post-banner-exit",260+ readyPattern: /READY (http:\/\/127\.0\.0\.1:\d+\/)/,261+ readyCheck: probeHttpReady,262+ timeoutMs: 5_000,263+ });264+ try {265+ let message = "";266+ try {267+ await process.ready;268+ } catch (error) {269+ message = error instanceof Error ? error.message : String(error);270+ }271+ assert(message.includes("exited with 42"), "readiness must report the process exit code");272+ assert(message.includes("unique post-banner failure"), "readiness must include stderr tail");273+ assert(message.includes(process.stderrPath), "readiness must point to retained stderr");274+ await process.completed;275+ } finally {276+ await process.stop();277+ await removeWorkspaceTemp(artifacts);278+ }279+});280+281+Deno.test("stopping a ready wrapper terminates its serving grandchild", async () => {282+ if (Deno.build.os === "windows") return;283+ const artifacts = await workspaceTemp("golden-process-tree");284+ const port = reserveLocalPort();285+ const url = `http://127.0.0.1:${port}/`;286+ const serverSource =287+ `Deno.serve({ hostname: "127.0.0.1", port: ${port} }, () => new Response("child")); await new Promise(() => {});`;288+ const wrapperSource = `const child = new Deno.Command(Deno.execPath(), { args: ["eval", ${289+ JSON.stringify(serverSource)290+ }], stdout: "inherit", stderr: "inherit" }).spawn(); console.log("READY ${url}"); await child.status;`;291+ const process = startReadyProcess({292+ executable: Deno.execPath(),293+ cwd: Deno.cwd(),294+ args: ["eval", wrapperSource],295+ environment: safeChildEnvironment({}),296+ artifactRoot: artifacts,297+ name: "wrapper-tree",298+ readyPattern: /READY (http:\/\/127\.0\.0\.1:\d+\/)/,299+ readyCheck: probeHttpReady,300+ timeoutMs: 5_000,301+ });302+ try {303+ await process.ready;304+ await process.stop();305+ const rebound = Deno.listen({ hostname: "127.0.0.1", port });306+ rebound.close();307+ } finally {308+ await process.stop().catch(() => undefined);309+ await removeWorkspaceTemp(artifacts);310+ }311+});312+313+Deno.test("captured commands retain output and duration", async () => {314+ const artifacts = await workspaceTemp("golden-command");315+ try {316+ const record = await runCapturedCommand({317+ executable: "git",318+ cwd: Deno.cwd(),319+ args: ["--version"],320+ environment: safeChildEnvironment({}),321+ artifactRoot: artifacts,322+ name: "capture-control",323+ });324+ assert(record.exitCode === 0, "the control command should pass");325+ assert(record.durationMs >= 0, "duration should be retained");326+ assert(327+ (await Deno.readTextFile(record.stdoutPath)).includes("git version"),328+ "stdout should be retained",329+ );330+ } finally {331+ await removeWorkspaceTemp(artifacts);332+ }333+});334+335+Deno.test("deadline helper preserves a readiness result", async () => {336+ assert(337+ await withDeadline(Promise.resolve("ready"), 1_000, "control") === "ready",338+ "a ready operation should win the deadline race",339+ );340+});@@ -1,6 +1,6 @@
11 import { extname, join, normalize } from "node:path";22 3-const root = "apps/prototype/dist";3+const root = "apps/reference/dist";44 const portFlag = Deno.args.findIndex((arg) => arg === "--port");55 const port = portFlag >= 0 ? Number(Deno.args[portFlag + 1]) : 4321;66 if (!Number.isInteger(port) || port < 1 || port > 65_535) {@@ -1,7 +1,7 @@
11 import { environmentNames, validateEnvironment } from "./env_contract.ts";22 import { loadEnvironment } from "./load_env.ts";33 4-const INTERNAL_ENV = "apps/prototype/.env";4+const INTERNAL_ENV = "apps/reference/.env";55 const LOCAL_APP_ID = "00000000-0000-0000-0000-00000000f153";66 77 async function ensureStableLocalAppId() {@@ -63,7 +63,7 @@ async function routeCount(): Promise<number> {
6363 }6464 }6565 try {66- await visit("apps/prototype/dist");66+ await visit("apps/reference/dist");6767 } catch (error) {6868 if (!(error instanceof Deno.errors.NotFound)) throw error;6969 }@@ -85,7 +85,7 @@ async function buildFiles(root: string): Promise<string[]> {
8585 8686 const command = Deno.args[0];8787 if (command !== "dev" && command !== "build") {88- console.error("usage: run_prototype.ts <dev|build>");88+ console.error("usage: run_reference.ts <dev|build>");8989 Deno.exit(2);9090 }9191 @@ -135,7 +135,7 @@ const exitCode = await run(
135135 "npm:astro@7.0.9",136136 command,137137 "--root",138- "apps/prototype",138+ "apps/reference",139139 ...forwardedArgs,140140 ],141141 childEnvironment,@@ -150,21 +150,21 @@ if (command === "build") {
150150 }).output()).stdout,151151 ).trim();152152 await Deno.writeTextFile(153- "apps/prototype/dist/lofi-build.json",153+ "apps/reference/dist/lofi-build.json",154154 `${JSON.stringify({ revision, builtAt: new Date().toISOString() })}\n`,155155 );156- const serviceWorkerPath = "apps/prototype/dist/sw.js";156+ const serviceWorkerPath = "apps/reference/dist/sw.js";157157 const serviceWorker = await Deno.readTextFile(serviceWorkerPath);158158 await Deno.writeTextFile(159159 serviceWorkerPath,160160 serviceWorker.replace("__LOFI_BUILD_REVISION__", revision),161161 );162- const precache = (await buildFiles("apps/prototype/dist"))162+ const precache = (await buildFiles("apps/reference/dist"))163163 .filter((path) => path !== "./lofi-precache.json")164164 .map((path) => path === "./index.html" ? "./" : path);165165 precache.push("./lofi-precache.json");166166 await Deno.writeTextFile(167- "apps/prototype/dist/lofi-precache.json",167+ "apps/reference/dist/lofi-precache.json",168168 `${JSON.stringify(precache.sort())}\n`,169169 );170170 const scanCode = await run(@@ -172,5 +172,5 @@ if (command === "build") {
172172 validatedEnvironment,173173 );174174 if (scanCode !== 0) Deno.exit(scanCode);175- console.log(`lofi build: apps/prototype/dist (${await routeCount()} routes, ${revision})`);175+ console.log(`lofi build: apps/reference/dist (${await routeCount()} routes, ${revision})`);176176 }