Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 6 additions & 39 deletions .github/workflows/reef-preview.yml
Original file line number Diff line number Diff line change
@@ -1,28 +1,3 @@
# Per-PR preview of the Reef demo (examples/reef/): deploy an isolated Alchemy
# stage `pr-<number>` 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-<n>` 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-<n>` 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:
Expand Down Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/reef-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 Workers 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.
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
14 changes: 10 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 7 additions & 6 deletions e2e-ops.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 }),
Expand All @@ -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 }),
Expand All @@ -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({
Expand All @@ -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,
Expand Down
19 changes: 0 additions & 19 deletions examples/kv-style/operations.ts

This file was deleted.

12 changes: 12 additions & 0 deletions examples/kv-style/schema.ts
Original file line number Diff line number Diff line change
@@ -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 });

Expand Down
57 changes: 25 additions & 32 deletions examples/reef/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,17 +21,19 @@ 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)
one deployed catalog, root database "reef",
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
Expand All @@ -57,45 +58,37 @@ 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 |
| `test/` | policy and catalog shape, slug rules, ranking — unit tests |

## 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="<peerUrl>"'` 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.
6 changes: 3 additions & 3 deletions examples/reef/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 };
}),
);
Loading
Loading