From 698ce0bcd83fd651ff01849b80745f9a3d01f100 Mon Sep 17 00:00:00 2001 From: Tyler van Hensbergen Date: Thu, 10 Sep 2026 15:31:24 -0700 Subject: [PATCH] Unify pre-release APIs and remove application setup friction --- .github/workflows/reef-preview.yml | 45 +----- .github/workflows/reef-publish.yml | 2 +- .github/workflows/release.yml | 7 +- CONTRIBUTING.md | 14 +- bun.lock | 1 + e2e-ops.ts | 13 +- examples/kv-style/operations.ts | 19 --- examples/kv-style/schema.ts | 12 ++ examples/reef/README.md | 57 +++---- examples/reef/alchemy.run.ts | 6 +- examples/reef/src/app/App.tsx | 16 +- examples/reef/src/app/MutationFeedback.tsx | 55 +++++++ examples/reef/src/app/auth.ts | 53 +------ .../reef/src/app/components/IssueDetail.tsx | 83 +++------- .../reef/src/app/components/MembersPanel.tsx | 35 ++-- examples/reef/src/app/entities.ts | 13 ++ examples/reef/src/app/index.css | 5 + examples/reef/src/app/ramose.ts | 24 ++- examples/reef/src/app/screens/AuthScreen.tsx | 3 +- examples/reef/src/app/screens/BoardScreen.tsx | 95 ++++------- .../reef/src/app/screens/WorkspacesScreen.tsx | 6 +- examples/reef/src/domain/queries.ts | 13 +- examples/reef/src/domain/schema.ts | 75 +++------ examples/reef/src/domain/shared.ts | 1 - examples/reef/src/infra/api.ts | 26 +-- examples/reef/src/infra/domain.ts | 4 - examples/reef/src/infra/resources.ts | 18 +-- examples/reef/src/infra/web-worker.ts | 18 +++ examples/reef/src/infra/web.ts | 18 +++ examples/reef/test/policy.test.ts | 2 +- examples/todos/README.md | 6 +- examples/todos/schema.ts | 31 ++++ examples/todos/src/todos.ts | 39 +---- package.json | 11 +- packages/ramose/README.md | 12 +- packages/ramose/package.json | 14 +- packages/ramose/src/Server.ts | 11 +- packages/ramose/src/better-auth/client.ts | 99 ++++++++++++ packages/ramose/src/client/client.ts | 28 ++-- packages/ramose/src/client/index.ts | 1 + packages/ramose/src/client/query.ts | 3 + packages/ramose/src/db/Field.ts | 8 +- packages/ramose/src/db/Operation.ts | 4 +- packages/ramose/src/db/index.ts | 38 +---- packages/ramose/src/db/internal.ts | 2 +- packages/ramose/src/db/query/query.ts | 10 +- packages/ramose/src/db/query/surface.ts | 29 +++- packages/ramose/src/index.ts | 3 +- .../internal/authorization/canonical-json.ts | 37 +---- packages/ramose/src/internal/core/bytes.ts | 16 +- packages/ramose/src/internal/core/json.ts | 8 +- packages/ramose/src/internal/core/novelty.ts | 5 +- packages/ramose/src/internal/core/segment.ts | 5 + packages/ramose/src/internal/core/store.ts | 50 +----- packages/ramose/src/internal/core/tree.ts | 4 + packages/ramose/src/react/index.ts | 3 +- packages/ramose/test/browser-bundles.test.ts | 26 +++ .../ramose/test/client/client-value-types.ts | 12 +- packages/ramose/test/client/client.test.ts | 10 +- packages/ramose/test/client/contract.test.ts | 14 +- .../test/client/mutation-namespace-types.ts | 4 +- packages/ramose/test/db-portable.test.ts | 16 +- packages/ramose/test/db/database-name.test.ts | 2 +- packages/ramose/test/db/ident-name.test.ts | 5 +- packages/ramose/test/db/public-query.test.ts | 56 +++++++ .../internal/authorization/authoring.test.ts | 2 +- .../ramose/test/internal/core/bytes.test.ts | 51 ++++++ .../ramose/test/internal/core/json.test.ts | 13 ++ .../ramose/test/internal/core/novelty.test.ts | 10 ++ .../ramose/test/internal/core/segment.test.ts | 22 +++ packages/ramose/test/react/exports.test.ts | 5 +- packages/ramose/test/surface.test.ts | 8 +- scripts/check-release.ts | 21 ++- scripts/e2e-cloudflare.sh | 4 +- scripts/lib/version.test.ts | 10 ++ scripts/lib/version.ts | 20 +++ scripts/publish-packages.ts | 21 ++- scripts/release.ts | 93 ++++++----- scripts/set-version.test.ts | 40 +++++ scripts/set-version.ts | 23 ++- test/browser/client-mutate.browser.test.ts | 12 +- test/browser/client.browser.test.ts | 12 +- test/browser/query-identity.browser.test.ts | 4 +- test/browser/react.browser.test.tsx | 12 +- test/local/conformance-catalog.ts | 6 +- test/local/operation-catalog.ts | 4 +- test/local/ops.ts | 31 ++-- test/local/reef.integration.ts | 150 ++++++++++++++++++ test/local/todo-operations.ts | 34 ++++ tsconfig.json | 4 + website/astro.config.mjs | 18 +-- website/package.json | 3 +- website/scripts/lib/snippets.test.ts | 4 +- website/scripts/local-search.ts | 27 ++++ .../docs/best-practices/query-performance.mdx | 6 +- .../src/content/docs/concepts/data-model.mdx | 6 +- .../src/content/docs/concepts/glossary.mdx | 2 +- .../src/content/docs/concepts/time-travel.mdx | 5 +- .../docs/getting-started/quickstart.mdx | 4 +- website/src/content/docs/guides/catalog.mdx | 8 +- website/src/content/docs/guides/queries.mdx | 29 ++-- website/src/content/docs/guides/sign-in.mdx | 24 ++- website/src/content/docs/guides/ssr.mdx | 13 +- website/src/content/docs/guides/workers.mdx | 54 +++---- website/src/content/docs/index.mdx | 4 +- .../src/content/docs/reference/client-api.mdx | 24 ++- .../src/content/docs/reference/operations.mdx | 30 +++- .../content/docs/reference/query-language.mdx | 50 ++++-- website/src/content/docs/reference/react.mdx | 2 +- website/src/content/docs/reference/schema.mdx | 19 +-- 110 files changed, 1394 insertions(+), 911 deletions(-) delete mode 100644 examples/kv-style/operations.ts create mode 100644 examples/reef/src/app/MutationFeedback.tsx create mode 100644 examples/reef/src/app/entities.ts create mode 100644 examples/reef/src/infra/web-worker.ts create mode 100644 examples/reef/src/infra/web.ts create mode 100644 packages/ramose/src/better-auth/client.ts create mode 100644 packages/ramose/test/browser-bundles.test.ts create mode 100644 packages/ramose/test/db/public-query.test.ts create mode 100644 packages/ramose/test/internal/core/bytes.test.ts create mode 100644 packages/ramose/test/internal/core/json.test.ts create mode 100644 scripts/lib/version.test.ts create mode 100644 scripts/lib/version.ts create mode 100644 scripts/set-version.test.ts create mode 100644 test/local/reef.integration.ts create mode 100644 test/local/todo-operations.ts create mode 100644 website/scripts/local-search.ts diff --git a/.github/workflows/reef-preview.yml b/.github/workflows/reef-preview.yml index 41ee92119..a4321eab5 100644 --- a/.github/workflows/reef-preview.yml +++ b/.github/workflows/reef-preview.yml @@ -1,28 +1,3 @@ -# Per-PR preview of the Reef demo (examples/reef/): deploy an isolated Alchemy -# stage `pr-` on PRs that change what Reef is, post/update a PR -# comment with the SPA URL, and destroy the stage when the PR closes. -# -# Isolation from production is the whole point. `REEF_DOMAIN` is production -# (see reef-publish.yml + examples/reef/src/infra/domain.ts): set, it pins -# D1 / R2 / Worker names to `ramose-reef-*` and attaches reef.ramose.ai. -# This workflow never sets it. Preview gets Alchemy-generated names that -# embed the `pr-` stage, and teardown destroys that stage only. -# -# URL shape: without a pinned hostname the peer URL exists only *after* a -# deploy, so previews are two passes — deploy once, bake the peer origin -# into the SPA (`--define REEF_PEER_ORIGIN=...`), deploy again so the built -# assets ship on the auth Worker. The human-facing URL is that auth Worker -# (workers.dev). The peer's CORS allowlist already carries the auth Worker's -# origin (examples/reef/src/infra/resources.ts). -# -# State: Alchemy's Cloudflare state store, per `pr-` stage (see -# reef-publish.yml) — durable and account private, so teardown reads real -# state instead of a possibly-evicted cache, and there is no `.alchemy/` to -# carry between runs. -# -# Credentials: GitHub Environment "Development". The token needs Account / -# D1 / Edit (Better Auth's database) plus the state-store scopes named in -# reef-publish.yml. name: Reef preview on: @@ -79,29 +54,21 @@ jobs: bun run scripts/build-packages.ts - # Pass 1: mint (or update) the isolated Workers + D1 + R2. The peer - # URL is only knowable after this. + bun build examples/reef/index.html --outdir examples/reef/dist --minify bun alchemy deploy examples/reef/alchemy.run.ts \ --stage "$STAGE" --yes --adopt | tee deploy.log PEER_URL="$(extract_url peerUrl)" - API_URL="$(extract_url apiUrl)" - if [ -z "$PEER_URL" ] || [ -z "$API_URL" ]; then - echo "::error::Deploy finished without peerUrl/apiUrl in the output." + APP_URL="$(extract_url appUrl)" + if [ -z "$PEER_URL" ] || [ -z "$APP_URL" ]; then + echo "::error::Deploy finished without appUrl/peerUrl in the output." exit 1 fi - # Pass 2: bake the peer origin into the SPA and ship it as the - # auth Worker's assets. - bun build examples/reef/index.html --outdir examples/reef/dist \ - --minify --define "REEF_PEER_ORIGIN=\"$PEER_URL\"" - bun alchemy deploy examples/reef/alchemy.run.ts \ - --stage "$STAGE" --yes --adopt | tee deploy.log - - echo "url=$API_URL" >> "$GITHUB_OUTPUT" + echo "url=$APP_URL" >> "$GITHUB_OUTPUT" echo "peer_url=$PEER_URL" >> "$GITHUB_OUTPUT" echo "### Reef preview" >> "$GITHUB_STEP_SUMMARY" - echo "$API_URL" >> "$GITHUB_STEP_SUMMARY" + echo "$APP_URL" >> "$GITHUB_STEP_SUMMARY" - name: Smoke-test the preview env: PREVIEW_URL: ${{ steps.deploy.outputs.url }} diff --git a/.github/workflows/reef-publish.yml b/.github/workflows/reef-publish.yml index 484787a8b..320663d69 100644 --- a/.github/workflows/reef-publish.yml +++ b/.github/workflows/reef-publish.yml @@ -75,7 +75,7 @@ jobs: REEF_DOMAIN: ${{ vars.REEF_DOMAIN || 'reef.ramose.ai' }} run: | fail=0 - # The SPA, from the auth Worker's assets. + # The SPA, from the public Worker’s assets. code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 "https://$REEF_DOMAIN/") [ "$code" = "200" ] || { echo "::error::SPA returned $code"; fail=1; } # Better Auth's key set — what the peer verifies tokens against. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94a24a059..329d1da06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,9 @@ jobs: - name: Typecheck run: bun run typecheck + - name: Install Chromium and system dependencies + run: bunx playwright install --with-deps chromium + - name: Test run: bun run test @@ -53,9 +56,9 @@ jobs: RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" != "false" ]; then - bun run scripts/release.ts --skip-tests --dry-run + bun run scripts/release.ts --skip-tests --dry-run --no-tag --no-push else - bun run scripts/release.ts --skip-tests + bun run scripts/release.ts --skip-tests --no-tag --no-push fi - name: Create GitHub Release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 530365f18..7befe6f1b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,8 +30,12 @@ bun run test:browser bun run test:local bun run test bun run build +bun run check ``` +`bun run test` runs unit, browser, and local integration tests. `bun run check` +also checks types and documentation, builds the package, and validates its exports. + Use the test lane that owns the behavior: | Lane | Command | Scope | @@ -69,10 +73,12 @@ The peer listens on `http://localhost:1337`. Run e2e tests against it with: RAMOSE_URL=http://localhost:1337 bun run test:e2e ``` -`bun run dev:graph` runs the offline-first browser client's example instead: a -peer on `http://localhost:1341` and the identity Worker that mints its bearers -on `http://localhost:1342`. `bun run test:browser` starts and stops that same -stack itself, so a browser test can drive the example against a real peer. +`bun run dev:reef` starts the Reef issue tracker's database and identity Workers. +Run `bun run dev:reef:ui` in a second terminal for its React app. See +[the Reef README](examples/reef/README.md) for ports and deployment details. + +Browser tests serve recorded replication frames with Vitest and exercise real +Chromium APIs. They do not need a separately running example. ## Cloudflare e2e diff --git a/bun.lock b/bun.lock index fe0b90482..56ea05a20 100644 --- a/bun.lock +++ b/bun.lock @@ -70,6 +70,7 @@ }, "devDependencies": { "@webgpu/types": "^0.1.72", + "pagefind": "1.5.2", }, }, }, diff --git a/e2e-ops.ts b/e2e-ops.ts index 72f0e2a52..3c53654ee 100644 --- a/e2e-ops.ts +++ b/e2e-ops.ts @@ -1,3 +1,4 @@ +import * as InternalOperations from "./packages/ramose/src/db/Operation.ts"; import * as Schema from "effect/Schema"; import * as Ramose from "ramose/db"; @@ -13,10 +14,10 @@ const ReefIssue = Ramose.Entity("issue", { title: Ramose.string(), status: Ramose.string(), rank: Ramose.float(), - creator: Ramose.Ref(ReefUser), + creator: Ramose.ref(ReefUser), }); -export const addSession = Ramose.Operation( +export const addSession = InternalOperations.Operation( "e2e/add-session", { input: Schema.Struct({ name: Schema.String, n: Schema.Finite }), @@ -30,7 +31,7 @@ export const addSession = Ramose.Operation( }, ); -export const addReefUser = Ramose.Operation( +export const addReefUser = InternalOperations.Operation( "e2e/add-reef-user", { input: Schema.Struct({ name: Schema.String }), @@ -42,7 +43,7 @@ export const addReefUser = Ramose.Operation( }, ); -export const addReefIssue = Ramose.Operation( +export const addReefIssue = InternalOperations.Operation( "e2e/add-reef-issue", { input: Schema.Struct({ @@ -64,13 +65,13 @@ export const addReefIssue = Ramose.Operation( }, ); -export const moveReefIssue = Ramose.Operation.patch( +export const moveReefIssue = InternalOperations.Operation.patch( "e2e/move-reef-issue", ReefIssue, ["status", "rank"], ); -export const operations = Ramose.Operations({ +export const operations = InternalOperations.Operations({ addSession, addReefUser, addReefIssue, diff --git a/examples/kv-style/operations.ts b/examples/kv-style/operations.ts deleted file mode 100644 index 1429f3d49..000000000 --- a/examples/kv-style/operations.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as Schema from "effect/Schema"; -import * as Ramose from "ramose/db"; -import { Movies, User } from "./schema.ts"; - -export const addUser = Ramose.Operation( - "user/add", - { - input: Schema.Struct({ name: Schema.String }), - output: Schema.Struct({}), - schema: Movies, - doc: "Create a user by name", - }, - (op, input) => { - op.put(User, { name: input.name }); - return {}; - }, -); - -export const operations = Ramose.defineOperations(Movies, { addUser }); diff --git a/examples/kv-style/schema.ts b/examples/kv-style/schema.ts index ba0563b7f..735e0cd0e 100644 --- a/examples/kv-style/schema.ts +++ b/examples/kv-style/schema.ts @@ -1,7 +1,19 @@ +import * as S from "effect/Schema"; import * as Ramose from "ramose/db"; export const User = Ramose.Entity("user", { name: Ramose.Field.unique(Ramose.string(), "upsert"), +}, { + operations: (Operation) => ({ + create: Operation({ + self: false, + input: S.Struct({ name: S.String }), + output: S.Struct({ id: Ramose.EntityId }), + run(op, { name }) { + return { id: op.create({ name }) }; + }, + }), + }), }); export const Movies = Ramose.Schema("kv-style", { user: User }); diff --git a/examples/reef/README.md b/examples/reef/README.md index 0f1002b1f..f94d9ea68 100644 --- a/examples/reef/README.md +++ b/examples/reef/README.md @@ -1,9 +1,8 @@ # Reef -The flagship Ramose demo: a Linear-style, multi-tenant issue tracker where -**every workspace is its own Ramose database**, reached by walking a deployed -graph from one configured root. Better Auth is the identity plane; membership -data in the root database is the tenancy boundary; the offline-first +The flagship Ramose demo: a Linear-style, multi-tenant issue tracker in one +Ramose database. Better Auth supplies identity; workspace membership and +reference-based read policy isolate each tenant's data. The offline-first `ramose/client` and `ramose/react` render the board. ## Run it @@ -22,7 +21,7 @@ CORS or baked URLs are involved. ## The architecture ``` -auth Worker (:1338) Better Auth on D1: sign-in, JWKS, +public Web Worker → auth Worker (:1338): Better Auth on D1, sign-in, JWKS, POST /api/auth/ramose/token → 15-minute JWT │ (class "user", attrs { name, email }) └── JWKS ──► Ramose peer (:1337) @@ -30,9 +29,11 @@ auth Worker (:1338) Better Auth on D1: sign-in, JWKS, Transactor/QueryReplica DOs, R2 ``` -The auth Worker never talks to the peer, so the resource graph is a DAG: the -peer's env needs the auth Worker's JWKS (a service binding deployed, a URL in -dev), and the auth Worker needs nothing back. +The public Web Worker routes `/api/*` to the private auth Worker and `/db/*` +to the peer through service bindings. Its own URL is a runtime binding, so +preview bundles need no generated URLs. The +peer needs the auth Worker's JWKS through a service binding, and the auth +Worker needs nothing back. Identity is deployment-global: every signed-in account mints the same class (`user`), and the JWT carries no database or role. What a principal can reach @@ -57,8 +58,9 @@ is data: | `src/domain/queries.ts` | the queries the app and tests share | | `src/domain/rank.ts` | fractional ranking — a drag writes one `:issue/rank` double | | `src/domain/shared.ts` | auth config, ports, and the workspace slug rules | -| `src/infra/api.ts` | the auth Worker: Better Auth (jwt + `ramose/better-auth` mint plugins) on D1, serving the built SPA as assets | +| `src/infra/api.ts` | the auth Worker: Better Auth (jwt + `ramose/better-auth` mint plugins) on D1, reachable through service bindings | | `src/infra/resources.ts` / `peer.ts` | the Ramose peer with the catalog deployed onto it | +| `src/infra/web.ts` / `web-worker.ts` | the public SPA and same-origin gateway | | `src/infra/domain.ts` | `REEF_DOMAIN` — production naming and routing | | `src/app/` | the React SPA on `ramose/react` | | `dev.ts` | the SPA dev server: Bun serve + `/api` and `/db` proxies | @@ -66,36 +68,27 @@ is data: ## Deploying to real Cloudflare -The live demo is **https://reef.ramose.ai**, published by -`.github/workflows/reef-publish.yml` on every merge to master. One hostname -serves both Workers: - -| path | Worker | how | -|---|---|---| -| `/db/*` | the Ramose peer | a zone route (`src/infra/resources.ts`) | -| everything else | the auth Worker | a custom domain (`src/infra/api.ts`), assets-first | - -`REEF_DOMAIN` is what turns all of that on (see `src/infra/domain.ts`). Set, -it attaches the domain and the route and pins the physical names of the -Workers, the D1 database and the R2 bucket; unset, a deploy is an ordinary -personal stage with generated names. +The public Web Worker serves the SPA and routes authentication and database +requests through native service bindings. The auth Worker has no public +`workers.dev` endpoint. Set `REEF_DOMAIN` to attach the public custom domain +and pin resource names; leave it unset for an isolated preview stage. ```sh bun run build:reef -REEF_DOMAIN=reef.ramose.ai bun alchemy deploy examples/reef/alchemy.run.ts --stage prod --adopt +bun alchemy deploy examples/reef/alchemy.run.ts --stage preview ``` -Without `REEF_DOMAIN` the SPA needs the peer's origin baked in, because the -auth Worker and the peer sit on different `workers.dev` hosts: deploy once, -then rebuild with `--define 'REEF_PEER_ORIGIN=""'` and deploy again — -`.github/workflows/reef-preview.yml` does exactly this for every PR. +One build and one deployment work for both previews and production. The stack +returns `appUrl` for the browser and `peerUrl` for direct peer diagnostics. + +Run `bun run test:reef` to exercise the production bundle in Chromium against +local Workers and D1, including credentials, workspace writes, and rejection +feedback. The API token needs the `todos` e2e permissions (Workers Scripts, R2 — see CONTRIBUTING.md) **plus `Account / D1 / Edit`** for the Better Auth database, plus zone access for the hostname. -One thing the local run cannot show you, handled in `src/infra/resources.ts`: -deployed, the peer reaches the auth Worker's JWKS through the `AUTH` -**service binding** (`jwksService`), not its public URL — Cloudflare answers -a Worker→Worker subrequest on `workers.dev` with error 1042 instead of the -key set, and every token would 401. +The peer obtains JWKS through the private `AUTH` service binding. The public +Worker supplies its runtime URL to the auth Worker so origin validation and +cookies use the browser’s address even behind local forwarding. diff --git a/examples/reef/alchemy.run.ts b/examples/reef/alchemy.run.ts index 330f5fac5..9dbf8fc79 100644 --- a/examples/reef/alchemy.run.ts +++ b/examples/reef/alchemy.run.ts @@ -3,7 +3,7 @@ import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { Api } from "./src/infra/api.ts"; +import { Web } from "./src/infra/web.ts"; import { Server } from "./src/infra/resources.ts"; export default Alchemy.Stack( @@ -19,8 +19,8 @@ export default Alchemy.Stack( : Cloudflare.state(), }, Effect.gen(function* () { - const api = yield* Api; + const web = yield* Web; const server = yield* Server; - return { apiUrl: api.url, peerUrl: server.url }; + return { appUrl: web.url, peerUrl: server.url }; }), ); diff --git a/examples/reef/src/app/App.tsx b/examples/reef/src/app/App.tsx index 8a3158777..6ac658377 100644 --- a/examples/reef/src/app/App.tsx +++ b/examples/reef/src/app/App.tsx @@ -1,9 +1,9 @@ +import { useMutationFeedback, MutationFeedbackProvider } from "./MutationFeedback.tsx"; import { Suspense, useEffect, useMemo, useState } from "react"; import { RamoseProvider, useSyncState } from "ramose/react"; import { authClient, clearCachedUser, - dropToken, readCachedUser, writeCachedUser, type CachedUser, @@ -59,10 +59,11 @@ const Shell = (props: { readonly userName: string; readonly onSignOut: () => void; }) => { + const track = useMutationFeedback(); const route = useRoute(); useEffect(() => { - props.client.open().mutate.ensureMe({}).queued.catch(() => undefined); - }, [props.client]); + track(props.client.open().mutate.ensureMe({}), "Initialize account").queued.catch(() => undefined); + }, [props.client, track]); return (
@@ -111,10 +112,11 @@ export const App = () => { } }, [session.data?.user, settledOut]); - const client = useMemo( + const connection = useMemo( () => (userId === undefined ? undefined : openReef(userId)), [userId], ); + const client = connection?.client; useEffect(() => { if (client === undefined) return; return () => { @@ -128,17 +130,17 @@ export const App = () => { : ; } return ( - { - dropToken(userId); + connection?.credentials.clear(); clearCachedUser(); void authClient.signOut().finally(() => { location.hash = ""; location.reload(); }); }} - /> + /> ); }; diff --git a/examples/reef/src/app/MutationFeedback.tsx b/examples/reef/src/app/MutationFeedback.tsx new file mode 100644 index 000000000..dd6105c26 --- /dev/null +++ b/examples/reef/src/app/MutationFeedback.tsx @@ -0,0 +1,55 @@ +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; +import type { Receipt } from "ramose/client"; +import { useReceipt } from "ramose/react"; + +type Notice = { readonly receipt: Receipt; readonly label: string }; +type Track = (receipt: Receipt, label: string) => Receipt; +const Feedback = createContext(undefined); + +export const useMutationFeedback = (): Track => { + const track = useContext(Feedback); + if (track === undefined) throw new Error("Mutation feedback needs its provider"); + return track; +}; + +const MutationNotice = ({ notice, dismiss }: { readonly notice: Notice; readonly dismiss: (id: string) => void }) => { + const state = useReceipt(notice.receipt); + const id = notice.receipt.invocation; + useEffect(() => { + if (state.status !== "committed") return; + const timer = setTimeout(() => dismiss(id), 3000); + return () => clearTimeout(timer); + }, [state.status, dismiss, id]); + const failed = state.status === "failed" || state.status === "rejected"; + const message = state.status === "rejected" ? "This change was rejected and has not been saved" + : state.status === "failed" ? "Could not save this change on this device" + : state.status === "committed" ? "Saved" + : state.status === "queued" ? "Saved on this device; waiting to sync" + : "Saving on this device…"; + return ( +
+ {notice.label}: {message} + +
+ ); +}; + +export const MutationFeedbackProvider = ({ children }: { readonly children: ReactNode }) => { + const [notices, setNotices] = useState([]); + const track = useCallback((receipt, label) => { + setNotices((current) => current.some((item) => item.receipt.invocation === receipt.invocation) + ? current : [...current, { receipt, label }]); + return receipt; + }, []); + const dismiss = useCallback((id: string) => { + setNotices((current) => current.filter((item) => item.receipt.invocation !== id)); + }, []); + return ( + + {children} + + + ); +}; diff --git a/examples/reef/src/app/auth.ts b/examples/reef/src/app/auth.ts index 89089597d..10f0b793c 100644 --- a/examples/reef/src/app/auth.ts +++ b/examples/reef/src/app/auth.ts @@ -1,5 +1,5 @@ import { createAuthClient } from "better-auth/react"; -import { AUTH_BASE_PATH, MINT_PATH } from "../domain/shared.ts"; +import { AUTH_BASE_PATH } from "../domain/shared.ts"; export const authClient = createAuthClient({ baseURL: `${location.origin}${AUTH_BASE_PATH}`, @@ -12,7 +12,6 @@ export type CachedUser = { }; const USER_KEY = "reef:user"; -const bearerKey = (userId: string) => `reef:bearer:${userId}`; const readJson = (key: string): A | undefined => { try { @@ -51,53 +50,3 @@ export const writeCachedUser = (user: CachedUser): void => writeJson(USER_KEY, user); export const clearCachedUser = (): void => drop(USER_KEY); - -type StoredBearer = { - readonly token: string; - readonly exp: number; -}; - -const freshFor = (bearer: StoredBearer | undefined): boolean => - bearer !== undefined && Date.now() < bearer.exp * 1000 - 60_000; - -/** - * The credential for one activation. The stored bearer is presented again - * whenever it is still fresh — and, when the mint endpoint is unreachable, - * even when it is not: presenting the *same* bearer the replica was last - * confirmed under is what lets stored data render before anything reaches - * the network, and an unreachable server cannot refuse it. A reachable mint - * replaces it and the next activation carries the renewal. - */ -export const mintToken = async (userId: string): Promise => { - const stored = readJson(bearerKey(userId)); - if (freshFor(stored)) return stored!.token; - let response: Response; - try { - response = await fetch(`${location.origin}${MINT_PATH}`, { - method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: "{}", - }); - } catch (cause) { - if (stored !== undefined) return stored.token; - throw cause; - } - if (!response.ok) { - throw new Error(`reef: token mint failed with ${response.status}`); - } - const body = (await response.json()) as { - readonly token?: unknown; - readonly exp?: unknown; - }; - if (typeof body.token !== "string" || body.token.length === 0) { - throw new Error("reef: token mint returned no token"); - } - const exp = typeof body.exp === "number" - ? body.exp - : Math.floor(Date.now() / 1000) + 60; - writeJson(bearerKey(userId), { token: body.token, exp }); - return body.token; -}; - -export const dropToken = (userId: string): void => drop(bearerKey(userId)); diff --git a/examples/reef/src/app/components/IssueDetail.tsx b/examples/reef/src/app/components/IssueDetail.tsx index 3733d678e..e1a1b7101 100644 --- a/examples/reef/src/app/components/IssueDetail.tsx +++ b/examples/reef/src/app/components/IssueDetail.tsx @@ -1,3 +1,4 @@ +import { useMutationFeedback } from "../MutationFeedback.tsx"; import { useEffect, useState } from "react"; import type { ClientDatabase } from "ramose/client"; import type { MutationRef } from "ramose/db"; @@ -7,6 +8,7 @@ import { PRIORITIES, PRIORITY_LABELS, type Priority, + Workspace, } from "../../domain/schema.ts"; import type { ReefMutations } from "../ramose.ts"; import { @@ -14,57 +16,23 @@ import { type IssueRow, type Member, type PersonRow, -} from "../screens/BoardScreen.tsx"; +} from "../entities.ts"; type ReefDb = ClientDatabase; -type DetailIssue = IssueRow & { - readonly data: IssueRow["data"] & { - readonly description?: string | undefined; - readonly privateNote?: string | undefined; - readonly labels?: readonly { readonly id: string }[] | undefined; - }; - readonly mutate: IssueRow["mutate"] & { - readonly editIssue: (input: { - title?: string; - description?: string; - }) => unknown; - readonly setPriority: (input: { priority: Priority }) => unknown; - readonly setAssignee: (input: { sub?: string; name?: string }) => unknown; - readonly addLabel: (input: { label: string }) => unknown; - readonly removeLabel: (input: { label: string }) => unknown; - readonly setPrivateNote: (input: { note: string }) => unknown; - readonly deleteIssue: (input: Record) => unknown; - }; -}; - -type CommentRow = { - readonly id: unknown; - readonly data: { - readonly body: string; - readonly at: Date; - readonly author?: { readonly id: string } | undefined; - }; -}; - -type LabelRow = { - readonly id: unknown; - readonly data: { readonly name: string; readonly color: string }; -}; - const LABEL_COLORS = ["#5e6ad2", "#26b5ce", "#4cb782", "#f2c94c", "#eb5757"]; export const IssueDetail = (props: { readonly board: ReefDb; readonly issue: IssueRow; - readonly workspaceId: MutationRef; - readonly workspaceSlug: string; + readonly workspaceId: MutationRef; readonly peopleById: ReadonlyMap; readonly members: readonly Member[]; readonly onClose: () => void; }) => { - const issue = props.issue as DetailIssue; - const issueId = String(issue.id); + const track = useMutationFeedback(); + const issue = props.issue; + const issueId = issue.id; const [title, setTitle] = useState(issue.data.title); const [description, setDescription] = useState(issue.data.description ?? ""); @@ -90,16 +58,16 @@ export const IssueDetail = (props: { const comments = useQuery(issueComments(props.board, issueId), props.board); const labels = useQuery( - boardLabels(props.board, props.workspaceSlug), + boardLabels(props.board, props.workspaceId), props.board, ); const allLabels = labels.status === "ready" || labels.status === "stale" - ? (labels.data as unknown as readonly LabelRow[]) + ? labels.data : []; const attached = new Set((issue.data.labels ?? []).map((label) => label.id)); const commentRows = comments.status === "ready" || comments.status === "stale" - ? (comments.data as unknown as readonly CommentRow[]) + ? comments.data : []; return ( @@ -114,7 +82,7 @@ export const IssueDetail = (props: { }} onBlur={() => { if (dirty.title && title.trim() !== "" && title !== issue.data.title) { - issue.mutate.editIssue({ title: title.trim() }); + track(issue.mutate.editIssue({ title: title.trim() }), "Edit issue"); } setDirty((d) => ({ ...d, title: false })); }} @@ -129,7 +97,7 @@ export const IssueDetail = (props: { ; - -export type IssueRow = { - readonly id: unknown; - readonly data: { - readonly title: string; - readonly status: Status; - readonly priority: string; - readonly rank: number; - readonly assignee?: { readonly id: string } | undefined; - readonly creator?: { readonly id: string } | undefined; - }; - readonly local: { readonly pending: boolean }; - readonly mutate: { - readonly moveIssue: (input: { status: Status; rank: number }) => unknown; - }; -}; - -export type PersonRow = { - readonly id: unknown; - readonly data: { - readonly sub: string; - readonly name?: string | undefined; - readonly email?: string | undefined; - }; -}; - -export type Member = { - readonly sub: string; - readonly label: string; -}; - -export const personLabel = (person: PersonRow | undefined): string => - person?.data.name ?? person?.data.email ?? "Someone"; - const Column = (props: { readonly status: Status; readonly issues: readonly IssueRow[]; @@ -144,20 +112,31 @@ const Column = (props: { }; export const BoardScreen = (props: { readonly slug: string }) => { - const root = useDb(); - const board = root as ReefDb; + const db = useDb(); + const result = useSuspenseQuery(workspaces(db), db); + if (result.status === "error") return
{result.error.message}
; + if (result.status !== "ready" && result.status !== "stale") return null; + const workspace = result.data.find((row) => row.data.slug === props.slug); + return workspace === undefined + ?

This workspace is unavailable. Back to workspaces

+ : ; +}; - const rows = useSuspenseQuery(boardIssues(board, props.slug), board); +const WorkspaceBoard = (props: { readonly workspace: EntityHandleFor }) => { + const root = useDb(); + const board = root; + const workspace = props.workspace; + const track = useMutationFeedback(); + const rows = useSuspenseQuery(boardIssues(board, workspace.id), board); const folk = useQuery(people(board), board); - const rootWorkspaces = useQuery(workspaces(root), root); const [selected, setSelected] = useState(undefined); const [membersOpen, setMembersOpen] = useState(false); const issues = rows.status === "ready" || rows.status === "stale" - ? (rows.data as unknown as readonly IssueRow[]) + ? rows.data : []; const persons = folk.status === "ready" || folk.status === "stale" - ? (folk.data as unknown as readonly PersonRow[]) + ? folk.data : []; const peopleById = useMemo( () => new Map(persons.map((person) => [String(person.id), person])), @@ -172,21 +151,11 @@ export const BoardScreen = (props: { readonly slug: string }) => { return grouped; }, [issues]); - const workspace = (rootWorkspaces.status === "ready" || - rootWorkspaces.status === "stale") - ? rootWorkspaces.data.find((row) => row.data.slug === props.slug) - : undefined; - - const directory = useQuery(people(root), root); - const rootPersons = directory.status === "ready" || directory.status === "stale" - ? (directory.data as unknown as readonly PersonRow[]) - : []; const memberIds = new Set( - ((workspace?.data as { members?: readonly { id: string }[] } | undefined) - ?.members ?? []).map((member) => member.id), + (workspace?.data.members ?? []).map((member) => member.id), ); - const members: readonly Member[] = rootPersons - .filter((person) => memberIds.has(String(person.id))) + const members: readonly Member[] = persons + .filter((person) => memberIds.has(person.id)) .map((person) => ({ sub: person.data.sub, label: personLabel(person) })); const dropIssue = (issueId: string, status: Status, beforeIndex: number) => { @@ -198,20 +167,19 @@ export const BoardScreen = (props: { readonly slug: string }) => { const at = Math.min(beforeIndex, column.length); const before = column[at - 1]?.data.rank; const after = column[at]?.data.rank; - issue.mutate.moveIssue({ status, rank: rankBetween(before, after) }); + track(issue.mutate.moveIssue({ status, rank: rankBetween(before, after) }), "Move issue"); }; const createIssue = (status: Status, title: string) => { if (workspace === undefined) return; const column = byStatus.get(status) ?? []; const last = column[column.length - 1]?.data.rank; - board.mutate.createIssue({ + track(board.mutate.createIssue({ workspace: workspace.id, - workspaceSlug: props.slug, title, status, rank: rankBetween(last, undefined), - }); + }), "Create issue"); }; const selectedIssue = selected === undefined @@ -221,13 +189,7 @@ export const BoardScreen = (props: { readonly slug: string }) => { return (
-

- {workspace - ? String( - (workspace.data as { label?: string }).label ?? props.slug, - ) - : props.slug} -

+

{workspace.data.label ?? workspace.data.slug}

{rows.status === "stale" && offline copy}