feat(starter): enforce the author boundary everywhere, fix vendoring drift
- commit
- 79ecf21f94b3c55010b35b26d01d6de177ec246d
- parent
- efa9e7b5032335ca0c6574ba2c97c2b842ac1411
- parent
- 5d8d2eae4c7fad11d612860fa8ce911782ccd222
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | apps/ | 15 | ++------ |
| M | apps/ | 6 | ++-- |
| M | apps/ | 1 | + |
| M | apps/ | 3 | +- |
| M | apps/ | 6 | ++- |
| M | apps/ | 28 | +++++++++++++--- |
| M | docs/ | 8 | +++- |
| M | package/ | 18 | ++++++++-- |
| A | package/ | 55 | ++++++++++++++++++++++++++++++ |
| M | package/ | 1 | + |
| M | package/ | 7 | ++++ |
| M | package/ | 12 | +++--- |
@@ -76,15 +76,15 @@ export default function AccountGate() {
7676 void readAccountSession().then(setSession, (cause) => setError(describe(cause)));7777 }, []);7878 79+ // Every account action funnels through here: one busy flag, one error line,80+ // and any returned Session becomes the new snapshot.7981 const run = useCallback(80- (kind: Busy, action: () => Promise<unknown>) => {82+ (kind: Busy, action: () => Promise<Session | void>) => {8183 setBusy(kind);8284 setError(null);8385 void action().then(8486 (result) => {85- if (result && typeof result === "object" && "syncAvailable" in result) {86- setSession(result as Session);87- }87+ if (result) setSession(result);8888 },8989 (cause) => setError(describe(cause)),9090 ).finally(() => setBusy(null));@@ -113,13 +113,6 @@ export default function AccountGate() {
113113 <ol class="account-words" aria-label="Recovery phrase">114114 {phrase.split(" ").map((word, index) => <li key={index}>{word}</li>)}115115 </ol>116- <button117- type="button"118- class="account-secondary"119- onClick={() => void navigator.clipboard?.writeText(phrase).catch(() => {})}120- >121- Copy phrase122- </button>123116 </div>124117 );125118 @@ -1,5 +1,5 @@
11 import { useCallback } from "preact/hooks";2-import { schema as s } from "jazz-tools";2+import type { RowOf } from "@nzip/lofi";33 import { useLiveQuery, useTableMutations } from "@nzip/lofi/preact";44 import { app } from "../app.ts";55 @@ -11,8 +11,8 @@ import { app } from "../app.ts";
1111 */1212 const tasksTable = app.schema.tasks;1313 14-/** Row and insert types come straight from the declared schema. */15-export type Task = s.RowOf<typeof tasksTable>;14+/** The row type comes straight from the declared schema. */15+export type Task = RowOf<typeof tasksTable>;1616 1717 export function useTasks() {1818 const query = useLiveQuery(() => tasksTable.orderBy("createdAt", "desc"), []);@@ -24,6 +24,7 @@ const publicAsset = (name: string) => `${deploymentBase}${name}`;
2424 <main><slot /></main>2525 <script>2626 import { bootLofi } from "@nzip/lofi";27+ // Importing app.ts registers defineLofiApp before the runtime boots.2728 import { app } from "../app.ts";2829 void app;2930 void bootLofi();@@ -12,7 +12,8 @@ import "../styles/global.css";
1212 <p class="eyebrow">lofi</p>1313 <h1>Hello, lo-fi World.</h1>1414 <p>15- Local-first mobile PWA meta-framework - Jazz 2 CRDT sync, Preact islands, Astro shell, Deno toolchain.15+ Local-first mobile PWA meta-framework — Jazz 2 CRDT sync, Preact islands, Astro shell, Deno16+ toolchain.1617 </p>1718 <p>Opinionated Offline-first.</p>1819 </header>@@ -1,5 +1,8 @@
11 import { schema as s } from "jazz-tools";22 3+// Author-owned schema. This file and permissions.ts are the starter's two4+// deliberate raw-Jazz surfaces; UI islands consume the schema only through5+// public @nzip/lofi seams (see tests/author-boundary_test.ts).36 const schema = {47 tasks: s.table({58 text: s.string(),@@ -8,5 +11,4 @@ const schema = {
811 }),912 };1013 11-type AppSchema = s.Schema<typeof schema>;12-export const app: s.App<AppSchema> = s.defineApp(schema);14+export const app = s.defineApp(schema);@@ -2,18 +2,27 @@ const test = (globalThis as unknown as {
22 Deno: { test(name: string, body: () => void | Promise<void>): void };33 }).Deno.test;44 5+// Every author UI file is scanned with the full rule set — no exceptions, so6+// a violation cannot hide in a file the loop forgot to list.57 const authorUiFiles = [68 new URL("../src/pages/index.astro", import.meta.url),9+ new URL("../src/layouts/Shell.astro", import.meta.url),10+ new URL("../src/islands/AccountGate.tsx", import.meta.url),711 new URL("../src/islands/TaskList.tsx", import.meta.url),12+ new URL("../src/islands/use-tasks.ts", import.meta.url),13+];14+15+// schema.ts and permissions.ts are the starter's two deliberate raw-Jazz16+// surfaces; every other rule still applies to them.17+const schemaSurfaceFiles = [18+ new URL("../src/schema.ts", import.meta.url),19+ new URL("../src/permissions.ts", import.meta.url),820 ];921 1022 const packageConsumers = [1123 new URL("../src/app.ts", import.meta.url),12- new URL("../src/pages/index.astro", import.meta.url),13- new URL("../src/layouts/Shell.astro", import.meta.url),14- new URL("../src/islands/AccountGate.tsx", import.meta.url),15- new URL("../src/islands/TaskList.tsx", import.meta.url),16- new URL("../src/islands/use-tasks.ts", import.meta.url),24+ ...authorUiFiles,25+ ...schemaSurfaceFiles,1726 ];1827 1928 const forbidden = [@@ -23,6 +32,7 @@ const forbidden = [
2332 { pattern: /workbox/i, name: "Workbox configuration" },2433 { pattern: /navigator\.|globalThis\.(?:isSecureContext|SharedWorker)/, name: "browser branch" },2534 ];35+const forbiddenOnSchemaSurface = forbidden.filter((rule) => rule.name !== "raw Jazz import");2636 2737 test("author source consumes public lofi package seams and hides plumbing from product UI", async () => {2838 for (@@ -53,4 +63,12 @@ test("author source consumes public lofi package seams and hides plumbing from p
5363 }5464 }5565 }66+ for (const file of schemaSurfaceFiles) {67+ const source = await Deno.readTextFile(file);68+ for (const rule of forbiddenOnSchemaSurface) {69+ if (rule.pattern.test(source)) {70+ throw new Error(`${file.pathname} exposes forbidden ${rule.name}`);71+ }72+ }73+ }5674 });@@ -31,10 +31,14 @@ const schema = {
3131 }),3232 };3333 34-type AppSchema = s.Schema<typeof schema>;35-export const app: s.App<AppSchema> = s.defineApp(schema);34+export const app = s.defineApp(schema);3635 ```3736 37+`src/schema.ts` and `src/permissions.ts` are the two deliberate raw-Jazz surfaces in author source.38+UI islands stay on public package seams: derive row types with `RowOf` from `@nzip/lofi`39+(`type Record = RowOf<typeof app.schema.records>`) instead of importing the vendor module, and the40+generated author-boundary test enforces exactly that split.41+3842 Every table needs a policy in `src/permissions.ts`. Permissions determine which rows enter a live3943 query and which mutations succeed; realtime access is not a separate permission mode. See4044 [Permissions](permissions.md), [direct sharing](examples/shared.md), and@@ -17,7 +17,12 @@ export type LofiAstroOptions = {
1717 schemaDir?: string;1818 };1919 20-const runtimeFiles = [20+/**21+ * Runtime modules vendored into a project's `.lofi/` directory. Static because22+ * the published package cannot list directories over JSR; the manifest test23+ * fails when this list and `package/runtime/` drift apart.24+ */25+export const runtimeFiles = [2126 "app.ts",2227 "auth.ts",2328 "boot.ts",@@ -30,6 +35,7 @@ const runtimeFiles = [
3035 "lifecycle.ts",3136 "live-query-store.ts",3237 "mod.ts",38+ "namespace-state.ts",3339 "passkey-recovery.ts",3440 "probe.ts",3541 "pwa.ts",@@ -40,10 +46,12 @@ const runtimeFiles = [
4046 "startup-recovery.ts",4147 "table-mutations.ts",4248 "table-store.ts",49+ "transport-gate.ts",4350 "ui-mutation.ts",4451 ] as const;4552 46-const accessFiles = [53+/** Access modules vendored into `.lofi/`; kept in lockstep by the manifest test. */54+export const accessFiles = [4755 "errors.ts",4856 "identity.ts",4957 "mod.ts",@@ -52,7 +60,8 @@ const accessFiles = [
5260 "schema.ts",5361 ] as const;5462 55-const preactFiles = [63+/** Preact modules vendored into `.lofi/`; kept in lockstep by the manifest test. */64+export const preactFiles = [5665 "DeviceStatus.tsx",5766 "live-data.ts",5867 "mod.ts",@@ -61,7 +70,8 @@ const preactFiles = [
6170 "use-device-capabilities.ts",6271 ] as const;6372 64-const recipeFiles = [73+/** Recipe modules vendored into `.lofi/`; kept in lockstep by the manifest test. */74+export const recipeFiles = [6575 "file-handler.ts",6676 "launch-handler.ts",6777 "protocol-handler.ts",@@ -0,0 +1,55 @@
1+// Repo contract test: the static `.lofi/` vendoring manifests must track the2+// actual package directories. The lists are static because the published3+// package cannot enumerate directories over JSR — this test is what fails4+// when a new runtime module lands without being added to the manifest5+// (namespace-state.ts and transport-gate.ts shipped exactly that way and6+// broke every generated project's build until vendoring caught up).7+import { accessFiles, preactFiles, recipeFiles, runtimeFiles } from "./mod.ts";8+9+function assert(condition: unknown, message: string): asserts condition {10+ if (!condition) throw new Error(message);11+}12+13+// Files that live in the source directories but are deliberately not vendored.14+const notVendored = new Set([15+ "sw.js", // copied by the build command with its revision stamped in16+ "test-assert.ts", // package test helper17+]);18+19+async function moduleEntries(directory: string): Promise<string[]> {20+ const names: string[] = [];21+ for await (const entry of Deno.readDir(new URL(`../${directory}`, import.meta.url))) {22+ if (!entry.isFile) continue;23+ if (entry.name.endsWith("_test.ts") || entry.name.endsWith("_test.tsx")) continue;24+ if (notVendored.has(entry.name)) continue;25+ if (!/\.(ts|tsx|d\.ts)$/.test(entry.name)) continue;26+ names.push(entry.name);27+ }28+ return names.sort();29+}30+31+const manifests: ReadonlyArray<[string, readonly string[]]> = [32+ ["runtime", runtimeFiles],33+ ["access", accessFiles],34+ ["preact", preactFiles],35+ ["recipes", recipeFiles],36+];37+38+Deno.test("the .lofi vendoring manifests match the package directories", async () => {39+ for (const [directory, manifest] of manifests) {40+ const actual = await moduleEntries(directory);41+ const listed = [...manifest].sort();42+ const missing = actual.filter((name) => !listed.includes(name));43+ const stale = listed.filter((name) => !actual.includes(name));44+ assert(45+ missing.length === 0,46+ `package/${directory} contains modules missing from the vendoring manifest: ${47+ missing.join(", ")48+ } — generated projects cannot build without them`,49+ );50+ assert(51+ stale.length === 0,52+ `the vendoring manifest lists modules absent from package/${directory}: ${stale.join(", ")}`,53+ );54+ }55+});@@ -5,6 +5,13 @@ import type { RuntimeDiagnostics } from "./diagnostics.ts";
55 /** The minimum shape every persisted row exposes to the framework. */66 export type TableRow = { id: string };77 8+/**9+ * The row type of one declared schema table — `RowOf<typeof app.schema.tasks>`.10+ * Lets author code derive row types from its schema without importing the11+ * vendor module, keeping UI islands on public package seams.12+ */13+export type RowOf<Table> = Table extends { readonly _rowType: infer Row } ? Row : never;14+815 /**916 * A declared schema table (`schema.<name>`). It is both an insert/update/delete1017 * target and a query source, so the store accepts the intersection Jazz expects.@@ -14,16 +14,16 @@
1414 "README.md": "10d47c681ae44a3d70b790e3952e0ac361e0eeec4a433f0ff35cfe9db61a7778",1515 "src/app.ts": "0f508004dfb12456d65f3ee2231199b932fe239bef6977405fb93135972c0f33",1616 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",17- "src/islands/AccountGate.tsx": "9375e8393fadeb621244cccd14234ede017fbf1f2014f28cbddf55ea412a9094",17+ "src/islands/AccountGate.tsx": "31e74120f882dc9c1ff44e7ff41c0aa6e816ad8a4e9f937e611a3c19fb69c5f0",1818 "src/islands/TaskList.tsx": "cf559f8529e3836730abcdafab8184032c8ac662a1e794e20f0879161bee37be",19- "src/islands/use-tasks.ts": "6a4ef635bda5fafb57965fe680dd4d87b46fa487f3eb2db6bb6bd39c4b6c266b",20- "src/layouts/Shell.astro": "12144355def66c7bf6ed5b0d611ba3a5454cf7499b667936aa6c04494807653a",21- "src/pages/index.astro": "de9ad86f9b25f7aff1e293931a785dbe393e4d0a35464ffbb649a0ebb4518281",19+ "src/islands/use-tasks.ts": "22400693c9b7197adb8af858b0af13456459823229908c87e3af4fd4f6336554",20+ "src/layouts/Shell.astro": "57f5c144813fad7d6c7223b0e68498665f41dcd8b0855f2e8bf020d3bb072711",21+ "src/pages/index.astro": "884afb177743ccef0d996b8f60158f948b8bf38c966281f29564a9b4e767fa4c",2222 "src/permissions.ts": "dcb00b7f0576d04ad527b8892b8096573b107c3130ed6d835243251c406a38b7",23- "src/schema.ts": "f2c555b088ded8eea2653f5d955fc9aae69dfd588e891adf509e99052f1e9949",23+ "src/schema.ts": "9f88050f6ece01d28242a6d93ed32d32c1c5a71cff35e0dc421ab3715d591748",2424 "src/styles/global.css": "da8594026ea8420d00c58549b9b38040b4456f75615959ca2e94997b18be1997",2525 "tests/auth_e2e_test.ts": "87dc3eb441eeaa96fc82b15df90bf4108096506217e5bfae907b483a3e75b4bf",26- "tests/author-boundary_test.ts": "303691da91fd4d13ed2dca195b7745c5c106bcd0837338e632469dfec032d71b",26+ "tests/author-boundary_test.ts": "9fdb6d94180cef044b1d2e74601a01d8f0b26253700bb494bbb45d4127dc728a",2727 "tests/convergence_e2e_test.ts": "71ec0ac935c25ff0722905e5b195c730f32a3b886dd4172e11d9e0e80f19cb38",2828 "tests/testing-contract_test.ts": "4257306060d42566b879d92b3a6c395805b20f53717bee55d670c0ef7a117eec",2929 "tsconfig.json": "c632473513c623f3cc69d7476159fb9421624521220029755695defa265c6cab"