mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
add AGENTS.md files
This commit is contained in:
parent
bf035ad0ee
commit
9fb3581937
187
AGENTS.md
Normal file
187
AGENTS.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# AGENTS.md
|
||||
|
||||
This file provides guidance to AI coding agents when working with code in this repository. Each component directory carries its own AGENTS.md; read it before working in that component.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
**Leave no containers running.** If you start a stack or a container while
|
||||
working — `make start`, `make dev`, `make prod-run`, a bare `docker compose
|
||||
up`, a one-off `docker run` — stop it before you finish, including when the
|
||||
task failed or you are interrupted. The `make e2e-*` targets tear their own
|
||||
stack down; anything you started by hand is yours to stop. A stack left up
|
||||
holds ports, burns battery and silently changes what the next command sees.
|
||||
The exception is a stack that was already running when you started, or one
|
||||
the user asked you to leave up.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Comments Serve the Reader, Not the Requester
|
||||
|
||||
**A comment explains the code. It never answers the prompt.**
|
||||
|
||||
The reader has never seen the conversation that produced the change, and
|
||||
never will. Write only what they need to work on the code in front of them.
|
||||
|
||||
- **Never comment a decision to whoever asked for it.** No "deliberately
|
||||
without X", no "as requested", no note explaining why something is absent,
|
||||
removed, or named the way it is. Code says what it does; a comment is for
|
||||
what the code cannot say.
|
||||
- **Never narrate the change.** A comment describes the code as it stands,
|
||||
not what it used to be or what moved where. That is what git is for.
|
||||
- **Keep it to a line or two.** A paragraph of justification means either
|
||||
the code needs the work, or the comment is arguing with a reviewer who
|
||||
will never read it.
|
||||
- Comment a genuine trap: a non-obvious invariant, an upstream bug, an
|
||||
ordering that looks arbitrary and is not.
|
||||
|
||||
The test: would this still make sense, and still earn its lines, to someone
|
||||
reading the file a year from now with no idea what was asked?
|
||||
|
||||
The same applies to the prose in these AGENTS.md files — write the rule, not
|
||||
the story of how it came up.
|
||||
|
||||
## 5. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
## 6. Keep This File in Sync
|
||||
|
||||
**If a request contradicts AGENTS.md, surface it. If the rule changes, update the file.**
|
||||
|
||||
- When the user asks for something that contradicts this file (or a nested AGENTS.md), don't silently comply and don't silently refuse — name the conflict and ask which side wins.
|
||||
- If the user confirms the new direction, update the affected AGENTS.md section as part of the same task, so the documentation never drifts from how the code actually works.
|
||||
|
||||
---
|
||||
|
||||
## Repository map
|
||||
|
||||
**Oxynote** — collaborative documentation product. One repo, four buildable components:
|
||||
|
||||
- `web/` — Nuxt 4 + Vue 3 frontend; ships as both a web app (SSR) and an Electron desktop app. Own **pnpm** workspace. Details: [web/AGENTS.md](web/AGENTS.md).
|
||||
- `server/core/` — Go API server (`oxynote-core`). Go module `github.com/oxynote/oxynote/server/core`. Details: [server/AGENTS.md](server/AGENTS.md).
|
||||
- `server/auth-realtime/` — Node service (`@oxynote/auth-realtime`, **pnpm**) running Better Auth and a Hocuspocus (Yjs) server in one Hono process. Details: [server/auth-realtime/AGENTS.md](server/auth-realtime/AGENTS.md).
|
||||
- `datagen/` — demo-data generator; separate Go module `github.com/oxynote/oxynote/datagen`. Demo/testing only.
|
||||
- `e2e/` — Playwright end-to-end suite (`@oxynote/e2e`, **pnpm**) plus the two docker-compose stacks it drives: the dev stack built from this repo, and the all-in-one image `docker/prod/` produces. Not shipped; it exercises the composed product through a real backend. Every file, script and make target belonging to one stack is named `dev` or `prod` — neither is the unnamed default. Details: [e2e/AGENTS.md](e2e/AGENTS.md).
|
||||
- `scripts/` — helpers the root Makefile calls. `run-quietly.sh` runs a build step with its output held back, replaying the log only if the step fails.
|
||||
- `docker/` — dev docker-compose stack, Caddyfile, `env/` (committed `*.example.env` templates; `make setup` copies them to the gitignored `*.local.env` files the compose stack reads, and `web.example.env` also to `web/.env` for the host dev server and electron builds; `sync-env.sh` reconciles an existing `*.local.env` with its template, since `make setup` only copies when the local file is missing, and `make setup-force` copies over them regardless), `demo/` (demo-data configs for mariadb/postgres).
|
||||
- `docker/prod/` — the production all-in-one image: alpine-based Dockerfile (built via `make prod-build`; core comes from goreleaser), the image's Caddyfile (sibling of `docker/Caddyfile`), the TS launcher (`@oxynote/launcher`, **pnpm**) that validates the flat public `OXYNOTE_*` env, generates internal secrets, and supervises caddy + web + core + auth-realtime, plus the example compose deployment and the local override `make prod-run` layers on it. Details: [docker/prod/AGENTS.md](docker/prod/AGENTS.md).
|
||||
|
||||
**One `.gitignore`, and it lives at the repository root.** Components do not carry their own — a rule for a nested directory is written with its path (`e2e/test-results/`, `web/coverage/`), so every exclusion in the repository is readable in one file.
|
||||
|
||||
## Common commands
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make deps # install web/ + auth-realtime (pnpm) and go module dependencies
|
||||
make setup # deps + web prepare + creates docker/env/*.local.env from templates
|
||||
make setup-force # setup, but *.local.env is rewritten from the templates, values and all
|
||||
make run # build images + run the dev stack in the foreground (ctrl-c stops it)
|
||||
make start # build images + run the dev stack in the background
|
||||
make dev # backend containers + web dev server on the host (hot reload, :3000)
|
||||
make stop # stop the dev stack
|
||||
make check-env # report variables that drifted between *.example.env and *.local.env
|
||||
make sync-env # rewrite *.local.env from the templates, keeping existing values
|
||||
|
||||
make lint # fix lint/format/type issues in web, auth-realtime, core, datagen, e2e, launcher
|
||||
make check-lint # the same gates, verification only
|
||||
|
||||
make prod-build # goreleaser core binary + the all-in-one prod image
|
||||
make prod-run # build the image + run the example compose on :8080, with
|
||||
# a mailpit on :8025 so signup mail is readable
|
||||
make prod-stop # stop it
|
||||
# release-only, run by .github/workflows/release.yml on a tag:
|
||||
# make prod-publish RELEASE_VERSION=1.2.3 -> pushes :latest and :1.2.3
|
||||
|
||||
make e2e-dev # one-shot: build, run playwright, tear the dev stack down
|
||||
make e2e-dev-stack-build # build that stack's images, then iterate with `pnpm test:dev`
|
||||
make e2e-dev-stack-stop # stop it and drop its data
|
||||
|
||||
make e2e-prod # the same suite against the all-in-one image
|
||||
make e2e-prod-stack-build # build that image, then iterate with `pnpm test:prod`
|
||||
make e2e-prod-stack-stop # stop it and drop its data
|
||||
```
|
||||
|
||||
The e2e dev stack listens on `:18080` (and mailpit on `:18025`) and the prod one on `:19080` (`:19025`), so all three stacks run alongside each other rather than fighting for ports.
|
||||
|
||||
Component build/test/qa commands are listed in the nested AGENTS.md files.
|
||||
|
||||
## Cross-component contracts
|
||||
|
||||
- **Front door**: Caddy on host `:8080`. `/core/*` is path-stripped and proxied to core (`:8080`); `/auth-realtime/*` is path-stripped and proxied to auth-realtime (`:8081`, so `/auth-realtime/api/...` and `/auth-realtime/hocuspocus` publicly); everything else goes to the web SSR container. The frontend reaches the core API via `NUXT_PUBLIC_CORE_API_BASE_HTTP_URL` (`…:8080/core`) and auth/realtime via `NUXT_PUBLIC_AUTH_REALTIME_API_BASE_*_URL` (`…:8080/auth-realtime`).
|
||||
- **Trust boundary**: core has four surfaces. `/api/...` requires a session. `/api/x/...` (core) and `/api/internal/...` (auth-realtime) have no auth and must never be exposed by the reverse proxy (the Caddyfile blocks both at the front door) — they exist for service-to-service calls (auth-realtime ↔ core). Four of core's `/api/x` routes are internal *and* session-authed: fork (`POST .../branches`), branch update (`PUT .../branches/{branchId}`), merge (`PUT .../merge`) and branch delete (`DELETE .../branches/{branchId}`) act for a person but are reachable only through auth-realtime, which first flushes the branch's pending Hocuspocus store so core reads what the editors show, forwards the caller's session for core to authorize, and resets the branch's connections after a protection change or a delete. `/api/apps/...` is public and sessionless: GitHub and Slack deliver webhooks and OAuth callbacks there, and every request proves itself with the provider's signature or an encrypted state parameter. `/api/mcp` is the MCP surface: OAuth 2.1 bearer tokens issued by auth-realtime (which is the authorization server via `@better-auth/mcp`), validated per request against auth-realtime's internal MCP session endpoint; the Caddyfile also routes the front-door `/.well-known/oauth-*` discovery documents and `/api/auth/*` OAuth endpoints to auth-realtime.
|
||||
- **Session validation**: core's auth middleware validates sessions by calling auth-realtime's `/api/auth/get-session`; auth-realtime owns the Better Auth schema.
|
||||
- **Yjs invariant**: the Hocuspocus `documentName` is `"<documentId>-<branchIdentifier>"`, split on the first `-`. Never seed or merge one Y.Doc from another with `Y.applyUpdate` — use `replaceYdocContent` (`server/auth-realtime/src/ydocument.ts`). Read the document-storage section of [server/AGENTS.md](server/AGENTS.md) before touching branch content anywhere.
|
||||
- **System writes**: a protected branch refuses every persist but core's own. Core marks the batches it originates with `edit.Client.Apply`'s `system` argument, which only a caller acting for core itself may set, auth-realtime opens the direct connection under that origin, and `onStoreDocument` marks the persist system only while every change in the window came from core. Protection itself is enforced where a write enters the document — `onAuthenticate` makes a connection to a protected branch read-only — because the persist carries whole-document state and could otherwise smuggle an edit it never authorised.
|
||||
- **Env naming**: core reads `OXYNOTE_CORE_*` (via `buildinfo.Getenv`), auth-realtime reads `OXYNOTE_AUTH_REALTIME_*`, the frontend reads `NUXT_PUBLIC_*`.
|
||||
- **`_DSN` vs `_URL`**: a variable holding a connection string with credentials in it ends in `_DSN` — `DB_DSN`, `VALKEY_DSN`, `SMTP_DSN`, `OBJECT_STORAGE_DSN` (public), `SENTRY_DSN`. A variable holding a plain address ends in `_URL`, and any credential it needs is its own variable — `MEILISEARCH_URL` + `_MASTER_KEY`, `CHANGE_DETECTION_URL`/`CHANGEDETECTION_API_URL` + its key, `OBJECT_STORAGE_URL` + key/secret on core's side, plus every address that is not a dependency at all (`PUBLIC_URL`, `BASE_APP_URL`, `TERMS_OF_SERVICE_URL`). The suffix is a claim about whether the value is a secret, so it holds across the trust boundary: the same dependency is never `_DSN` publicly and `_URL` internally.
|
||||
|
||||
## Code style (TS/JS — web and auth-realtime)
|
||||
|
||||
### Comments
|
||||
|
||||
- Only comment **complex implementations, edge cases, and locations that would confuse a future reader** — under the rules in "Comments Serve the Reader, Not the Requester" above, which apply to every language in the repository.
|
||||
- Always use `//` single-line comments. Never use `/* */` — not even for multi-line blocks.
|
||||
- **Never reference AGENTS.md or any other agent-instruction file from a comment.** Comments stand alone for any reader, and AGENTS.md is agent-facing documentation, not part of the codebase's own narrative. Where a comment would say "see AGENTS.md", inline the actual rule or rationale instead. Links to real project docs (READMEs, upstream issues, specs) stay fine.
|
||||
- Wrap comments at 80 characters. Continuation lines also start with `// `.
|
||||
- Within a single comment, the **first sentence starts with a lowercase letter**; every following sentence starts with an uppercase letter. Sentences are still separated with proper punctuation. However, if the first word is the name of a class/function, use the casing of that class/function and don't override it.
|
||||
|
||||
Example:
|
||||
```ts
|
||||
// strip the leading "; " that getCookie always emits. Downstream code
|
||||
// expects a bare cookie pair, not a header-prefix fragment.
|
||||
```
|
||||
|
||||
### Whitespace
|
||||
|
||||
Leave a blank line **before and after each logical block**: function definitions, `if`/`else` branches, `for`/`while` loops, `try`/`catch`, `switch` blocks, etc. The goal is visual separation between distinct units of logic — not blank lines between every statement.
|
||||
|
||||
Go style, Vue conventions, and per-component formatting configs live in the nested AGENTS.md files.
|
||||
188
CLAUDE.md
188
CLAUDE.md
|
|
@ -1,187 +1 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
**Leave no containers running.** If you start a stack or a container while
|
||||
working — `make start`, `make dev`, `make prod-run`, a bare `docker compose
|
||||
up`, a one-off `docker run` — stop it before you finish, including when the
|
||||
task failed or you are interrupted. The `make e2e-*` targets tear their own
|
||||
stack down; anything you started by hand is yours to stop. A stack left up
|
||||
holds ports, burns battery and silently changes what the next command sees.
|
||||
The exception is a stack that was already running when you started, or one
|
||||
the user asked you to leave up.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Comments Serve the Reader, Not the Requester
|
||||
|
||||
**A comment explains the code. It never answers the prompt.**
|
||||
|
||||
The reader has never seen the conversation that produced the change, and
|
||||
never will. Write only what they need to work on the code in front of them.
|
||||
|
||||
- **Never comment a decision to whoever asked for it.** No "deliberately
|
||||
without X", no "as requested", no note explaining why something is absent,
|
||||
removed, or named the way it is. Code says what it does; a comment is for
|
||||
what the code cannot say.
|
||||
- **Never narrate the change.** A comment describes the code as it stands,
|
||||
not what it used to be or what moved where. That is what git is for.
|
||||
- **Keep it to a line or two.** A paragraph of justification means either
|
||||
the code needs the work, or the comment is arguing with a reviewer who
|
||||
will never read it.
|
||||
- Comment a genuine trap: a non-obvious invariant, an upstream bug, an
|
||||
ordering that looks arbitrary and is not.
|
||||
|
||||
The test: would this still make sense, and still earn its lines, to someone
|
||||
reading the file a year from now with no idea what was asked?
|
||||
|
||||
The same applies to the prose in these CLAUDE.md files — write the rule, not
|
||||
the story of how it came up.
|
||||
|
||||
## 5. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
## 6. Keep This File in Sync
|
||||
|
||||
**If a request contradicts CLAUDE.md, surface it. If the rule changes, update the file.**
|
||||
|
||||
- When the user asks for something that contradicts this file (or a nested CLAUDE.md), don't silently comply and don't silently refuse — name the conflict and ask which side wins.
|
||||
- If the user confirms the new direction, update the affected CLAUDE.md section as part of the same task, so the documentation never drifts from how the code actually works.
|
||||
|
||||
---
|
||||
|
||||
## Repository map
|
||||
|
||||
**Oxynote** — collaborative documentation product. One repo, four buildable components:
|
||||
|
||||
- `web/` — Nuxt 4 + Vue 3 frontend; ships as both a web app (SSR) and an Electron desktop app. Own **pnpm** workspace. Details: [web/CLAUDE.md](web/CLAUDE.md).
|
||||
- `server/core/` — Go API server (`oxynote-core`). Go module `github.com/oxynote/oxynote/server/core`. Details: [server/CLAUDE.md](server/CLAUDE.md).
|
||||
- `server/auth-realtime/` — Node service (`@oxynote/auth-realtime`, **pnpm**) running Better Auth and a Hocuspocus (Yjs) server in one Hono process. Details: [server/auth-realtime/CLAUDE.md](server/auth-realtime/CLAUDE.md).
|
||||
- `datagen/` — demo-data generator; separate Go module `github.com/oxynote/oxynote/datagen`. Demo/testing only.
|
||||
- `e2e/` — Playwright end-to-end suite (`@oxynote/e2e`, **pnpm**) plus the two docker-compose stacks it drives: the dev stack built from this repo, and the all-in-one image `docker/prod/` produces. Not shipped; it exercises the composed product through a real backend. Every file, script and make target belonging to one stack is named `dev` or `prod` — neither is the unnamed default. Details: [e2e/CLAUDE.md](e2e/CLAUDE.md).
|
||||
- `scripts/` — helpers the root Makefile calls. `run-quietly.sh` runs a build step with its output held back, replaying the log only if the step fails.
|
||||
- `docker/` — dev docker-compose stack, Caddyfile, `env/` (committed `*.example.env` templates; `make setup` copies them to the gitignored `*.local.env` files the compose stack reads, and `web.example.env` also to `web/.env` for the host dev server and electron builds; `sync-env.sh` reconciles an existing `*.local.env` with its template, since `make setup` only copies when the local file is missing, and `make setup-force` copies over them regardless), `demo/` (demo-data configs for mariadb/postgres).
|
||||
- `docker/prod/` — the production all-in-one image: alpine-based Dockerfile (built via `make prod-build`; core comes from goreleaser), the image's Caddyfile (sibling of `docker/Caddyfile`), the TS launcher (`@oxynote/launcher`, **pnpm**) that validates the flat public `OXYNOTE_*` env, generates internal secrets, and supervises caddy + web + core + auth-realtime, plus the example compose deployment and the local override `make prod-run` layers on it. Details: [docker/prod/CLAUDE.md](docker/prod/CLAUDE.md).
|
||||
|
||||
**One `.gitignore`, and it lives at the repository root.** Components do not carry their own — a rule for a nested directory is written with its path (`e2e/test-results/`, `web/coverage/`), so every exclusion in the repository is readable in one file.
|
||||
|
||||
## Common commands
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make deps # install web/ + auth-realtime (pnpm) and go module dependencies
|
||||
make setup # deps + web prepare + creates docker/env/*.local.env from templates
|
||||
make setup-force # setup, but *.local.env is rewritten from the templates, values and all
|
||||
make run # build images + run the dev stack in the foreground (ctrl-c stops it)
|
||||
make start # build images + run the dev stack in the background
|
||||
make dev # backend containers + web dev server on the host (hot reload, :3000)
|
||||
make stop # stop the dev stack
|
||||
make check-env # report variables that drifted between *.example.env and *.local.env
|
||||
make sync-env # rewrite *.local.env from the templates, keeping existing values
|
||||
|
||||
make lint # fix lint/format/type issues in web, auth-realtime, core, datagen, e2e, launcher
|
||||
make check-lint # the same gates, verification only
|
||||
|
||||
make prod-build # goreleaser core binary + the all-in-one prod image
|
||||
make prod-run # build the image + run the example compose on :8080, with
|
||||
# a mailpit on :8025 so signup mail is readable
|
||||
make prod-stop # stop it
|
||||
# release-only, run by .github/workflows/release.yml on a tag:
|
||||
# make prod-publish RELEASE_VERSION=1.2.3 -> pushes :latest and :1.2.3
|
||||
|
||||
make e2e-dev # one-shot: build, run playwright, tear the dev stack down
|
||||
make e2e-dev-stack-build # build that stack's images, then iterate with `pnpm test:dev`
|
||||
make e2e-dev-stack-stop # stop it and drop its data
|
||||
|
||||
make e2e-prod # the same suite against the all-in-one image
|
||||
make e2e-prod-stack-build # build that image, then iterate with `pnpm test:prod`
|
||||
make e2e-prod-stack-stop # stop it and drop its data
|
||||
```
|
||||
|
||||
The e2e dev stack listens on `:18080` (and mailpit on `:18025`) and the prod one on `:19080` (`:19025`), so all three stacks run alongside each other rather than fighting for ports.
|
||||
|
||||
Component build/test/qa commands are listed in the nested CLAUDE.md files.
|
||||
|
||||
## Cross-component contracts
|
||||
|
||||
- **Front door**: Caddy on host `:8080`. `/core/*` is path-stripped and proxied to core (`:8080`); `/auth-realtime/*` is path-stripped and proxied to auth-realtime (`:8081`, so `/auth-realtime/api/...` and `/auth-realtime/hocuspocus` publicly); everything else goes to the web SSR container. The frontend reaches the core API via `NUXT_PUBLIC_CORE_API_BASE_HTTP_URL` (`…:8080/core`) and auth/realtime via `NUXT_PUBLIC_AUTH_REALTIME_API_BASE_*_URL` (`…:8080/auth-realtime`).
|
||||
- **Trust boundary**: core has four surfaces. `/api/...` requires a session. `/api/x/...` (core) and `/api/internal/...` (auth-realtime) have no auth and must never be exposed by the reverse proxy (the Caddyfile blocks both at the front door) — they exist for service-to-service calls (auth-realtime ↔ core). Four of core's `/api/x` routes are internal *and* session-authed: fork (`POST .../branches`), branch update (`PUT .../branches/{branchId}`), merge (`PUT .../merge`) and branch delete (`DELETE .../branches/{branchId}`) act for a person but are reachable only through auth-realtime, which first flushes the branch's pending Hocuspocus store so core reads what the editors show, forwards the caller's session for core to authorize, and resets the branch's connections after a protection change or a delete. `/api/apps/...` is public and sessionless: GitHub and Slack deliver webhooks and OAuth callbacks there, and every request proves itself with the provider's signature or an encrypted state parameter. `/api/mcp` is the MCP surface: OAuth 2.1 bearer tokens issued by auth-realtime (which is the authorization server via `@better-auth/mcp`), validated per request against auth-realtime's internal MCP session endpoint; the Caddyfile also routes the front-door `/.well-known/oauth-*` discovery documents and `/api/auth/*` OAuth endpoints to auth-realtime.
|
||||
- **Session validation**: core's auth middleware validates sessions by calling auth-realtime's `/api/auth/get-session`; auth-realtime owns the Better Auth schema.
|
||||
- **Yjs invariant**: the Hocuspocus `documentName` is `"<documentId>-<branchIdentifier>"`, split on the first `-`. Never seed or merge one Y.Doc from another with `Y.applyUpdate` — use `replaceYdocContent` (`server/auth-realtime/src/ydocument.ts`). Read the document-storage section of [server/CLAUDE.md](server/CLAUDE.md) before touching branch content anywhere.
|
||||
- **System writes**: a protected branch refuses every persist but core's own. Core marks the batches it originates with `edit.Client.Apply`'s `system` argument, which only a caller acting for core itself may set, auth-realtime opens the direct connection under that origin, and `onStoreDocument` marks the persist system only while every change in the window came from core. Protection itself is enforced where a write enters the document — `onAuthenticate` makes a connection to a protected branch read-only — because the persist carries whole-document state and could otherwise smuggle an edit it never authorised.
|
||||
- **Env naming**: core reads `OXYNOTE_CORE_*` (via `buildinfo.Getenv`), auth-realtime reads `OXYNOTE_AUTH_REALTIME_*`, the frontend reads `NUXT_PUBLIC_*`.
|
||||
- **`_DSN` vs `_URL`**: a variable holding a connection string with credentials in it ends in `_DSN` — `DB_DSN`, `VALKEY_DSN`, `SMTP_DSN`, `OBJECT_STORAGE_DSN` (public), `SENTRY_DSN`. A variable holding a plain address ends in `_URL`, and any credential it needs is its own variable — `MEILISEARCH_URL` + `_MASTER_KEY`, `CHANGE_DETECTION_URL`/`CHANGEDETECTION_API_URL` + its key, `OBJECT_STORAGE_URL` + key/secret on core's side, plus every address that is not a dependency at all (`PUBLIC_URL`, `BASE_APP_URL`, `TERMS_OF_SERVICE_URL`). The suffix is a claim about whether the value is a secret, so it holds across the trust boundary: the same dependency is never `_DSN` publicly and `_URL` internally.
|
||||
|
||||
## Code style (TS/JS — web and auth-realtime)
|
||||
|
||||
### Comments
|
||||
|
||||
- Only comment **complex implementations, edge cases, and locations that would confuse a future reader** — under the rules in "Comments Serve the Reader, Not the Requester" above, which apply to every language in the repository.
|
||||
- Always use `//` single-line comments. Never use `/* */` — not even for multi-line blocks.
|
||||
- **Never reference CLAUDE.md or any other agent-instruction file from a comment.** Comments stand alone for any reader, and CLAUDE.md is agent-facing documentation, not part of the codebase's own narrative. Where a comment would say "see CLAUDE.md", inline the actual rule or rationale instead. Links to real project docs (READMEs, upstream issues, specs) stay fine.
|
||||
- Wrap comments at 80 characters. Continuation lines also start with `// `.
|
||||
- Within a single comment, the **first sentence starts with a lowercase letter**; every following sentence starts with an uppercase letter. Sentences are still separated with proper punctuation. However, if the first word is the name of a class/function, use the casing of that class/function and don't override it.
|
||||
|
||||
Example:
|
||||
```ts
|
||||
// strip the leading "; " that getCookie always emits. Downstream code
|
||||
// expects a bare cookie pair, not a header-prefix fragment.
|
||||
```
|
||||
|
||||
### Whitespace
|
||||
|
||||
Leave a blank line **before and after each logical block**: function definitions, `if`/`else` branches, `for`/`while` loops, `try`/`catch`, `switch` blocks, etc. The goal is visual separation between distinct units of logic — not blank lines between every statement.
|
||||
|
||||
Go style, Vue conventions, and per-component formatting configs live in the nested CLAUDE.md files.
|
||||
@AGENTS.md
|
||||
|
|
|
|||
|
|
@ -127,6 +127,14 @@ stack on `19080` (`19025`).
|
|||
deployment. See [docker/prod/README.md](docker/prod/README.md).
|
||||
- `scripts/` — helpers the root Makefile calls.
|
||||
|
||||
## AI coding agents
|
||||
|
||||
Instructions for coding agents live in `AGENTS.md` at the repository root
|
||||
and in each component directory; the root file maps the components and
|
||||
links their files. Every `CLAUDE.md` next to one is a one-line import that
|
||||
points Claude Code at the `AGENTS.md` beside it — edit `AGENTS.md`, never
|
||||
the shim.
|
||||
|
||||
## Adding a data source
|
||||
|
||||
Decide first what you are adding. If the new type is compatible with an
|
||||
|
|
|
|||
161
docker/prod/AGENTS.md
Normal file
161
docker/prod/AGENTS.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# AGENTS.md
|
||||
|
||||
Guidance for `docker/prod/` — the production all-in-one Docker image.
|
||||
Shared working principles and the TS/JS comment/whitespace rules live in the
|
||||
root [AGENTS.md](../../AGENTS.md).
|
||||
|
||||
## What this is
|
||||
|
||||
One public image (`ghcr.io/oxynote/oxynote`) containing Caddy, the web app
|
||||
(Nuxt SSR), core, and auth-realtime, supervised by `launcher/`
|
||||
(`@oxynote/launcher`, **pnpm**) — a TypeScript program run by node under
|
||||
tini. Postgres is the only required external service; valkey, an
|
||||
S3-compatible store, meilisearch, SMTP and changedetection.io are optional
|
||||
and external too — without a store, objects are kept on the data volume;
|
||||
`docker-compose.example.yaml` is the canonical deployment and `README.md`
|
||||
the operator-facing reference. `docker-compose.local.yaml` is not part of
|
||||
either: it is the override **`make prod-run`** layers on the example to
|
||||
build the image and drive it from a browser here, adding the mailpit the
|
||||
example only describes in a comment — signup requires a verified address, so
|
||||
without a relay the verification link is never more than a log line.
|
||||
|
||||
Build with **`make prod-build`** from the repository root: core's
|
||||
binary must come from goreleaser (never a plain `go build`), so the target
|
||||
builds it first and stages it into `.build/` where the Dockerfile's COPY
|
||||
expects it. A bare `docker build` fails on that COPY by design.
|
||||
|
||||
**`make e2e-prod`** runs the whole e2e suite against the built image, plus
|
||||
the trust-boundary tests that only mean something here. It is the only thing
|
||||
that exercises this image as an operator installs it — the dev stack shares
|
||||
none of its assembly — so run it locally for a change to the Dockerfile, the
|
||||
Caddyfile or the launcher. CI runs it only as a release gate, so a change
|
||||
here gets no pre-merge signal from it. Details in
|
||||
[e2e/AGENTS.md](../../e2e/AGENTS.md).
|
||||
|
||||
**`make prod-publish` is the release build**, run by
|
||||
[release.yml](../../.github/workflows/release.yml) on a tag and by nothing
|
||||
else. It differs from `prod-build` in two ways that matter: core's binary
|
||||
comes from `goreleaser build` rather than a snapshot, so it reports the tag
|
||||
as its version and `production` as its environment instead of `dev`; and the
|
||||
result is pushed to `ghcr.io/oxynote/oxynote` as `latest` and the bare
|
||||
semver — `v1.2.3` publishes `:1.2.3`. Those two tags are the only things the
|
||||
registry gets. `goreleaser build` is deliberate: `goreleaser release` would
|
||||
also publish `ghcr.io/oxynote/core` from the `dockers_v2` block.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **The trust boundary is the loopback bind.** Core's `/api/x/*` and
|
||||
auth-realtime's `/api/internal/*` carry no authentication. Inside the
|
||||
container core (8180), auth-realtime (8181), and web (3000) bind
|
||||
`127.0.0.1`; only caddy listens on the wildcard (8080). The ports are
|
||||
constants in `launcher/src/mapping.ts`, the internal address variables are
|
||||
rejected by the launcher's unknown-variable guard, and the supervisor
|
||||
dials the container's own external addresses at boot and refuses to serve
|
||||
if anything accepts. Changing any of this is a security decision.
|
||||
[e2e/tests/prod-trust-boundary.test.ts](../../e2e/tests/prod-trust-boundary.test.ts)
|
||||
asserts it from the outside — a sibling container on the private network
|
||||
dialling each internal port, and the front door refusing every spelling of
|
||||
the blocked prefixes — so the boot gate is not the only thing standing
|
||||
between a regression and a release.
|
||||
- **`Caddyfile` is a sibling of [docker/Caddyfile](../Caddyfile)**: same
|
||||
routes, same `/core/api/x/*` and `/auth-realtime/api/internal/*` 403
|
||||
blocks — only the upstreams differ. Change the two files together.
|
||||
- **The external namespace is minimal and vendor-neutral.** Operators see
|
||||
only the flat `OXYNOTE_*` variables declared in `launcher/src/env.ts`:
|
||||
derive from shared values (one `OXYNOTE_PUBLIC_URL`, DSN-style URLs
|
||||
carrying credentials) instead of adding variables, generate secrets
|
||||
instead of exposing them, and never leak an internal dependency's name
|
||||
into a public variable. Any undeclared `OXYNOTE_*` variable is a boot
|
||||
error.
|
||||
- **Secrets precedence** (`launcher/src/secrets.ts`): explicit env override
|
||||
wins and is never persisted → existing volume file → generate
|
||||
(`crypto.randomBytes`) + persist 0600. The data-source encryption key is
|
||||
unrotatable — never weaken this path.
|
||||
- **Sentry DSNs are build-time only** (esbuild `--define` from Dockerfile
|
||||
ARGs). No runtime DSN variables; `OXYNOTE_CRASH_REPORTING_DISABLED` is the
|
||||
only runtime switch. There are four: web, core and auth-realtime get
|
||||
theirs passed down as child env, and the launcher keeps its own
|
||||
(`bakedLauncherSentryDsn`) because it reports for itself. That fourth one
|
||||
is not redundant — it covers what no child can report, a child killed
|
||||
outright and a boot that fails before any child exists. The launcher reads
|
||||
the disable switch straight from `process.env` rather than from `Config`,
|
||||
since an invalid environment is one of the failures worth reporting and
|
||||
`loadConfig` throws on it.
|
||||
- **Crash policy: die, don't restart.** Any child exit tears the rest down
|
||||
and exits the container nonzero; restarts belong to the container runtime.
|
||||
- **Every line the container emits is a JSON record with a `service`
|
||||
field** naming the process that wrote it: `launcher`, `core`,
|
||||
`auth-realtime`, `web`, `caddy`.
|
||||
- **`service` is the only thing the launcher adds to a child's line.**
|
||||
`childRecord` ([logging.ts](launcher/src/logging.ts)) merges it into the
|
||||
object the child logged and changes nothing else, so core's `level` and
|
||||
caddy's `ts` survive as their own keys; the launcher's own level and
|
||||
timestamp would describe the relay rather than the event. A line that is
|
||||
not a JSON object becomes `{msg, service}`. `service` is written last, so
|
||||
it wins over a child that claims a different one.
|
||||
- **The launcher's own events go through pino** at a fixed info level:
|
||||
`OXYNOTE_LOG_LEVEL` governs the services, not the container's account of
|
||||
its own boot. Both paths share one open stdout, so records reach the
|
||||
stream in write order.
|
||||
- **Each stream gets its own splitter**, since a line is only whole within
|
||||
the stream it arrived on; `mute` drops what a child prints with no way to
|
||||
switch it off (nitro's loopback listen banner).
|
||||
- **Supply chain**: every `FROM` is pinned by digest; caddy and tini are
|
||||
downloaded at pinned versions and checksum-verified. Caddy is Apache-2.0
|
||||
(its LICENSE ships in the image at `/oxynote/licenses/caddy/`); the name
|
||||
"Caddy" is trademarked — bundle it unmodified and unrebranded.
|
||||
|
||||
## launcher/
|
||||
|
||||
Follows [server/auth-realtime](../../server/auth-realtime/AGENTS.md)'s
|
||||
conventions: tabs-at-8 prettier, no semicolons, explicit `.js` import
|
||||
extensions, type-aware eslint, knip, vitest with concurrent tests and the
|
||||
context-local `expect`. `src/index.ts` is the only module with side effects
|
||||
(env, filesystem, spawning, signals) and is excluded from coverage;
|
||||
everything else is a factory taking its dependencies and is fully tested.
|
||||
|
||||
```sh
|
||||
cd docker/prod/launcher
|
||||
pnpm run qa # check-lint + test
|
||||
pnpm run build:bundle # esbuild bundle -> dist/launcher.mjs; the SENTRY_*_DSN
|
||||
# env vars become baked defines
|
||||
```
|
||||
|
||||
The runtime base is pinned alpine with alpine's own nodejs package — the
|
||||
v24 line built against shared system libraries, about half the size of
|
||||
the official node binary — plus `icu-data-full` so `Intl` formats every
|
||||
locale during SSR. The image ships **no node_modules**:
|
||||
auth-realtime and the launcher are single esbuild bundles (sentry is
|
||||
folded into both — require-hook auto-instrumentation is lost, error
|
||||
capture kept) and the web server is nitro's terser-minified
|
||||
self-contained output (`NITRO_MINIFY=true`). With no native addons the
|
||||
musl runtime cannot clash with anything, and the web builder fails the
|
||||
build if a `*.node` file ever lands in the output.
|
||||
|
||||
**Both bundles carry a `createRequire` banner**, and it is what makes
|
||||
folding sentry in possible at all: its opentelemetry dependencies still
|
||||
`require()` node builtins at runtime, and an `--format=esm` bundle has no
|
||||
`require` to give them — esbuild's shim throws `Dynamic require of "util"
|
||||
is not supported` on the first import, before any of this code runs. The
|
||||
banner hands the shim a real `require` built from `import.meta.url`.
|
||||
Removing it from either package breaks that package's container at
|
||||
startup, and no unit test will tell you: the bundle is only built in the
|
||||
image.
|
||||
|
||||
## References
|
||||
|
||||
Established practice this design follows:
|
||||
|
||||
- Hoppscotch AIO — tini + node launcher spawning caddy/backend/frontend,
|
||||
checksum-verified caddy: <https://github.com/hoppscotch/hoppscotch>
|
||||
(`aio_run.mjs`, `healthcheck.sh`, `prod.Dockerfile`)
|
||||
- Baserow all-in-one — bundled Caddy, single public-URL variable, secret
|
||||
files on the data volume: <https://gitlab.com/baserow/baserow>
|
||||
(`deploy/all-in-one/`)
|
||||
- Appsmith fat container — layered healthcheck, generated secrets on the
|
||||
volume: <https://github.com/appsmithorg/appsmith> (`deploy/docker/`)
|
||||
- grist-omnibus — the anti-pattern reference (no tini, no SIGTERM handling):
|
||||
<https://github.com/gristlabs/grist-omnibus>
|
||||
- tini: <https://github.com/krallin/tini> · Docker multi-process guidance:
|
||||
<https://docs.docker.com/engine/containers/multi-service_container/>
|
||||
- Caddy (Apache-2.0, trademarked name): <https://github.com/caddyserver/caddy>
|
||||
|
|
@ -1,161 +1 @@
|
|||
# CLAUDE.md
|
||||
|
||||
Guidance for `docker/prod/` — the production all-in-one Docker image.
|
||||
Shared working principles and the TS/JS comment/whitespace rules live in the
|
||||
root [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
## What this is
|
||||
|
||||
One public image (`ghcr.io/oxynote/oxynote`) containing Caddy, the web app
|
||||
(Nuxt SSR), core, and auth-realtime, supervised by `launcher/`
|
||||
(`@oxynote/launcher`, **pnpm**) — a TypeScript program run by node under
|
||||
tini. Postgres is the only required external service; valkey, an
|
||||
S3-compatible store, meilisearch, SMTP and changedetection.io are optional
|
||||
and external too — without a store, objects are kept on the data volume;
|
||||
`docker-compose.example.yaml` is the canonical deployment and `README.md`
|
||||
the operator-facing reference. `docker-compose.local.yaml` is not part of
|
||||
either: it is the override **`make prod-run`** layers on the example to
|
||||
build the image and drive it from a browser here, adding the mailpit the
|
||||
example only describes in a comment — signup requires a verified address, so
|
||||
without a relay the verification link is never more than a log line.
|
||||
|
||||
Build with **`make prod-build`** from the repository root: core's
|
||||
binary must come from goreleaser (never a plain `go build`), so the target
|
||||
builds it first and stages it into `.build/` where the Dockerfile's COPY
|
||||
expects it. A bare `docker build` fails on that COPY by design.
|
||||
|
||||
**`make e2e-prod`** runs the whole e2e suite against the built image, plus
|
||||
the trust-boundary tests that only mean something here. It is the only thing
|
||||
that exercises this image as an operator installs it — the dev stack shares
|
||||
none of its assembly — so run it locally for a change to the Dockerfile, the
|
||||
Caddyfile or the launcher. CI runs it only as a release gate, so a change
|
||||
here gets no pre-merge signal from it. Details in
|
||||
[e2e/CLAUDE.md](../../e2e/CLAUDE.md).
|
||||
|
||||
**`make prod-publish` is the release build**, run by
|
||||
[release.yml](../../.github/workflows/release.yml) on a tag and by nothing
|
||||
else. It differs from `prod-build` in two ways that matter: core's binary
|
||||
comes from `goreleaser build` rather than a snapshot, so it reports the tag
|
||||
as its version and `production` as its environment instead of `dev`; and the
|
||||
result is pushed to `ghcr.io/oxynote/oxynote` as `latest` and the bare
|
||||
semver — `v1.2.3` publishes `:1.2.3`. Those two tags are the only things the
|
||||
registry gets. `goreleaser build` is deliberate: `goreleaser release` would
|
||||
also publish `ghcr.io/oxynote/core` from the `dockers_v2` block.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **The trust boundary is the loopback bind.** Core's `/api/x/*` and
|
||||
auth-realtime's `/api/internal/*` carry no authentication. Inside the
|
||||
container core (8180), auth-realtime (8181), and web (3000) bind
|
||||
`127.0.0.1`; only caddy listens on the wildcard (8080). The ports are
|
||||
constants in `launcher/src/mapping.ts`, the internal address variables are
|
||||
rejected by the launcher's unknown-variable guard, and the supervisor
|
||||
dials the container's own external addresses at boot and refuses to serve
|
||||
if anything accepts. Changing any of this is a security decision.
|
||||
[e2e/tests/prod-trust-boundary.test.ts](../../e2e/tests/prod-trust-boundary.test.ts)
|
||||
asserts it from the outside — a sibling container on the private network
|
||||
dialling each internal port, and the front door refusing every spelling of
|
||||
the blocked prefixes — so the boot gate is not the only thing standing
|
||||
between a regression and a release.
|
||||
- **`Caddyfile` is a sibling of [docker/Caddyfile](../Caddyfile)**: same
|
||||
routes, same `/core/api/x/*` and `/auth-realtime/api/internal/*` 403
|
||||
blocks — only the upstreams differ. Change the two files together.
|
||||
- **The external namespace is minimal and vendor-neutral.** Operators see
|
||||
only the flat `OXYNOTE_*` variables declared in `launcher/src/env.ts`:
|
||||
derive from shared values (one `OXYNOTE_PUBLIC_URL`, DSN-style URLs
|
||||
carrying credentials) instead of adding variables, generate secrets
|
||||
instead of exposing them, and never leak an internal dependency's name
|
||||
into a public variable. Any undeclared `OXYNOTE_*` variable is a boot
|
||||
error.
|
||||
- **Secrets precedence** (`launcher/src/secrets.ts`): explicit env override
|
||||
wins and is never persisted → existing volume file → generate
|
||||
(`crypto.randomBytes`) + persist 0600. The data-source encryption key is
|
||||
unrotatable — never weaken this path.
|
||||
- **Sentry DSNs are build-time only** (esbuild `--define` from Dockerfile
|
||||
ARGs). No runtime DSN variables; `OXYNOTE_CRASH_REPORTING_DISABLED` is the
|
||||
only runtime switch. There are four: web, core and auth-realtime get
|
||||
theirs passed down as child env, and the launcher keeps its own
|
||||
(`bakedLauncherSentryDsn`) because it reports for itself. That fourth one
|
||||
is not redundant — it covers what no child can report, a child killed
|
||||
outright and a boot that fails before any child exists. The launcher reads
|
||||
the disable switch straight from `process.env` rather than from `Config`,
|
||||
since an invalid environment is one of the failures worth reporting and
|
||||
`loadConfig` throws on it.
|
||||
- **Crash policy: die, don't restart.** Any child exit tears the rest down
|
||||
and exits the container nonzero; restarts belong to the container runtime.
|
||||
- **Every line the container emits is a JSON record with a `service`
|
||||
field** naming the process that wrote it: `launcher`, `core`,
|
||||
`auth-realtime`, `web`, `caddy`.
|
||||
- **`service` is the only thing the launcher adds to a child's line.**
|
||||
`childRecord` ([logging.ts](launcher/src/logging.ts)) merges it into the
|
||||
object the child logged and changes nothing else, so core's `level` and
|
||||
caddy's `ts` survive as their own keys; the launcher's own level and
|
||||
timestamp would describe the relay rather than the event. A line that is
|
||||
not a JSON object becomes `{msg, service}`. `service` is written last, so
|
||||
it wins over a child that claims a different one.
|
||||
- **The launcher's own events go through pino** at a fixed info level:
|
||||
`OXYNOTE_LOG_LEVEL` governs the services, not the container's account of
|
||||
its own boot. Both paths share one open stdout, so records reach the
|
||||
stream in write order.
|
||||
- **Each stream gets its own splitter**, since a line is only whole within
|
||||
the stream it arrived on; `mute` drops what a child prints with no way to
|
||||
switch it off (nitro's loopback listen banner).
|
||||
- **Supply chain**: every `FROM` is pinned by digest; caddy and tini are
|
||||
downloaded at pinned versions and checksum-verified. Caddy is Apache-2.0
|
||||
(its LICENSE ships in the image at `/oxynote/licenses/caddy/`); the name
|
||||
"Caddy" is trademarked — bundle it unmodified and unrebranded.
|
||||
|
||||
## launcher/
|
||||
|
||||
Follows [server/auth-realtime](../../server/auth-realtime/CLAUDE.md)'s
|
||||
conventions: tabs-at-8 prettier, no semicolons, explicit `.js` import
|
||||
extensions, type-aware eslint, knip, vitest with concurrent tests and the
|
||||
context-local `expect`. `src/index.ts` is the only module with side effects
|
||||
(env, filesystem, spawning, signals) and is excluded from coverage;
|
||||
everything else is a factory taking its dependencies and is fully tested.
|
||||
|
||||
```sh
|
||||
cd docker/prod/launcher
|
||||
pnpm run qa # check-lint + test
|
||||
pnpm run build:bundle # esbuild bundle -> dist/launcher.mjs; the SENTRY_*_DSN
|
||||
# env vars become baked defines
|
||||
```
|
||||
|
||||
The runtime base is pinned alpine with alpine's own nodejs package — the
|
||||
v24 line built against shared system libraries, about half the size of
|
||||
the official node binary — plus `icu-data-full` so `Intl` formats every
|
||||
locale during SSR. The image ships **no node_modules**:
|
||||
auth-realtime and the launcher are single esbuild bundles (sentry is
|
||||
folded into both — require-hook auto-instrumentation is lost, error
|
||||
capture kept) and the web server is nitro's terser-minified
|
||||
self-contained output (`NITRO_MINIFY=true`). With no native addons the
|
||||
musl runtime cannot clash with anything, and the web builder fails the
|
||||
build if a `*.node` file ever lands in the output.
|
||||
|
||||
**Both bundles carry a `createRequire` banner**, and it is what makes
|
||||
folding sentry in possible at all: its opentelemetry dependencies still
|
||||
`require()` node builtins at runtime, and an `--format=esm` bundle has no
|
||||
`require` to give them — esbuild's shim throws `Dynamic require of "util"
|
||||
is not supported` on the first import, before any of this code runs. The
|
||||
banner hands the shim a real `require` built from `import.meta.url`.
|
||||
Removing it from either package breaks that package's container at
|
||||
startup, and no unit test will tell you: the bundle is only built in the
|
||||
image.
|
||||
|
||||
## References
|
||||
|
||||
Established practice this design follows:
|
||||
|
||||
- Hoppscotch AIO — tini + node launcher spawning caddy/backend/frontend,
|
||||
checksum-verified caddy: <https://github.com/hoppscotch/hoppscotch>
|
||||
(`aio_run.mjs`, `healthcheck.sh`, `prod.Dockerfile`)
|
||||
- Baserow all-in-one — bundled Caddy, single public-URL variable, secret
|
||||
files on the data volume: <https://gitlab.com/baserow/baserow>
|
||||
(`deploy/all-in-one/`)
|
||||
- Appsmith fat container — layered healthcheck, generated secrets on the
|
||||
volume: <https://github.com/appsmithorg/appsmith> (`deploy/docker/`)
|
||||
- grist-omnibus — the anti-pattern reference (no tini, no SIGTERM handling):
|
||||
<https://github.com/gristlabs/grist-omnibus>
|
||||
- tini: <https://github.com/krallin/tini> · Docker multi-process guidance:
|
||||
<https://docs.docker.com/engine/containers/multi-service_container/>
|
||||
- Caddy (Apache-2.0, trademarked name): <https://github.com/caddyserver/caddy>
|
||||
@AGENTS.md
|
||||
|
|
|
|||
649
e2e/AGENTS.md
Normal file
649
e2e/AGENTS.md
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
# AGENTS.md
|
||||
|
||||
Guidance for the `e2e/` end-to-end suite. Shared working principles and
|
||||
the TS/JS comment/whitespace rules live in the root
|
||||
[AGENTS.md](../AGENTS.md). The testing standards below are the `web/`
|
||||
standards ([web/AGENTS.md](../web/AGENTS.md)) carried over and adapted —
|
||||
where a rule differs, it differs because the tier does, and the reason is
|
||||
stated.
|
||||
|
||||
## Project
|
||||
|
||||
`@oxynote/e2e` — Playwright tests that drive the composed product through
|
||||
a browser, plus the docker-compose stacks they run against.
|
||||
|
||||
**There are two suites and they share every test.** The dev suite drives
|
||||
the four containers this repository builds; the prod suite drives the
|
||||
all-in-one image an operator actually installs
|
||||
([docker/prod/](../docker/prod/AGENTS.md)), and adds the `prod-*.test.ts`
|
||||
files, which only mean anything there. Neither is a subset of the other:
|
||||
the flow tests are the same files, run twice, because the thing under test
|
||||
is a different assembly of the same product.
|
||||
|
||||
**Everything that belongs to one stack says which, and `dev` is never the
|
||||
unnamed default.** `docker-compose.dev.yaml` and `docker-compose.prod.yaml`,
|
||||
`playwright.dev.config.ts` and `playwright.prod.config.ts`, `test:dev` and
|
||||
`test:prod`, `make e2e-dev` and `make e2e-prod`, `qa-e2e-dev.yml` and
|
||||
`qa-e2e-prod.yml`. A bare name for one half and a qualified name for the
|
||||
other reads as though the bare one were the whole suite, which is exactly
|
||||
the confusion worth spending a few characters to avoid — and it costs
|
||||
`-c`/`-f` on the direct commands, which is a fair price. Use `prod`, not
|
||||
`production`, in every name; `production` stays a word for the real-world
|
||||
thing a deployment is, as in `NODE_ENV=production`.
|
||||
|
||||
**It lives at the repository root, not in `web/`, for two reasons.** The
|
||||
tests exercise the whole system — the web app through Caddy, real core,
|
||||
real auth-realtime, real Postgres — so they belong to no single
|
||||
component. And keeping them out of `web/` keeps them out of `web/`'s
|
||||
toolchain: Playwright's `test`/`expect` would collide with vitest's, and
|
||||
the type-aware ESLint program, the knip scan and the Nuxt tsconfig
|
||||
solution would each need a carve-out for files that are not part of the
|
||||
app. This package owns its own tsconfig, eslint and prettier configs
|
||||
instead.
|
||||
|
||||
## Common commands
|
||||
|
||||
Package manager is **pnpm**. From the repository root:
|
||||
|
||||
```sh
|
||||
make e2e-dev # run the suite against the dev stack; builds and tears down
|
||||
make e2e-dev-stack-build # build that stack's images, for iterating
|
||||
make e2e-dev-stack-stop # stop it and drop its data
|
||||
|
||||
make e2e-prod # the same suite against the all-in-one image
|
||||
make e2e-prod-stack-build # build that image
|
||||
make e2e-prod-stack-stop # stop it and drop its data
|
||||
```
|
||||
|
||||
From `e2e/`:
|
||||
|
||||
```sh
|
||||
pnpm setup # deps + the playwright chromium browser
|
||||
pnpm test:dev # playwright against the dev stack (full cycle, see below)
|
||||
pnpm test:prod # the same, against the all-in-one image
|
||||
|
||||
pnpm check-lint # check-types + check-eslint + check-fmt + check-knip
|
||||
pnpm lint # the fixing counterpart of all four
|
||||
pnpm qa # check-lint + test:dev; qa-fix = lint + test:dev
|
||||
|
||||
pnpm check-types # tsc --noEmit
|
||||
pnpm check-eslint # eslint --max-warnings 0 . (eslint / fmt are the
|
||||
pnpm check-fmt # fixing counterparts)
|
||||
pnpm check-knip # dead exports, files and dependencies
|
||||
```
|
||||
|
||||
**The playwright config owns the whole cycle.**
|
||||
[global-setup.ts](global-setup.ts) builds the stack and brings it up;
|
||||
[global-teardown.ts](global-teardown.ts) stops it and drops its volumes.
|
||||
Nothing has to be running first, and nothing is left behind.
|
||||
|
||||
That is deliberate, and the reason is that a test suite has several front
|
||||
doors — `make e2e-dev`, `pnpm test:dev`, and the play button in the Playwright
|
||||
VS Code extension — and only the config is common to all of them. Putting
|
||||
the build in the caller meant the extension tested whatever image was
|
||||
last left behind, which passes and lies. Now every entry point runs the
|
||||
same cycle, so `make e2e-dev` is a one-line target that only starts the run.
|
||||
|
||||
The build still lives in the Makefile, and the hook shells back out to
|
||||
`make e2e-dev-stack-build` rather than reimplementing it. Two reasons: the
|
||||
hook cannot express it — goreleaser produces core's image, which compose
|
||||
cannot build at all, since the service has no build section — and going
|
||||
through make is what keeps `E2E_DEV_COMPOSE_EXTRA` working, which is how CI
|
||||
layers the buildx cache config in
|
||||
[docker-compose.dev.ci.yaml](docker-compose.dev.ci.yaml) over the base file.
|
||||
|
||||
**Both suites run through the same setup and teardown**, which is why
|
||||
they take the stack they drive from `E2E_STACK` rather than from a second
|
||||
copy of the hooks. Each playwright config sets it as its first statement,
|
||||
before playwright loads the global setup or forks a worker — both inherit
|
||||
the config process's environment — and
|
||||
[helpers/config.ts](helpers/config.ts) reads it to resolve the ports while
|
||||
[helpers/stack.ts](helpers/stack.ts) reads it to pick the compose file and
|
||||
the make targets. Nothing else in the suite knows which stack it is on.
|
||||
|
||||
The knock-on is that **neither config sets `use.baseURL`**: a config is
|
||||
evaluated before it can influence a module it imports, so the origin has
|
||||
to come from the helpers instead. `visit()` splices it on, and every other
|
||||
navigation and request in the suite was already absolute.
|
||||
|
||||
**The cost is that every run is a full build and teardown.** With warm
|
||||
caches and no source change that is tens of seconds, not minutes, and a
|
||||
dropped volume per run means each run starts from an empty database. To
|
||||
iterate without paying it, build once with `make e2e-dev-stack-build` and
|
||||
drive playwright directly with
|
||||
`pnpm exec playwright test -c playwright.dev.config.ts`, which is the same
|
||||
binary without the make wrapper — but understand that you are then
|
||||
responsible for rebuilding after a source change, and that a stale image
|
||||
fails silently. **`-c` is not optional**: neither config carries
|
||||
playwright's default filename, so a bare `playwright test` finds nothing.
|
||||
That is the price of naming both stacks rather than leaving the dev one
|
||||
implicit, and it is the right trade — an unnamed default is exactly what
|
||||
makes two-stack setups ambiguous.
|
||||
|
||||
Both build steps run quietly, under a ticking line, and replay their
|
||||
whole log if they fail. Docker and goreleaser say a great deal that
|
||||
matters only when something breaks.
|
||||
|
||||
**The stack has no pnpm scripts of its own**, and should not grow any.
|
||||
Its lifecycle belongs to the setup and teardown hooks, which reach the
|
||||
Makefile through [helpers/stack.ts](helpers/stack.ts); anything else —
|
||||
reading logs, poking at a container — is plain `docker compose` run from
|
||||
this directory with `-f docker-compose.dev.yaml` or
|
||||
`-f docker-compose.prod.yaml`. Neither carries compose's default name, for
|
||||
the same reason neither playwright config does: which stack a command
|
||||
drives is never left to a default. A `pnpm stack:*` wrapper would only be a
|
||||
second name for a command that is already shorter than its alias.
|
||||
|
||||
## The stack
|
||||
|
||||
[docker-compose.dev.yaml](docker-compose.dev.yaml) is the whole backend,
|
||||
built from this repository's own source — the same images and Dockerfiles the
|
||||
dev stack uses, never a published release. It deliberately differs from
|
||||
`docker/docker-compose.dev.yaml`:
|
||||
|
||||
- **No named volumes**, so every run starts from an empty database. This
|
||||
is what makes the suite deterministic; do not add persistence.
|
||||
- **No `container_name`**, and its own ports (`:18080` front door,
|
||||
`:18025` mailpit), so it runs at the same time as the dev stack.
|
||||
- **Env is inlined and committed.** There is nothing secret in it, so CI
|
||||
needs no setup step. Unlike `docker/env/*.example.env`, it lists only
|
||||
the variables that matter — it is a fixture, not an operator template.
|
||||
- **Every image is pinned**, as in the dev stack. A run has to fail
|
||||
because this repository changed, not because an upstream `latest`
|
||||
moved overnight.
|
||||
- **The images this repo builds are tagged `:e2e-dev` here and
|
||||
`:e2e-prod` in the prod stack**, never the `:dev` the dev stack uses.
|
||||
A tag names the stack an image belongs to, and the separation is load
|
||||
bearing rather than cosmetic: sharing a tag would mean an e2e run
|
||||
silently replaced the image `make start` is running. Core's comes from
|
||||
goreleaser, so its tag is threaded through as
|
||||
`make build-go CORE_IMAGE_TAG=e2e-dev` → core's `IMAGE_TAG` →
|
||||
`CORE_SNAPSHOT_TAG`, which `.goreleaser.yml` reads as the snapshot
|
||||
version. The prod image takes `PROD_IMAGE_TAG` the same way.
|
||||
- **Only the services the tests touch.** No changedetection,
|
||||
sockpuppet-chrome, mariadb, datagen, prometheus or grafana.
|
||||
- **The front door is `docker/Caddyfile` verbatim**, mounted from the dev
|
||||
stack rather than copied. E2e must exercise the routing that ships,
|
||||
including the `/api/x` and `/api/internal` blocks; a second copy would
|
||||
drift.
|
||||
|
||||
One knock-on constraint worth knowing:
|
||||
|
||||
- **Core cannot have a compose healthcheck** — its image is distroless,
|
||||
with no shell or http client to probe itself with. `global-setup.ts`
|
||||
polls it through the front door instead, treating any response under
|
||||
500 as ready (core answers 401 to an unauthenticated probe; Caddy
|
||||
answers 502 on its behalf while it is still starting).
|
||||
|
||||
**Mailpit is what makes the auth flows testable end to end.** Email
|
||||
verification is mandatory (`requireEmailVerification`), so tests fetch
|
||||
the real message from mailpit's REST API and follow the real link —
|
||||
nothing is stubbed or short-circuited.
|
||||
|
||||
**Neither stack runs valkey or an object store.** Both are optional
|
||||
everywhere in the product: without them the assistant keeps its
|
||||
conversations in the core process, better-auth keeps no secondary session
|
||||
storage, and uploaded images live on the filesystem. That is what a
|
||||
deployment does when those variables are unset, so it is the configuration
|
||||
most installs actually run — and testing it here is worth more than
|
||||
testing a shape that only larger deployments have. A test that needs
|
||||
either one back has to say why in the same breath.
|
||||
|
||||
## The prod stack
|
||||
|
||||
[docker-compose.prod.yaml](docker-compose.prod.yaml) is
|
||||
`docker/prod/docker-compose.example.yaml` — the deployment the
|
||||
image's README documents — retuned as a fixture: throwaway state, its own
|
||||
ports (`:19080` front door, `:19025` mailpit), inlined env, pinned images.
|
||||
It runs alongside both other stacks.
|
||||
|
||||
Two things about it are not variations on the dev stack:
|
||||
|
||||
- **Compose builds nothing.** The image comes from `make
|
||||
prod-build`, because core's binary has to be produced by
|
||||
goreleaser and staged where the Dockerfile's `COPY` expects it. There is
|
||||
no build section to point compose at, which is also why the CI cache
|
||||
reaches the build through `PROD_BUILD_EXTRA` on the `docker build`
|
||||
rather than through a compose override. It is tagged `:e2e-prod`.
|
||||
- **There is a `probe` container**, an idle alpine on the same private
|
||||
network. It exists for [tests/](tests/) and is the
|
||||
only honest way to ask whether a container that is not caddy can reach
|
||||
core, auth-realtime or the web server directly. Probing published host
|
||||
ports would answer nothing: the internal ports are not published, so
|
||||
every such probe passes whether the services bind loopback or the
|
||||
wildcard.
|
||||
|
||||
## Testing standards
|
||||
|
||||
### Layout
|
||||
|
||||
- **Test files live in [tests/](tests/), one per user-facing flow**,
|
||||
named after the flow as a user would name it — `signup`, `login`,
|
||||
`onboarding`, `editor`, `collaboration`. Never `.spec.ts`: the repo
|
||||
encodes meaning in the suffix and `.spec` is not one of them.
|
||||
- There is no mirror of the app's source tree and nothing to pair 1:1
|
||||
with. `web/` co-locates because a test there has exactly one subject
|
||||
file; an e2e test has none — it crosses four services by design, so it
|
||||
is filed by flow instead.
|
||||
- **A `prod-` prefix is the one exception to naming by flow**, and it is
|
||||
filed by stack rather than by flow because that is what those cases have
|
||||
in common: each observes something only true of the all-in-one image.
|
||||
The dev config ignores `prod-*.test.ts` (`testIgnore`), so the prefix is
|
||||
what decides a case runs once instead of twice. They stay in
|
||||
[tests/](tests/) beside everything else — a subdirectory would suggest a
|
||||
second tier, and there is only one. A test earns the prefix only if it
|
||||
would be meaningless or misleading against the dev stack; a flow that
|
||||
merely happens to work in both is a normal flow test, run by both suites
|
||||
already.
|
||||
- **Shared code lives in [helpers/](helpers/), one module per concern**
|
||||
(`auth`, `workspace`, `editor`, `collaboration`, `mailpit`, `i18n`,
|
||||
`page`, `config`, `api`, `realtime`). Helpers are setup and plumbing;
|
||||
they do not carry the assertion a test exists to make.
|
||||
- **`signUpWithWorkspace` is the starting point for almost everything.**
|
||||
It signs up, verifies, logs in and creates a workspace, and hands back
|
||||
a page with the welcome document open. Nearly every flow needs that
|
||||
state and nothing cheaper gets there — there is no seeding.
|
||||
- The one exception: **a setup helper asserts its own success**, so a
|
||||
broken precondition is reported where it broke.
|
||||
`signUpAndVerify` checks the account actually activated rather than
|
||||
letting the login three steps later fail for a reason that reads like
|
||||
a login bug. Assertions about the _behaviour under test_ still belong
|
||||
in the test.
|
||||
- When two flows need the same starting state, the setup moves into
|
||||
`helpers/` — never into a shared test file, and never into a test that
|
||||
another test depends on having run.
|
||||
|
||||
### Naming
|
||||
|
||||
**The rule is the repo's: the group names the subject, the test name
|
||||
completes the sentence "it …" with observable behaviour.**
|
||||
|
||||
```ts
|
||||
test.describe("signup", () => {
|
||||
test("sends a verification link that activates the new account")
|
||||
test("refuses a password shorter than the minimum")
|
||||
})
|
||||
```
|
||||
|
||||
Read aloud: "signup sends a verification link that activates the new
|
||||
account." If the concatenation is not a grammatical sentence, the name is
|
||||
off — and reporters print exactly that concatenation on failure.
|
||||
|
||||
The rule carries over from `web/`; the identifiers do not. Playwright has
|
||||
no `it` at all, and `describe` is not a typed export — `test()` and
|
||||
`test.describe()` are the whole API. What a vitest suite writes as
|
||||
`it("returns null")`, a Playwright suite writes as `test("returns null")`,
|
||||
and it still reads as "it …" once concatenated.
|
||||
|
||||
- **`test.describe` names the flow the way a user would** — `signup`,
|
||||
`login` — not the page component or the file.
|
||||
- **Present tense, third person, no "should".**
|
||||
- **Name the outcome, not the mechanics.**
|
||||
`test("takes a verified user to workspace creation")`, not
|
||||
`test("redirects to /welcome")`. The name should survive a route
|
||||
rename; the URL belongs in the assertion.
|
||||
- Be specific enough that the failure line diagnoses itself:
|
||||
`test("signs in")` is useless, `test("rejects an unknown email")` is
|
||||
not.
|
||||
- One level of nesting is the ceiling, as in `web/`.
|
||||
|
||||
### Parameterized tests
|
||||
|
||||
Playwright has no `it.for`. The equivalent is generating tests from a
|
||||
table at collection time:
|
||||
|
||||
```ts
|
||||
const CASES = [
|
||||
{ name: "no digit", password: "no-digits-in-here!" },
|
||||
{ name: "no symbol", password: "Passw0rdPassw0rd" },
|
||||
]
|
||||
|
||||
for (const c of CASES) {
|
||||
test(`refuses a password with ${c.name}`, async ({ page }) => {
|
||||
// ...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The rules carry over unchanged from `web/`:
|
||||
|
||||
- **A table is only for cases that differ in inputs and expected
|
||||
output.** The moment two cases differ in their _steps_ — a different
|
||||
page, a different sequence — they are separate tests, not rows sharing
|
||||
one callback.
|
||||
- **Every generated title must be distinct.** Playwright rejects
|
||||
duplicates within a file, and a shared title makes a report
|
||||
undiagnosable.
|
||||
- Case objects use the repo's vocabulary: `name`, `input`, `expected`.
|
||||
- Generate at module scope. Never call `test()` inside another `test()`.
|
||||
|
||||
### Scope — what earns a test here
|
||||
|
||||
This is the expensive tier: a browser, a full stack, seconds per case
|
||||
instead of milliseconds. A test earns that cost only by observing
|
||||
something no cheaper tier can.
|
||||
|
||||
- **Belongs here:** a flow crossing process boundaries — browser →
|
||||
Caddy → web → auth-realtime → core → Postgres → SMTP. Signup qualifies
|
||||
because the verification link is minted in auth-realtime, rendered and
|
||||
sent by core, and redeemed back through the front door.
|
||||
- **Does not belong here:** field validation messages, loading and empty
|
||||
states, disabled buttons, copy, component branches — anything decided
|
||||
in the browser alone. Those are component tests in `web/`, where they
|
||||
run without a stack.
|
||||
- **The test to apply: would this still fail if the backend were a
|
||||
mock?** If yes, it belongs one tier down.
|
||||
- **Coverage is by flow, not by branch.** `web/` owns "every exported
|
||||
function is covered" and "every path gets a test"; this tier owns "the
|
||||
wiring holds". Reaching for one more browser-decided error-path e2e is
|
||||
nearly always the wrong instinct — add it as a component test instead.
|
||||
- **A negative case belongs here when the server is what decides it**,
|
||||
and it is a first-class regression harness rather than a concession.
|
||||
What the rule above excludes is the browser deciding — a validation
|
||||
message, a disabled button. A refusal that crosses the process
|
||||
boundary is the opposite: "user A cannot open user B's document" is
|
||||
answered by core and nowhere else, so it meets the mock test as
|
||||
squarely as the happy path beside it, and it guards a class of bug
|
||||
that silently reappears the day someone drops an org filter.
|
||||
- **Access denials are grouped in
|
||||
[tests/access.test.ts](tests/access.test.ts)** rather than filed under
|
||||
the flow each one touches. A per-feature file never reaches them: a
|
||||
feature test always runs as the user who is supposed to have access,
|
||||
which is why a missing check survives a suite that covers every line.
|
||||
Naming follows the same rule as everywhere else — the group names the
|
||||
subject, each name completes "it …" with the denial it observes.
|
||||
- **The trust boundary is tested from outside the image**, in
|
||||
[tests/prod-trust-boundary.test.ts](tests/prod-trust-boundary.test.ts).
|
||||
Two independent things keep core's `/api/x/*` and auth-realtime's
|
||||
`/api/internal/*` unreachable — the services bind the container's
|
||||
loopback, and caddy refuses the prefixes at the front door — and the
|
||||
file asserts them separately, because either one alone is a single
|
||||
point of failure and a suite that conflated them would go green with
|
||||
one of them gone. The launcher runs its own bind check at boot; this
|
||||
asks the same question from the network, so a regression that took the
|
||||
boot gate with it is still caught.
|
||||
- The bind cases go through the `probe` container, and the group carries
|
||||
a **positive control** — the same probe reaching the front door — so a
|
||||
broken network cannot pass as a loopback bind. Every table-driven
|
||||
negative here needs one; without it the whole group is one typo away
|
||||
from proving nothing.
|
||||
- The front-door cases are sent with `rawRequest`, over `node:http`,
|
||||
because the point is what caddy does with a path and `fetch` would
|
||||
normalise `..`, duplicate slashes and encoding away before the request
|
||||
ever left. Two tables: spellings caddy is expected to recognise as the
|
||||
same request must be `403`, and traversal attempts must simply never
|
||||
succeed — asserting a specific status for those would couple the suite
|
||||
to caddy's normalisation rather than to the property that matters.
|
||||
- **A denial that does not hold yet is marked `test.fail()`**, with a
|
||||
comment saying what is missing and what to delete when it is fixed.
|
||||
That keeps the gap in the suite instead of in a backlog, and Playwright
|
||||
turns the annotation red the day the case starts passing. Check how
|
||||
long such a case takes when you add one: a test that hangs also
|
||||
registers as an expected failure, so a broken probe and a real gap
|
||||
look identical until you read the duration.
|
||||
|
||||
### A real backend, and no mocks
|
||||
|
||||
`web/` says only e2e is allowed a real backend. The counterpart here:
|
||||
**e2e is required to use it.**
|
||||
|
||||
- **Never `page.route()` an application endpoint, never stub a response,
|
||||
never seed the database out of band.** A stubbed e2e proves nothing
|
||||
the component tier had not already proven more cheaply.
|
||||
- **Reach state through the product's own surface.** An account exists
|
||||
because a test signed up for it. Where the UI genuinely offers no path,
|
||||
the product's HTTP API is the fallback — never SQL, which would let a
|
||||
test set up state the product itself cannot produce.
|
||||
- **Mail is real.** Verification links are read out of mailpit; nothing
|
||||
marks an account verified behind the app's back.
|
||||
- Needing a mock to make a case practical is the signal that the case
|
||||
belongs in the component tier.
|
||||
- Third-party edges (the OAuth providers) have no doubles here, which is
|
||||
why only email-password auth is covered.
|
||||
- **Collaboration runs as two real users.** `joinAsSecondUser` walks the
|
||||
whole journey a teammate takes: the owner sends the invitation from
|
||||
settings, and the invitee signs up, verifies, logs in and accepts the
|
||||
emailed invite in a fresh browser context — nothing is granted behind
|
||||
the product's back. Presence assertions key on the caret's name label
|
||||
(each side sees the *other* user's name), never its colour, which is
|
||||
random per client.
|
||||
|
||||
### Locators
|
||||
|
||||
- **Prefer role and placeholder over CSS**: `getByRole("button", { name })`,
|
||||
`getByPlaceholder(...)`. A restyle must not break a test that still
|
||||
describes the same user action.
|
||||
- **Never hardcode a translation.** Assert through `t()` from
|
||||
[helpers/i18n.ts](helpers/i18n.ts), which resolves the key against the
|
||||
same locale files the app renders with, interpolation included. A
|
||||
copied english string is a second, unmaintained definition of the
|
||||
message: it keeps passing when the copy changes, and a message edit
|
||||
silently breaks a suite that has nothing to do with it. The rules that
|
||||
follow from it are the `web/` ones:
|
||||
- **Interpolated messages take their values through `t()` too** —
|
||||
`t("onboarding.verify-email.sent-title", { email })`, never a
|
||||
hand-spliced string and never a bare prefix that skips the
|
||||
interpolation.
|
||||
- **Pick the key the component actually renders.** Many messages share
|
||||
an english value, so the wrong key passes today and diverges the day
|
||||
one of them is reworded. Follow the component to its key rather than
|
||||
grepping the locale file for the text.
|
||||
- **Overlays teleport out of the page's main tree** into `#teleports`.
|
||||
They are reachable from `page`, but not from a locator scoped to the
|
||||
container the trigger lives in.
|
||||
- **Strict mode is a feature.** When a locator matches more than one
|
||||
element, fix the locator — do not reach for `.first()`. A document
|
||||
name is a link twice (sidebar row and breadcrumb) and every tree row
|
||||
has an "Open Page Actions" button; `sidebarDocument()` and
|
||||
`openDocumentActions()` scope those for you.
|
||||
- **Read editor text through `editorText()`**, never `toHaveText` or
|
||||
`innerText` on the editor. A collaborator's caret is a decoration
|
||||
widget rendered inside the paragraph it sits in, label and all, so a
|
||||
plain text read of a shared document comes back with the other user's
|
||||
name spliced into the content.
|
||||
- **The title and the body are two editors.** `titleEditor()` is its
|
||||
own tiptap instance with a one-paragraph schema and its own yjs field;
|
||||
it is not the first heading of `contentEditor()`. Enter in the title
|
||||
moves the caret into the body rather than adding a line.
|
||||
- **Menus are appended to `<body>`.** The slash menu, dropdowns and
|
||||
dialogs are all outside the editor and sidebar subtrees; `openSlashMenu()`
|
||||
returns the menu, and dialogs come through `getByRole("dialog")`.
|
||||
|
||||
### Independence & concurrency
|
||||
|
||||
- **Every test creates its own account and its own workspace** via
|
||||
`newCredentials()` and `newWorkspace()`, which mint a unique email and
|
||||
a unique slug. Unique-per-test state is what makes `fullyParallel`
|
||||
safe against a single shared stack — the same independence rule
|
||||
`web/` states, enforced by construction rather than by mock resetting.
|
||||
A workspace slug is unique server-side, so two tests sharing one would
|
||||
fail on the second, exactly as two sharing an email would.
|
||||
- **Workers are capped at four locally and two on CI.** Every test's
|
||||
setup is several cross-service round trips against one core and one
|
||||
postgres; past a few workers the setup starts timing out, which reads
|
||||
as flaky tests when it is only load. CI gets fewer as headroom rather
|
||||
than because of any particular runner — it carries the stack and the
|
||||
browsers on one machine, and a green suite there is worth more than a
|
||||
slightly faster one. For the same reason the test budget is 60s, well
|
||||
above what a test takes locally, and the collaboration tests are marked
|
||||
slow, tripling theirs:
|
||||
each one runs two signups, two verifications, an invitation and its
|
||||
acceptance before the first assertion.
|
||||
- **Both suites run four workers, and turning that down is not how a
|
||||
flake gets fixed.** The prod stack contends harder than the dev one —
|
||||
caddy, web, core and auth-realtime share a single container there — and
|
||||
that pressure is worth keeping, because it is what surfaces the real
|
||||
defects. Every failure that only appeared at four turned out to be one:
|
||||
a form that read its session once instead of following it, sync
|
||||
connections that were never torn down when a tab closed, a dialog
|
||||
dismissed with one keypress when two were open, a toast asserted after
|
||||
it had auto-dismissed, and cross-service chains waited on with the
|
||||
default five seconds. Each was fixed where it lived. If a case fails
|
||||
only under load, that is the finding — chase it, do not widen the gap
|
||||
it hides in.
|
||||
- **No test depends on another's state or on execution order**, and no
|
||||
test cleans up after itself: the stack's database is thrown away
|
||||
wholesale, which is why it carries no volumes.
|
||||
- **No shared mutable module-level state.** Where several tests need the
|
||||
same starting point, each builds its own from a helper.
|
||||
- Browser contexts are per-test by default. Do not carry a signed-in
|
||||
session across tests unless a fixture makes that sharing explicit.
|
||||
|
||||
### Determinism
|
||||
|
||||
The `web/` rule — "never sleepy" — applies with more force here, because
|
||||
this tier has real network and real containers to be tempted by.
|
||||
|
||||
- **Never sleep.** No `page.waitForTimeout`, and no bare timeout used as
|
||||
a synchronisation device. Playwright's assertions already retry.
|
||||
- **Wait on the condition, not the clock**: `toHaveURL`, `toBeVisible`,
|
||||
`toHaveText`. For state that lives outside the page — a delivered
|
||||
email — `expect.poll` with a `message` naming what never arrived, so
|
||||
the timeout reports the missing thing instead of a bare expiry.
|
||||
- **Interact only after hydration.** Navigate with `visit()` from
|
||||
[helpers/page.ts](helpers/page.ts) rather than `page.goto` whenever the
|
||||
test then drives the page: server-rendered markup accepts clicks before
|
||||
vue has attached anything to it, and those clicks do nothing.
|
||||
- **Assert the exact URL when a parameter carries meaning.**
|
||||
`toHaveURL(".../login?verified=true")` distinguishes an activated
|
||||
account from a rejected token; a regex on the path alone would pass for
|
||||
both.
|
||||
- **`documentPersisted()` is the one deliberate wait on the clock.**
|
||||
Hocuspocus stores a document two seconds after its last change and
|
||||
nothing on the client reports when that happened, so a test that
|
||||
reloads to prove persistence waits out the debounce. It is the single
|
||||
permitted `waitForTimeout` in the suite and carries its disable reason.
|
||||
- **Three waits are raised above the default, all for the same
|
||||
reason**: the post-signup redirect, the post-login redirect and a cold
|
||||
document load. Each is a chain across auth-realtime, core and postgres
|
||||
— and in the document's case the websocket sync and a fade-in too —
|
||||
that several workers at once can stretch past five seconds while the
|
||||
product is entirely healthy. Any other raised timeout carries a
|
||||
comment saying what is slow and why.
|
||||
|
||||
### Failure artifacts
|
||||
|
||||
- `trace: "on-first-retry"` and `screenshot: "only-on-failure"`;
|
||||
`test-results/` and `playwright-report/` are gitignored.
|
||||
- `docker compose logs` from this directory gives the service side of a
|
||||
failure — the browser only ever shows one end of it.
|
||||
- A failure that reproduces only under load or only in CI is almost
|
||||
always a wait that was never really a wait. Fix the signal; do not
|
||||
raise the timeout.
|
||||
|
||||
### Prior art
|
||||
|
||||
A root-level, self-contained e2e package is the norm for repositories
|
||||
shaped like this one:
|
||||
[immich](https://github.com/immich-app/immich/tree/main/e2e) (`e2e/`
|
||||
beside `server/` and `web/`, own package.json and compose file),
|
||||
[grafana](https://github.com/grafana/grafana/tree/main/e2e-playwright),
|
||||
[mattermost](https://github.com/mattermost/mattermost/tree/master/e2e-tests)
|
||||
(beside `server/` and `webapp/`), and
|
||||
[cal.com](https://github.com/calcom/cal.com/tree/main/apps/web/playwright).
|
||||
None of them nest the suite under a `tests/` tier, and none share the
|
||||
frontend's toolchain.
|
||||
|
||||
## Formatting & TS
|
||||
|
||||
The same gates as `web/`, minus the ones that only make sense there.
|
||||
|
||||
Prettier uses **tabs, no semicolons, trailing commas**, and
|
||||
[.prettierignore](.prettierignore) keeps it off the lockfile and the
|
||||
markdown — the convention the sibling packages already follow.
|
||||
|
||||
ESLint ([eslint.config.mjs](eslint.config.mjs)) runs **type-aware**
|
||||
(`strictTypeChecked` + `stylisticTypeChecked` over a `projectService`),
|
||||
for one reason above all: it is what reports a promise that was never
|
||||
awaited. In a Playwright suite a missing `await` is not a style problem —
|
||||
the assertion never runs, and the test passes without having checked
|
||||
anything. It runs with `--max-warnings 0`, and `reportUnusedDisableDirectives`
|
||||
makes a stale disable an error, so every `eslint-disable` states its
|
||||
reason after `--` and is removed when it stops applying.
|
||||
|
||||
**`eslint-plugin-playwright` is this package's counterpart to `web/`'s
|
||||
`@vitest/eslint-plugin`,** and it enforces conventions this file would
|
||||
otherwise only ask for politely: `no-wait-for-timeout` is the "never
|
||||
sleep" rule, `no-focused-test` and `no-skipped-test` stop a `.only` or a
|
||||
`.skip` reaching main, and `missing-playwright-await` catches the
|
||||
unawaited assertion described above. It is scoped to
|
||||
[tests/](tests/) and [helpers/](helpers/).
|
||||
|
||||
**knip** guards dead exports, files and dependencies, and needs no config
|
||||
file: its Playwright plugin picks up both `playwright.*.config.ts` files,
|
||||
resolves `globalSetup` and `globalTeardown` from them, and treats the test
|
||||
files as entry points. Nothing here has `web/`'s auto-import or vendored-directory
|
||||
blind spots, so a `knip.ts` would only be a file to keep in sync.
|
||||
|
||||
TypeScript is strict, with `noUnusedLocals`, `noUnusedParameters`,
|
||||
`noUncheckedIndexedAccess`, `noImplicitOverride` and
|
||||
`verbatimModuleSyntax` on — the same posture as the other packages.
|
||||
|
||||
## CI
|
||||
|
||||
[.github/workflows/qa-e2e-dev.yml](../.github/workflows/qa-e2e-dev.yml) mirrors
|
||||
the other QA workflows: a `lint` job running `check-lint`, and a `test`
|
||||
job running `make e2e-dev`. The test job needs more than a node toolchain —
|
||||
it sets up Go and goreleaser, because the suite runs against images built
|
||||
from this repository and core's is not something compose can build. On
|
||||
failure it uploads the html report, which is why the CI reporter is
|
||||
`github` **and** `html`: annotations on the pull request, and a full
|
||||
report with traces to download.
|
||||
|
||||
**The triggers are asymmetric on purpose.** A merge to `main` runs the
|
||||
suite for a change anywhere that can break it — `web/`, `server/`,
|
||||
`docker/`, `e2e/` — because that is where regressions actually come from.
|
||||
A pull request only triggers it for changes to `e2e/` itself. A full run
|
||||
builds a nuxt image and costs real minutes, and paying that on every push
|
||||
of every frontend PR buys only a few minutes' warning over the run on
|
||||
main. `concurrency` supersedes earlier runs on
|
||||
every branch, `main` included — only the latest push's result matters,
|
||||
so a superseded run shows as cancelled rather than finishing.
|
||||
|
||||
**Layer caching is what keeps that affordable.** A runner starts with no
|
||||
layers, so the workflow sets up buildx and applies
|
||||
[docker-compose.dev.ci.yaml](docker-compose.dev.ci.yaml) through
|
||||
`E2E_DEV_COMPOSE_EXTRA` — a separate file because `type=gha` is meaningless
|
||||
outside a runner and would break every local build. Paired with the
|
||||
dependency layer in `web/Dockerfile.dev`, a push that leaves `web/`
|
||||
alone reuses the whole image instead of rebuilding it.
|
||||
|
||||
**The prod suite is
|
||||
[.github/workflows/qa-e2e-prod.yml](../.github/workflows/qa-e2e-prod.yml),
|
||||
and it has no trigger of its own.** No push, no pull request, no tag:
|
||||
[release.yml](../.github/workflows/release.yml) calls it, and refuses to
|
||||
publish the image unless it passes. `workflow_dispatch` remains for
|
||||
re-verifying an image without cutting a release. Where the dev suite guards
|
||||
day-to-day work, the prod suite answers a different question — does the
|
||||
artifact about to be published serve the product, and is its trust boundary
|
||||
intact — which is worth its minutes exactly when something is being
|
||||
released. Its cache reaches the build through `PROD_BUILD_EXTRA` rather
|
||||
than a compose override, because compose builds nothing in the prod stack:
|
||||
the image comes from `docker build` in the Makefile. That is also why
|
||||
[docker-compose.dev.ci.yaml](docker-compose.dev.ci.yaml) carries `dev` in
|
||||
its name with no prod counterpart to pair with.
|
||||
|
||||
**Being a release gate is what `workflow_call` is for.** A job cannot
|
||||
depend on a separate workflow's result, so release.yml is the one place
|
||||
that runs the gates and holds the publish behind `needs` — and every
|
||||
workflow it calls declares `workflow_call` alongside its own triggers. Path
|
||||
filters do not apply to a called workflow, so each one runs in full on a
|
||||
tag regardless of what that tag touched, which is what a release wants.
|
||||
Nothing is reused from an earlier run of the same commit either: a called
|
||||
workflow always executes, so a tag re-runs every gate against the tagged
|
||||
tree.
|
||||
|
||||
**The dev suite is deliberately not a gate**, and `qa-e2e-dev.yml` is
|
||||
therefore the one QA workflow that does not declare `workflow_call`. It
|
||||
runs the same flow tests as the prod suite, against a different assembly of
|
||||
the same product, while the prod suite runs them against the artifact
|
||||
actually being published — so gating on both would spend a nuxt image build
|
||||
and a browser run re-answering a question already answered about the thing
|
||||
that ships.
|
||||
|
||||
## Not covered yet
|
||||
|
||||
The desktop/Electron build. Playwright drives it through a different
|
||||
entry point (`_electron`) and its auth runs over IPC rather than cookies,
|
||||
so it is a separate track with its own launch mechanics — not a variant
|
||||
of these tests.
|
||||
650
e2e/CLAUDE.md
650
e2e/CLAUDE.md
|
|
@ -1,649 +1 @@
|
|||
# CLAUDE.md
|
||||
|
||||
Guidance for the `e2e/` end-to-end suite. Shared working principles and
|
||||
the TS/JS comment/whitespace rules live in the root
|
||||
[CLAUDE.md](../CLAUDE.md). The testing standards below are the `web/`
|
||||
standards ([web/CLAUDE.md](../web/CLAUDE.md)) carried over and adapted —
|
||||
where a rule differs, it differs because the tier does, and the reason is
|
||||
stated.
|
||||
|
||||
## Project
|
||||
|
||||
`@oxynote/e2e` — Playwright tests that drive the composed product through
|
||||
a browser, plus the docker-compose stacks they run against.
|
||||
|
||||
**There are two suites and they share every test.** The dev suite drives
|
||||
the four containers this repository builds; the prod suite drives the
|
||||
all-in-one image an operator actually installs
|
||||
([docker/prod/](../docker/prod/CLAUDE.md)), and adds the `prod-*.test.ts`
|
||||
files, which only mean anything there. Neither is a subset of the other:
|
||||
the flow tests are the same files, run twice, because the thing under test
|
||||
is a different assembly of the same product.
|
||||
|
||||
**Everything that belongs to one stack says which, and `dev` is never the
|
||||
unnamed default.** `docker-compose.dev.yaml` and `docker-compose.prod.yaml`,
|
||||
`playwright.dev.config.ts` and `playwright.prod.config.ts`, `test:dev` and
|
||||
`test:prod`, `make e2e-dev` and `make e2e-prod`, `qa-e2e-dev.yml` and
|
||||
`qa-e2e-prod.yml`. A bare name for one half and a qualified name for the
|
||||
other reads as though the bare one were the whole suite, which is exactly
|
||||
the confusion worth spending a few characters to avoid — and it costs
|
||||
`-c`/`-f` on the direct commands, which is a fair price. Use `prod`, not
|
||||
`production`, in every name; `production` stays a word for the real-world
|
||||
thing a deployment is, as in `NODE_ENV=production`.
|
||||
|
||||
**It lives at the repository root, not in `web/`, for two reasons.** The
|
||||
tests exercise the whole system — the web app through Caddy, real core,
|
||||
real auth-realtime, real Postgres — so they belong to no single
|
||||
component. And keeping them out of `web/` keeps them out of `web/`'s
|
||||
toolchain: Playwright's `test`/`expect` would collide with vitest's, and
|
||||
the type-aware ESLint program, the knip scan and the Nuxt tsconfig
|
||||
solution would each need a carve-out for files that are not part of the
|
||||
app. This package owns its own tsconfig, eslint and prettier configs
|
||||
instead.
|
||||
|
||||
## Common commands
|
||||
|
||||
Package manager is **pnpm**. From the repository root:
|
||||
|
||||
```sh
|
||||
make e2e-dev # run the suite against the dev stack; builds and tears down
|
||||
make e2e-dev-stack-build # build that stack's images, for iterating
|
||||
make e2e-dev-stack-stop # stop it and drop its data
|
||||
|
||||
make e2e-prod # the same suite against the all-in-one image
|
||||
make e2e-prod-stack-build # build that image
|
||||
make e2e-prod-stack-stop # stop it and drop its data
|
||||
```
|
||||
|
||||
From `e2e/`:
|
||||
|
||||
```sh
|
||||
pnpm setup # deps + the playwright chromium browser
|
||||
pnpm test:dev # playwright against the dev stack (full cycle, see below)
|
||||
pnpm test:prod # the same, against the all-in-one image
|
||||
|
||||
pnpm check-lint # check-types + check-eslint + check-fmt + check-knip
|
||||
pnpm lint # the fixing counterpart of all four
|
||||
pnpm qa # check-lint + test:dev; qa-fix = lint + test:dev
|
||||
|
||||
pnpm check-types # tsc --noEmit
|
||||
pnpm check-eslint # eslint --max-warnings 0 . (eslint / fmt are the
|
||||
pnpm check-fmt # fixing counterparts)
|
||||
pnpm check-knip # dead exports, files and dependencies
|
||||
```
|
||||
|
||||
**The playwright config owns the whole cycle.**
|
||||
[global-setup.ts](global-setup.ts) builds the stack and brings it up;
|
||||
[global-teardown.ts](global-teardown.ts) stops it and drops its volumes.
|
||||
Nothing has to be running first, and nothing is left behind.
|
||||
|
||||
That is deliberate, and the reason is that a test suite has several front
|
||||
doors — `make e2e-dev`, `pnpm test:dev`, and the play button in the Playwright
|
||||
VS Code extension — and only the config is common to all of them. Putting
|
||||
the build in the caller meant the extension tested whatever image was
|
||||
last left behind, which passes and lies. Now every entry point runs the
|
||||
same cycle, so `make e2e-dev` is a one-line target that only starts the run.
|
||||
|
||||
The build still lives in the Makefile, and the hook shells back out to
|
||||
`make e2e-dev-stack-build` rather than reimplementing it. Two reasons: the
|
||||
hook cannot express it — goreleaser produces core's image, which compose
|
||||
cannot build at all, since the service has no build section — and going
|
||||
through make is what keeps `E2E_DEV_COMPOSE_EXTRA` working, which is how CI
|
||||
layers the buildx cache config in
|
||||
[docker-compose.dev.ci.yaml](docker-compose.dev.ci.yaml) over the base file.
|
||||
|
||||
**Both suites run through the same setup and teardown**, which is why
|
||||
they take the stack they drive from `E2E_STACK` rather than from a second
|
||||
copy of the hooks. Each playwright config sets it as its first statement,
|
||||
before playwright loads the global setup or forks a worker — both inherit
|
||||
the config process's environment — and
|
||||
[helpers/config.ts](helpers/config.ts) reads it to resolve the ports while
|
||||
[helpers/stack.ts](helpers/stack.ts) reads it to pick the compose file and
|
||||
the make targets. Nothing else in the suite knows which stack it is on.
|
||||
|
||||
The knock-on is that **neither config sets `use.baseURL`**: a config is
|
||||
evaluated before it can influence a module it imports, so the origin has
|
||||
to come from the helpers instead. `visit()` splices it on, and every other
|
||||
navigation and request in the suite was already absolute.
|
||||
|
||||
**The cost is that every run is a full build and teardown.** With warm
|
||||
caches and no source change that is tens of seconds, not minutes, and a
|
||||
dropped volume per run means each run starts from an empty database. To
|
||||
iterate without paying it, build once with `make e2e-dev-stack-build` and
|
||||
drive playwright directly with
|
||||
`pnpm exec playwright test -c playwright.dev.config.ts`, which is the same
|
||||
binary without the make wrapper — but understand that you are then
|
||||
responsible for rebuilding after a source change, and that a stale image
|
||||
fails silently. **`-c` is not optional**: neither config carries
|
||||
playwright's default filename, so a bare `playwright test` finds nothing.
|
||||
That is the price of naming both stacks rather than leaving the dev one
|
||||
implicit, and it is the right trade — an unnamed default is exactly what
|
||||
makes two-stack setups ambiguous.
|
||||
|
||||
Both build steps run quietly, under a ticking line, and replay their
|
||||
whole log if they fail. Docker and goreleaser say a great deal that
|
||||
matters only when something breaks.
|
||||
|
||||
**The stack has no pnpm scripts of its own**, and should not grow any.
|
||||
Its lifecycle belongs to the setup and teardown hooks, which reach the
|
||||
Makefile through [helpers/stack.ts](helpers/stack.ts); anything else —
|
||||
reading logs, poking at a container — is plain `docker compose` run from
|
||||
this directory with `-f docker-compose.dev.yaml` or
|
||||
`-f docker-compose.prod.yaml`. Neither carries compose's default name, for
|
||||
the same reason neither playwright config does: which stack a command
|
||||
drives is never left to a default. A `pnpm stack:*` wrapper would only be a
|
||||
second name for a command that is already shorter than its alias.
|
||||
|
||||
## The stack
|
||||
|
||||
[docker-compose.dev.yaml](docker-compose.dev.yaml) is the whole backend,
|
||||
built from this repository's own source — the same images and Dockerfiles the
|
||||
dev stack uses, never a published release. It deliberately differs from
|
||||
`docker/docker-compose.dev.yaml`:
|
||||
|
||||
- **No named volumes**, so every run starts from an empty database. This
|
||||
is what makes the suite deterministic; do not add persistence.
|
||||
- **No `container_name`**, and its own ports (`:18080` front door,
|
||||
`:18025` mailpit), so it runs at the same time as the dev stack.
|
||||
- **Env is inlined and committed.** There is nothing secret in it, so CI
|
||||
needs no setup step. Unlike `docker/env/*.example.env`, it lists only
|
||||
the variables that matter — it is a fixture, not an operator template.
|
||||
- **Every image is pinned**, as in the dev stack. A run has to fail
|
||||
because this repository changed, not because an upstream `latest`
|
||||
moved overnight.
|
||||
- **The images this repo builds are tagged `:e2e-dev` here and
|
||||
`:e2e-prod` in the prod stack**, never the `:dev` the dev stack uses.
|
||||
A tag names the stack an image belongs to, and the separation is load
|
||||
bearing rather than cosmetic: sharing a tag would mean an e2e run
|
||||
silently replaced the image `make start` is running. Core's comes from
|
||||
goreleaser, so its tag is threaded through as
|
||||
`make build-go CORE_IMAGE_TAG=e2e-dev` → core's `IMAGE_TAG` →
|
||||
`CORE_SNAPSHOT_TAG`, which `.goreleaser.yml` reads as the snapshot
|
||||
version. The prod image takes `PROD_IMAGE_TAG` the same way.
|
||||
- **Only the services the tests touch.** No changedetection,
|
||||
sockpuppet-chrome, mariadb, datagen, prometheus or grafana.
|
||||
- **The front door is `docker/Caddyfile` verbatim**, mounted from the dev
|
||||
stack rather than copied. E2e must exercise the routing that ships,
|
||||
including the `/api/x` and `/api/internal` blocks; a second copy would
|
||||
drift.
|
||||
|
||||
One knock-on constraint worth knowing:
|
||||
|
||||
- **Core cannot have a compose healthcheck** — its image is distroless,
|
||||
with no shell or http client to probe itself with. `global-setup.ts`
|
||||
polls it through the front door instead, treating any response under
|
||||
500 as ready (core answers 401 to an unauthenticated probe; Caddy
|
||||
answers 502 on its behalf while it is still starting).
|
||||
|
||||
**Mailpit is what makes the auth flows testable end to end.** Email
|
||||
verification is mandatory (`requireEmailVerification`), so tests fetch
|
||||
the real message from mailpit's REST API and follow the real link —
|
||||
nothing is stubbed or short-circuited.
|
||||
|
||||
**Neither stack runs valkey or an object store.** Both are optional
|
||||
everywhere in the product: without them the assistant keeps its
|
||||
conversations in the core process, better-auth keeps no secondary session
|
||||
storage, and uploaded images live on the filesystem. That is what a
|
||||
deployment does when those variables are unset, so it is the configuration
|
||||
most installs actually run — and testing it here is worth more than
|
||||
testing a shape that only larger deployments have. A test that needs
|
||||
either one back has to say why in the same breath.
|
||||
|
||||
## The prod stack
|
||||
|
||||
[docker-compose.prod.yaml](docker-compose.prod.yaml) is
|
||||
`docker/prod/docker-compose.example.yaml` — the deployment the
|
||||
image's README documents — retuned as a fixture: throwaway state, its own
|
||||
ports (`:19080` front door, `:19025` mailpit), inlined env, pinned images.
|
||||
It runs alongside both other stacks.
|
||||
|
||||
Two things about it are not variations on the dev stack:
|
||||
|
||||
- **Compose builds nothing.** The image comes from `make
|
||||
prod-build`, because core's binary has to be produced by
|
||||
goreleaser and staged where the Dockerfile's `COPY` expects it. There is
|
||||
no build section to point compose at, which is also why the CI cache
|
||||
reaches the build through `PROD_BUILD_EXTRA` on the `docker build`
|
||||
rather than through a compose override. It is tagged `:e2e-prod`.
|
||||
- **There is a `probe` container**, an idle alpine on the same private
|
||||
network. It exists for [tests/](tests/) and is the
|
||||
only honest way to ask whether a container that is not caddy can reach
|
||||
core, auth-realtime or the web server directly. Probing published host
|
||||
ports would answer nothing: the internal ports are not published, so
|
||||
every such probe passes whether the services bind loopback or the
|
||||
wildcard.
|
||||
|
||||
## Testing standards
|
||||
|
||||
### Layout
|
||||
|
||||
- **Test files live in [tests/](tests/), one per user-facing flow**,
|
||||
named after the flow as a user would name it — `signup`, `login`,
|
||||
`onboarding`, `editor`, `collaboration`. Never `.spec.ts`: the repo
|
||||
encodes meaning in the suffix and `.spec` is not one of them.
|
||||
- There is no mirror of the app's source tree and nothing to pair 1:1
|
||||
with. `web/` co-locates because a test there has exactly one subject
|
||||
file; an e2e test has none — it crosses four services by design, so it
|
||||
is filed by flow instead.
|
||||
- **A `prod-` prefix is the one exception to naming by flow**, and it is
|
||||
filed by stack rather than by flow because that is what those cases have
|
||||
in common: each observes something only true of the all-in-one image.
|
||||
The dev config ignores `prod-*.test.ts` (`testIgnore`), so the prefix is
|
||||
what decides a case runs once instead of twice. They stay in
|
||||
[tests/](tests/) beside everything else — a subdirectory would suggest a
|
||||
second tier, and there is only one. A test earns the prefix only if it
|
||||
would be meaningless or misleading against the dev stack; a flow that
|
||||
merely happens to work in both is a normal flow test, run by both suites
|
||||
already.
|
||||
- **Shared code lives in [helpers/](helpers/), one module per concern**
|
||||
(`auth`, `workspace`, `editor`, `collaboration`, `mailpit`, `i18n`,
|
||||
`page`, `config`, `api`, `realtime`). Helpers are setup and plumbing;
|
||||
they do not carry the assertion a test exists to make.
|
||||
- **`signUpWithWorkspace` is the starting point for almost everything.**
|
||||
It signs up, verifies, logs in and creates a workspace, and hands back
|
||||
a page with the welcome document open. Nearly every flow needs that
|
||||
state and nothing cheaper gets there — there is no seeding.
|
||||
- The one exception: **a setup helper asserts its own success**, so a
|
||||
broken precondition is reported where it broke.
|
||||
`signUpAndVerify` checks the account actually activated rather than
|
||||
letting the login three steps later fail for a reason that reads like
|
||||
a login bug. Assertions about the _behaviour under test_ still belong
|
||||
in the test.
|
||||
- When two flows need the same starting state, the setup moves into
|
||||
`helpers/` — never into a shared test file, and never into a test that
|
||||
another test depends on having run.
|
||||
|
||||
### Naming
|
||||
|
||||
**The rule is the repo's: the group names the subject, the test name
|
||||
completes the sentence "it …" with observable behaviour.**
|
||||
|
||||
```ts
|
||||
test.describe("signup", () => {
|
||||
test("sends a verification link that activates the new account")
|
||||
test("refuses a password shorter than the minimum")
|
||||
})
|
||||
```
|
||||
|
||||
Read aloud: "signup sends a verification link that activates the new
|
||||
account." If the concatenation is not a grammatical sentence, the name is
|
||||
off — and reporters print exactly that concatenation on failure.
|
||||
|
||||
The rule carries over from `web/`; the identifiers do not. Playwright has
|
||||
no `it` at all, and `describe` is not a typed export — `test()` and
|
||||
`test.describe()` are the whole API. What a vitest suite writes as
|
||||
`it("returns null")`, a Playwright suite writes as `test("returns null")`,
|
||||
and it still reads as "it …" once concatenated.
|
||||
|
||||
- **`test.describe` names the flow the way a user would** — `signup`,
|
||||
`login` — not the page component or the file.
|
||||
- **Present tense, third person, no "should".**
|
||||
- **Name the outcome, not the mechanics.**
|
||||
`test("takes a verified user to workspace creation")`, not
|
||||
`test("redirects to /welcome")`. The name should survive a route
|
||||
rename; the URL belongs in the assertion.
|
||||
- Be specific enough that the failure line diagnoses itself:
|
||||
`test("signs in")` is useless, `test("rejects an unknown email")` is
|
||||
not.
|
||||
- One level of nesting is the ceiling, as in `web/`.
|
||||
|
||||
### Parameterized tests
|
||||
|
||||
Playwright has no `it.for`. The equivalent is generating tests from a
|
||||
table at collection time:
|
||||
|
||||
```ts
|
||||
const CASES = [
|
||||
{ name: "no digit", password: "no-digits-in-here!" },
|
||||
{ name: "no symbol", password: "Passw0rdPassw0rd" },
|
||||
]
|
||||
|
||||
for (const c of CASES) {
|
||||
test(`refuses a password with ${c.name}`, async ({ page }) => {
|
||||
// ...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The rules carry over unchanged from `web/`:
|
||||
|
||||
- **A table is only for cases that differ in inputs and expected
|
||||
output.** The moment two cases differ in their _steps_ — a different
|
||||
page, a different sequence — they are separate tests, not rows sharing
|
||||
one callback.
|
||||
- **Every generated title must be distinct.** Playwright rejects
|
||||
duplicates within a file, and a shared title makes a report
|
||||
undiagnosable.
|
||||
- Case objects use the repo's vocabulary: `name`, `input`, `expected`.
|
||||
- Generate at module scope. Never call `test()` inside another `test()`.
|
||||
|
||||
### Scope — what earns a test here
|
||||
|
||||
This is the expensive tier: a browser, a full stack, seconds per case
|
||||
instead of milliseconds. A test earns that cost only by observing
|
||||
something no cheaper tier can.
|
||||
|
||||
- **Belongs here:** a flow crossing process boundaries — browser →
|
||||
Caddy → web → auth-realtime → core → Postgres → SMTP. Signup qualifies
|
||||
because the verification link is minted in auth-realtime, rendered and
|
||||
sent by core, and redeemed back through the front door.
|
||||
- **Does not belong here:** field validation messages, loading and empty
|
||||
states, disabled buttons, copy, component branches — anything decided
|
||||
in the browser alone. Those are component tests in `web/`, where they
|
||||
run without a stack.
|
||||
- **The test to apply: would this still fail if the backend were a
|
||||
mock?** If yes, it belongs one tier down.
|
||||
- **Coverage is by flow, not by branch.** `web/` owns "every exported
|
||||
function is covered" and "every path gets a test"; this tier owns "the
|
||||
wiring holds". Reaching for one more browser-decided error-path e2e is
|
||||
nearly always the wrong instinct — add it as a component test instead.
|
||||
- **A negative case belongs here when the server is what decides it**,
|
||||
and it is a first-class regression harness rather than a concession.
|
||||
What the rule above excludes is the browser deciding — a validation
|
||||
message, a disabled button. A refusal that crosses the process
|
||||
boundary is the opposite: "user A cannot open user B's document" is
|
||||
answered by core and nowhere else, so it meets the mock test as
|
||||
squarely as the happy path beside it, and it guards a class of bug
|
||||
that silently reappears the day someone drops an org filter.
|
||||
- **Access denials are grouped in
|
||||
[tests/access.test.ts](tests/access.test.ts)** rather than filed under
|
||||
the flow each one touches. A per-feature file never reaches them: a
|
||||
feature test always runs as the user who is supposed to have access,
|
||||
which is why a missing check survives a suite that covers every line.
|
||||
Naming follows the same rule as everywhere else — the group names the
|
||||
subject, each name completes "it …" with the denial it observes.
|
||||
- **The trust boundary is tested from outside the image**, in
|
||||
[tests/prod-trust-boundary.test.ts](tests/prod-trust-boundary.test.ts).
|
||||
Two independent things keep core's `/api/x/*` and auth-realtime's
|
||||
`/api/internal/*` unreachable — the services bind the container's
|
||||
loopback, and caddy refuses the prefixes at the front door — and the
|
||||
file asserts them separately, because either one alone is a single
|
||||
point of failure and a suite that conflated them would go green with
|
||||
one of them gone. The launcher runs its own bind check at boot; this
|
||||
asks the same question from the network, so a regression that took the
|
||||
boot gate with it is still caught.
|
||||
- The bind cases go through the `probe` container, and the group carries
|
||||
a **positive control** — the same probe reaching the front door — so a
|
||||
broken network cannot pass as a loopback bind. Every table-driven
|
||||
negative here needs one; without it the whole group is one typo away
|
||||
from proving nothing.
|
||||
- The front-door cases are sent with `rawRequest`, over `node:http`,
|
||||
because the point is what caddy does with a path and `fetch` would
|
||||
normalise `..`, duplicate slashes and encoding away before the request
|
||||
ever left. Two tables: spellings caddy is expected to recognise as the
|
||||
same request must be `403`, and traversal attempts must simply never
|
||||
succeed — asserting a specific status for those would couple the suite
|
||||
to caddy's normalisation rather than to the property that matters.
|
||||
- **A denial that does not hold yet is marked `test.fail()`**, with a
|
||||
comment saying what is missing and what to delete when it is fixed.
|
||||
That keeps the gap in the suite instead of in a backlog, and Playwright
|
||||
turns the annotation red the day the case starts passing. Check how
|
||||
long such a case takes when you add one: a test that hangs also
|
||||
registers as an expected failure, so a broken probe and a real gap
|
||||
look identical until you read the duration.
|
||||
|
||||
### A real backend, and no mocks
|
||||
|
||||
`web/` says only e2e is allowed a real backend. The counterpart here:
|
||||
**e2e is required to use it.**
|
||||
|
||||
- **Never `page.route()` an application endpoint, never stub a response,
|
||||
never seed the database out of band.** A stubbed e2e proves nothing
|
||||
the component tier had not already proven more cheaply.
|
||||
- **Reach state through the product's own surface.** An account exists
|
||||
because a test signed up for it. Where the UI genuinely offers no path,
|
||||
the product's HTTP API is the fallback — never SQL, which would let a
|
||||
test set up state the product itself cannot produce.
|
||||
- **Mail is real.** Verification links are read out of mailpit; nothing
|
||||
marks an account verified behind the app's back.
|
||||
- Needing a mock to make a case practical is the signal that the case
|
||||
belongs in the component tier.
|
||||
- Third-party edges (the OAuth providers) have no doubles here, which is
|
||||
why only email-password auth is covered.
|
||||
- **Collaboration runs as two real users.** `joinAsSecondUser` walks the
|
||||
whole journey a teammate takes: the owner sends the invitation from
|
||||
settings, and the invitee signs up, verifies, logs in and accepts the
|
||||
emailed invite in a fresh browser context — nothing is granted behind
|
||||
the product's back. Presence assertions key on the caret's name label
|
||||
(each side sees the *other* user's name), never its colour, which is
|
||||
random per client.
|
||||
|
||||
### Locators
|
||||
|
||||
- **Prefer role and placeholder over CSS**: `getByRole("button", { name })`,
|
||||
`getByPlaceholder(...)`. A restyle must not break a test that still
|
||||
describes the same user action.
|
||||
- **Never hardcode a translation.** Assert through `t()` from
|
||||
[helpers/i18n.ts](helpers/i18n.ts), which resolves the key against the
|
||||
same locale files the app renders with, interpolation included. A
|
||||
copied english string is a second, unmaintained definition of the
|
||||
message: it keeps passing when the copy changes, and a message edit
|
||||
silently breaks a suite that has nothing to do with it. The rules that
|
||||
follow from it are the `web/` ones:
|
||||
- **Interpolated messages take their values through `t()` too** —
|
||||
`t("onboarding.verify-email.sent-title", { email })`, never a
|
||||
hand-spliced string and never a bare prefix that skips the
|
||||
interpolation.
|
||||
- **Pick the key the component actually renders.** Many messages share
|
||||
an english value, so the wrong key passes today and diverges the day
|
||||
one of them is reworded. Follow the component to its key rather than
|
||||
grepping the locale file for the text.
|
||||
- **Overlays teleport out of the page's main tree** into `#teleports`.
|
||||
They are reachable from `page`, but not from a locator scoped to the
|
||||
container the trigger lives in.
|
||||
- **Strict mode is a feature.** When a locator matches more than one
|
||||
element, fix the locator — do not reach for `.first()`. A document
|
||||
name is a link twice (sidebar row and breadcrumb) and every tree row
|
||||
has an "Open Page Actions" button; `sidebarDocument()` and
|
||||
`openDocumentActions()` scope those for you.
|
||||
- **Read editor text through `editorText()`**, never `toHaveText` or
|
||||
`innerText` on the editor. A collaborator's caret is a decoration
|
||||
widget rendered inside the paragraph it sits in, label and all, so a
|
||||
plain text read of a shared document comes back with the other user's
|
||||
name spliced into the content.
|
||||
- **The title and the body are two editors.** `titleEditor()` is its
|
||||
own tiptap instance with a one-paragraph schema and its own yjs field;
|
||||
it is not the first heading of `contentEditor()`. Enter in the title
|
||||
moves the caret into the body rather than adding a line.
|
||||
- **Menus are appended to `<body>`.** The slash menu, dropdowns and
|
||||
dialogs are all outside the editor and sidebar subtrees; `openSlashMenu()`
|
||||
returns the menu, and dialogs come through `getByRole("dialog")`.
|
||||
|
||||
### Independence & concurrency
|
||||
|
||||
- **Every test creates its own account and its own workspace** via
|
||||
`newCredentials()` and `newWorkspace()`, which mint a unique email and
|
||||
a unique slug. Unique-per-test state is what makes `fullyParallel`
|
||||
safe against a single shared stack — the same independence rule
|
||||
`web/` states, enforced by construction rather than by mock resetting.
|
||||
A workspace slug is unique server-side, so two tests sharing one would
|
||||
fail on the second, exactly as two sharing an email would.
|
||||
- **Workers are capped at four locally and two on CI.** Every test's
|
||||
setup is several cross-service round trips against one core and one
|
||||
postgres; past a few workers the setup starts timing out, which reads
|
||||
as flaky tests when it is only load. CI gets fewer as headroom rather
|
||||
than because of any particular runner — it carries the stack and the
|
||||
browsers on one machine, and a green suite there is worth more than a
|
||||
slightly faster one. For the same reason the test budget is 60s, well
|
||||
above what a test takes locally, and the collaboration tests are marked
|
||||
slow, tripling theirs:
|
||||
each one runs two signups, two verifications, an invitation and its
|
||||
acceptance before the first assertion.
|
||||
- **Both suites run four workers, and turning that down is not how a
|
||||
flake gets fixed.** The prod stack contends harder than the dev one —
|
||||
caddy, web, core and auth-realtime share a single container there — and
|
||||
that pressure is worth keeping, because it is what surfaces the real
|
||||
defects. Every failure that only appeared at four turned out to be one:
|
||||
a form that read its session once instead of following it, sync
|
||||
connections that were never torn down when a tab closed, a dialog
|
||||
dismissed with one keypress when two were open, a toast asserted after
|
||||
it had auto-dismissed, and cross-service chains waited on with the
|
||||
default five seconds. Each was fixed where it lived. If a case fails
|
||||
only under load, that is the finding — chase it, do not widen the gap
|
||||
it hides in.
|
||||
- **No test depends on another's state or on execution order**, and no
|
||||
test cleans up after itself: the stack's database is thrown away
|
||||
wholesale, which is why it carries no volumes.
|
||||
- **No shared mutable module-level state.** Where several tests need the
|
||||
same starting point, each builds its own from a helper.
|
||||
- Browser contexts are per-test by default. Do not carry a signed-in
|
||||
session across tests unless a fixture makes that sharing explicit.
|
||||
|
||||
### Determinism
|
||||
|
||||
The `web/` rule — "never sleepy" — applies with more force here, because
|
||||
this tier has real network and real containers to be tempted by.
|
||||
|
||||
- **Never sleep.** No `page.waitForTimeout`, and no bare timeout used as
|
||||
a synchronisation device. Playwright's assertions already retry.
|
||||
- **Wait on the condition, not the clock**: `toHaveURL`, `toBeVisible`,
|
||||
`toHaveText`. For state that lives outside the page — a delivered
|
||||
email — `expect.poll` with a `message` naming what never arrived, so
|
||||
the timeout reports the missing thing instead of a bare expiry.
|
||||
- **Interact only after hydration.** Navigate with `visit()` from
|
||||
[helpers/page.ts](helpers/page.ts) rather than `page.goto` whenever the
|
||||
test then drives the page: server-rendered markup accepts clicks before
|
||||
vue has attached anything to it, and those clicks do nothing.
|
||||
- **Assert the exact URL when a parameter carries meaning.**
|
||||
`toHaveURL(".../login?verified=true")` distinguishes an activated
|
||||
account from a rejected token; a regex on the path alone would pass for
|
||||
both.
|
||||
- **`documentPersisted()` is the one deliberate wait on the clock.**
|
||||
Hocuspocus stores a document two seconds after its last change and
|
||||
nothing on the client reports when that happened, so a test that
|
||||
reloads to prove persistence waits out the debounce. It is the single
|
||||
permitted `waitForTimeout` in the suite and carries its disable reason.
|
||||
- **Three waits are raised above the default, all for the same
|
||||
reason**: the post-signup redirect, the post-login redirect and a cold
|
||||
document load. Each is a chain across auth-realtime, core and postgres
|
||||
— and in the document's case the websocket sync and a fade-in too —
|
||||
that several workers at once can stretch past five seconds while the
|
||||
product is entirely healthy. Any other raised timeout carries a
|
||||
comment saying what is slow and why.
|
||||
|
||||
### Failure artifacts
|
||||
|
||||
- `trace: "on-first-retry"` and `screenshot: "only-on-failure"`;
|
||||
`test-results/` and `playwright-report/` are gitignored.
|
||||
- `docker compose logs` from this directory gives the service side of a
|
||||
failure — the browser only ever shows one end of it.
|
||||
- A failure that reproduces only under load or only in CI is almost
|
||||
always a wait that was never really a wait. Fix the signal; do not
|
||||
raise the timeout.
|
||||
|
||||
### Prior art
|
||||
|
||||
A root-level, self-contained e2e package is the norm for repositories
|
||||
shaped like this one:
|
||||
[immich](https://github.com/immich-app/immich/tree/main/e2e) (`e2e/`
|
||||
beside `server/` and `web/`, own package.json and compose file),
|
||||
[grafana](https://github.com/grafana/grafana/tree/main/e2e-playwright),
|
||||
[mattermost](https://github.com/mattermost/mattermost/tree/master/e2e-tests)
|
||||
(beside `server/` and `webapp/`), and
|
||||
[cal.com](https://github.com/calcom/cal.com/tree/main/apps/web/playwright).
|
||||
None of them nest the suite under a `tests/` tier, and none share the
|
||||
frontend's toolchain.
|
||||
|
||||
## Formatting & TS
|
||||
|
||||
The same gates as `web/`, minus the ones that only make sense there.
|
||||
|
||||
Prettier uses **tabs, no semicolons, trailing commas**, and
|
||||
[.prettierignore](.prettierignore) keeps it off the lockfile and the
|
||||
markdown — the convention the sibling packages already follow.
|
||||
|
||||
ESLint ([eslint.config.mjs](eslint.config.mjs)) runs **type-aware**
|
||||
(`strictTypeChecked` + `stylisticTypeChecked` over a `projectService`),
|
||||
for one reason above all: it is what reports a promise that was never
|
||||
awaited. In a Playwright suite a missing `await` is not a style problem —
|
||||
the assertion never runs, and the test passes without having checked
|
||||
anything. It runs with `--max-warnings 0`, and `reportUnusedDisableDirectives`
|
||||
makes a stale disable an error, so every `eslint-disable` states its
|
||||
reason after `--` and is removed when it stops applying.
|
||||
|
||||
**`eslint-plugin-playwright` is this package's counterpart to `web/`'s
|
||||
`@vitest/eslint-plugin`,** and it enforces conventions this file would
|
||||
otherwise only ask for politely: `no-wait-for-timeout` is the "never
|
||||
sleep" rule, `no-focused-test` and `no-skipped-test` stop a `.only` or a
|
||||
`.skip` reaching main, and `missing-playwright-await` catches the
|
||||
unawaited assertion described above. It is scoped to
|
||||
[tests/](tests/) and [helpers/](helpers/).
|
||||
|
||||
**knip** guards dead exports, files and dependencies, and needs no config
|
||||
file: its Playwright plugin picks up both `playwright.*.config.ts` files,
|
||||
resolves `globalSetup` and `globalTeardown` from them, and treats the test
|
||||
files as entry points. Nothing here has `web/`'s auto-import or vendored-directory
|
||||
blind spots, so a `knip.ts` would only be a file to keep in sync.
|
||||
|
||||
TypeScript is strict, with `noUnusedLocals`, `noUnusedParameters`,
|
||||
`noUncheckedIndexedAccess`, `noImplicitOverride` and
|
||||
`verbatimModuleSyntax` on — the same posture as the other packages.
|
||||
|
||||
## CI
|
||||
|
||||
[.github/workflows/qa-e2e-dev.yml](../.github/workflows/qa-e2e-dev.yml) mirrors
|
||||
the other QA workflows: a `lint` job running `check-lint`, and a `test`
|
||||
job running `make e2e-dev`. The test job needs more than a node toolchain —
|
||||
it sets up Go and goreleaser, because the suite runs against images built
|
||||
from this repository and core's is not something compose can build. On
|
||||
failure it uploads the html report, which is why the CI reporter is
|
||||
`github` **and** `html`: annotations on the pull request, and a full
|
||||
report with traces to download.
|
||||
|
||||
**The triggers are asymmetric on purpose.** A merge to `main` runs the
|
||||
suite for a change anywhere that can break it — `web/`, `server/`,
|
||||
`docker/`, `e2e/` — because that is where regressions actually come from.
|
||||
A pull request only triggers it for changes to `e2e/` itself. A full run
|
||||
builds a nuxt image and costs real minutes, and paying that on every push
|
||||
of every frontend PR buys only a few minutes' warning over the run on
|
||||
main. `concurrency` supersedes earlier runs on
|
||||
every branch, `main` included — only the latest push's result matters,
|
||||
so a superseded run shows as cancelled rather than finishing.
|
||||
|
||||
**Layer caching is what keeps that affordable.** A runner starts with no
|
||||
layers, so the workflow sets up buildx and applies
|
||||
[docker-compose.dev.ci.yaml](docker-compose.dev.ci.yaml) through
|
||||
`E2E_DEV_COMPOSE_EXTRA` — a separate file because `type=gha` is meaningless
|
||||
outside a runner and would break every local build. Paired with the
|
||||
dependency layer in `web/Dockerfile.dev`, a push that leaves `web/`
|
||||
alone reuses the whole image instead of rebuilding it.
|
||||
|
||||
**The prod suite is
|
||||
[.github/workflows/qa-e2e-prod.yml](../.github/workflows/qa-e2e-prod.yml),
|
||||
and it has no trigger of its own.** No push, no pull request, no tag:
|
||||
[release.yml](../.github/workflows/release.yml) calls it, and refuses to
|
||||
publish the image unless it passes. `workflow_dispatch` remains for
|
||||
re-verifying an image without cutting a release. Where the dev suite guards
|
||||
day-to-day work, the prod suite answers a different question — does the
|
||||
artifact about to be published serve the product, and is its trust boundary
|
||||
intact — which is worth its minutes exactly when something is being
|
||||
released. Its cache reaches the build through `PROD_BUILD_EXTRA` rather
|
||||
than a compose override, because compose builds nothing in the prod stack:
|
||||
the image comes from `docker build` in the Makefile. That is also why
|
||||
[docker-compose.dev.ci.yaml](docker-compose.dev.ci.yaml) carries `dev` in
|
||||
its name with no prod counterpart to pair with.
|
||||
|
||||
**Being a release gate is what `workflow_call` is for.** A job cannot
|
||||
depend on a separate workflow's result, so release.yml is the one place
|
||||
that runs the gates and holds the publish behind `needs` — and every
|
||||
workflow it calls declares `workflow_call` alongside its own triggers. Path
|
||||
filters do not apply to a called workflow, so each one runs in full on a
|
||||
tag regardless of what that tag touched, which is what a release wants.
|
||||
Nothing is reused from an earlier run of the same commit either: a called
|
||||
workflow always executes, so a tag re-runs every gate against the tagged
|
||||
tree.
|
||||
|
||||
**The dev suite is deliberately not a gate**, and `qa-e2e-dev.yml` is
|
||||
therefore the one QA workflow that does not declare `workflow_call`. It
|
||||
runs the same flow tests as the prod suite, against a different assembly of
|
||||
the same product, while the prod suite runs them against the artifact
|
||||
actually being published — so gating on both would spend a nuxt image build
|
||||
and a browser run re-answering a question already answered about the thing
|
||||
that ships.
|
||||
|
||||
## Not covered yet
|
||||
|
||||
The desktop/Electron build. Playwright drives it through a different
|
||||
entry point (`_electron`) and its auth runs over IPC rather than cookies,
|
||||
so it is a separate track with its own launch mechanics — not a variant
|
||||
of these tests.
|
||||
@AGENTS.md
|
||||
|
|
|
|||
365
server/AGENTS.md
Normal file
365
server/AGENTS.md
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
# AGENTS.md
|
||||
|
||||
Guidance for the backend: `server/core`, `server/auth-realtime`, and the
|
||||
repo-root `datagen/`. Shared working principles and the TS/JS
|
||||
comment/whitespace rules live in the root [AGENTS.md](../AGENTS.md). Go
|
||||
engineering and testing standards (style, errors, logging, db layer, tests)
|
||||
live in [core/AGENTS.md](core/AGENTS.md) — this file covers only architecture,
|
||||
infrastructure, and the non-Go services.
|
||||
|
||||
## Stack overview
|
||||
|
||||
Three buildable components, orchestrated through `docker-compose`. What each one *is* is in the root [AGENTS.md](../AGENTS.md) map; what follows is the backend-specific detail.
|
||||
|
||||
- `server/core/` — one binary, `cmd/core` (`oxynote-core`), listening on `:8080`. Owns Postgres, Meilisearch, Valkey, object storage, the GitHub/Slack apps, the assistant, and the outbound data-source connections (`internal/datasource`). Its request surface is mapped below.
|
||||
- `server/auth-realtime/` — listens on `:8081`, runs the Better Auth organization plugin and Hocuspocus in one Hono process, and forwards non-auth `/api/...` traffic to core (`OXYNOTE_AUTH_REALTIME_BACKEND_URL`).
|
||||
- `datagen/` — separate Go module `github.com/oxynote/oxynote/datagen`. Synthesises demo Postgres/MariaDB content. Demo/testing only.
|
||||
|
||||
Go builds go through **goreleaser**: `make build` in `server/core` and `datagen` runs `goreleaser release --snapshot --clean`, producing binaries in `bin/` and the `ghcr.io/oxynote/{core,datagen}:dev` images via each module's `Dockerfile.dev`. The dev compose stack consumes those images; `auth-realtime` (and `web/`) are built from source by compose via their `Dockerfile.dev`. `make run` / `make start` from the repository root orchestrates both.
|
||||
|
||||
## Common commands
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
docker-compose -p oxynote -f docker/docker-compose.dev.yaml up # bring up the stack
|
||||
```
|
||||
|
||||
Per-component:
|
||||
|
||||
```sh
|
||||
# core
|
||||
cd server/core && make build # goreleaser release --snapshot --clean -> bin/
|
||||
|
||||
# datagen — same targets as core
|
||||
cd datagen && make build
|
||||
cd datagen && make test # go test -race ./... (pgdemo/mariademo need docker)
|
||||
cd datagen && make check-coverage # fails below COVERAGE_MIN
|
||||
cd datagen && make check-lint # golangci-lint; lint = the fixing variant
|
||||
|
||||
# auth-realtime — commands are in auth-realtime/AGENTS.md
|
||||
```
|
||||
|
||||
Both Go modules share the same test/lint workflow, documented in
|
||||
[core/AGENTS.md](core/AGENTS.md) ("Commands & QA gates"); datagen carries its
|
||||
own copy of core's golangci-lint profile. Its `pgdemo` and `mariademo` suites
|
||||
start throwaway Postgres and MariaDB containers, so docker is a unit-test
|
||||
dependency for those two packages the same way it is for core's `internal/db`
|
||||
and `internal/datasource/processor`.
|
||||
|
||||
Go dependencies are fetched into the module cache at build time (`make deps` runs `go mod download`) — there is no vendoring. All dependencies, including the first-party `github.com/oxynote/wetsocks`, are public; no GOPRIVATE or git auth setup is needed.
|
||||
|
||||
## Caddy / port layout
|
||||
|
||||
`docker/Caddyfile` is the entrypoint for all client traffic:
|
||||
|
||||
- `:8080` — front door. `/core/*` is path-stripped and reverse-proxied to `core:8080`; `/auth-realtime/*` is path-stripped and reverse-proxied to `auth-realtime:8081` (Better Auth, Hocuspocus, the merge proxy — the service itself still serves `/api/...` and `/hocuspocus`); everything else goes to the web SSR container (`web:3000`).
|
||||
- `:8082` — changedetection.io
|
||||
- `:8083` — Grafana (direct, not via Caddy)
|
||||
|
||||
So from a frontend's point of view: auth + realtime is `:8080/auth-realtime/...`, the core API is `:8080/core/api/...`. Internal-only Go routes (`/api/x/...`) are not exposed by Caddy; the GitHub and Slack callback URLs configured in those apps must point at `:8080/core/api/apps/...`, which is.
|
||||
|
||||
## Core request surface
|
||||
|
||||
`server/core/internal/server/router.go` is the canonical map. Three layers:
|
||||
|
||||
- `/api/...` (public): auth middleware (`internal/server/internal/auth`) validates sessions by calling auth-realtime's `/api/auth/get-session` (configured via `SERVER_AUTH_BETTER_AUTH_URL`). Routes for documents, branches, comments, reviewers, hooks, files, GitHub, Slack, notifications, data-sources, AI chat. `POST /api/documents/{id}/branches/{branchId}/blocks/{blockUid}/run` runs one block — `internal/document/blockrun` resolves it from stored branch content and dispatches on its type, which today means probing whether a simulated metric block's data has arrived. `GET /api/capabilities` is the frontend's capability signal: one boolean per optional service (`github`, `slack`, `changeDetection`, `search`), snapshotted at boot from each client's/manager's `Configured()`, plus an `aiAssistant` object (`status` + `model`) carrying the assistant's model-tier verdict decided in main.
|
||||
- `/api/x/...` (internal): no auth at all — reverse proxy must firewall. Used by auth-realtime to fetch/store branch content (`/x/documents/{id}/branches`, `/x/documents/{id}/branch/{branchId}`), trigger emails, and initialize or tear down orgs.
|
||||
- `/api/apps/...` (public, sessionless): where GitHub and Slack deliver. `POST /apps/github/events` and `POST /apps/slack/{events,commands,slash}` are gated by the provider's request signature; `GET /apps/slack/install` completes the direct-install OAuth exchange. These must stay outside `/api/x` — third parties reach core through the same front door as browsers, and the proxy 403s the internal subtree.
|
||||
- `/api/mcp` (public, bearer-authed): the MCP surface (`internal/server/internal/mcp`), a streamable HTTP MCP server bridging the assistant's ungated tool registry (`tools.Set.Entries`) plus documents-as-resources. Bearer tokens are JWTs issued by auth-realtime's `@better-auth/mcp` OAuth provider; core validates each request against auth-realtime's internal `GET /api/internal/mcp/session` (JWKS verify + consent-row check, so revoking a client 401s immediately) and scopes the tool list by the token's `documents:read` / `documents:write` / `data-sources:read` scopes. Tokens are org-bound via an `org_id` claim minted at issuance. `SERVER_MCP_SESSION_URL` and `SERVER_MCP_RESOURCE_URL` are required env; the Caddyfile routes `/.well-known/oauth-*` and `/api/auth/*` at the front door to auth-realtime for OAuth discovery.
|
||||
- WebSocket topics under `/api/ws` (routed by `wetsocks/wsserver` from the first-party `github.com/oxynote/wetsocks` library): `change@document-tree`, `change@tag-tree`, `change@documents.{documentId}.comments|metadata|reviewers|maintainers|tags|hooks`, `post@slack.messages`, `creation@notifications`, `ping@version`. Topic binders live on the per-domain handler types under `internal/server/internal/...` (`Handler.BindXxx`).
|
||||
|
||||
Most public routes in the README (`/api/documents`, `/api/documents/tree`, etc.) are served by core; the README is the closest thing to a contract spec — when changing handlers, update it.
|
||||
|
||||
## The demo data source
|
||||
|
||||
Every organization is seeded at initialization with a Prometheus data source named "Demo" whose URL is `demo://engineering`, plus the welcome document whose charts query it, plus three tags — Production (`#22c55e`), Staging (`#f97316`) and Incidents (`#3b82f6`), in that order — with the welcome document's default branch under Production. This is unconditional — there is no env var and no way to opt out, so a fresh install shows populated charts and a tags section with nothing else deployed. The tags are inserted in the welcome document's transaction, so a failed seed rolls the whole initialization back; only the data source is written before it, for the reason given at the call site.
|
||||
|
||||
- `internal/datasource/demo` synthesises those metrics in-process. `runner.ensurePrepared` picks it over the real client when the URL is exactly `demo://engineering`; any other `demo://` URL is `demo.ErrUnknownSource` rather than a second demo. The data source's type stays `prometheus`, so the editor, autocomplete and charts cannot tell the difference.
|
||||
- **One `demo.Client` serves the whole process, and it lives on `datasource.Manager`.** The client owns its engine, parser and registry, and the registry's walks cache the history they replay. A runner is built per call, so building the client there instead would throw that cache away on every request and re-walk every tick since the epoch.
|
||||
- **The timeline is a pure function of the tick, not stored data.** A fixed epoch and a one-minute tick, values computed on demand from a per-series seed — so history never rewrites, a query over any window backfills for free, and the same window always answers identically. `walk.checkpoint` caches one value per day-long segment; without it a sample would replay every tick since the epoch.
|
||||
- Queries run through the official `promql` engine over `queryable`, a `storage.Queryable` that synthesises only the requested window. Samples are spaced at the query's own step and the lookback delta is widened to match, which is what lets a year-long range answer with ~100 points per series instead of half a million.
|
||||
- `registry.go` is the single declaration of what exists: ~15 gauge families driving both sample synthesis and the metadata/label/series endpoints. `Test_welcomeDocumentQueries` runs every query in `internal/document/files/available_metrics.json` against it, so a metric renamed in one place and not the other fails the build rather than emptying the welcome document silently.
|
||||
|
||||
## Document storage / Hocuspocus integration
|
||||
|
||||
- Documents are organized into **branches** (`document_branches` table). Every document has at least one branch; the oldest = the default/main branch.
|
||||
- The Hocuspocus `documentName` is encoded as `"<documentId>-<branchIdentifier>"`. `branchIdentifier` may be the literal string `"default"`, in which case `resolveBranchId` in `server/auth-realtime/src/hocuspocus.ts` looks up the default branch ID. Splitting on the **first** `-` (`indexOf("-")`) is intentional; XIDs do not contain dashes but the suffix path can.
|
||||
- Branch content is stored two ways in `document_branches`: structured ProseMirror JSON in `content` (JSONB) and the canonical Yjs binary state in `raw_content` (base64-encoded over the wire). `raw_content` is authoritative for CRDT continuity.
|
||||
- `onLoadDocument` / `onStoreDocument` round-trip through core's internal `/api/x/documents/{id}/branch/{branchId}` endpoints. **Do not** use `Y.applyUpdate` to seed a freshly-created doc with content from another doc — the differing `clientID`s CRDT-merge and silently duplicate content. The codebase's pattern is `replaceYdocContent` in `server/auth-realtime/src/ydocument.ts`, which deep-clones `Y.XmlElement`s (preserving non-string attrs that `XmlElement.clone()` drops). Read the comments on `onLoadDocument` in `server/auth-realtime/src/hocuspocus.ts` and on `applyMergeToOpenDocument` in `server/auth-realtime/src/routes.ts` before touching this area.
|
||||
- **The main branch is identified by its `"default"` flag, not by its name.** The tree join and `FetchMainBranchContent` key on the flag; `UpdateDocumentBranch` refuses to rename the default branch so the two never disagree. Anything that creates a document's first branch — `NewDocument`, `Duplicate` — must set `Default: true`, and forks must not.
|
||||
- **Maintainers accumulate; nothing removes them.** The `maintainers` field on a branch update is *who edited in that persist*, not the document's maintainer set — auth-realtime's store hook sends the current session's editors and system writes send none. `UpsertDocumentMaintainers` only ever adds, so a diff against the stored set would drop everyone not editing at that moment. Removing a maintainer needs a path of its own; there is none today.
|
||||
- **Fork, branch update, merge and branch delete go through auth-realtime.** The web client calls `POST /documents/:documentId/branches`, `PUT /documents/:documentId/branches/:branchId`, `PUT /documents/:documentId/merge` and `DELETE /documents/:documentId/branches/:branchId` on auth-realtime, which flushes the affected branch's pending Hocuspocus store (both sides for a merge; the `-default` alias too for the default branch) and refuses the operation if that store fails, then proxies to core's session-authed group under `/api/x` with the caller's headers. Core reads the branch's stored row, so without the flush a fork copies stale content, a merge reads it, and a protect toggle makes the pending user store be refused. After core confirms a protection change, auth-realtime drops the socket of every client on the branch's document (`resetConnections` in `hocuspocus.ts`): read-only is decided per connection in `onAuthenticate`, so each client reconnects with the permission the branch now has. Hocuspocus's own `closeConnections` is not that: it sends a close *message* on the still-open multiplexed socket, and the provider leaves the document closed until the page reloads. A delete flushes nothing — there is no row left to store into — but resets the branch's connections the same way once core confirms it, since a client still on the branch would otherwise keep editing a live document of a branch that is gone, with every later store refused. A merge then **directly mutates the in-memory target-branch Y.Doc** via `replaceYdocContent` (not `applyUpdate`) and immediately persists `rawContent` back through `/api/x/.../branch/:branchId` so a server restart can't reset the clientID and duplicate content on reconnect.
|
||||
|
||||
## External resource lifecycle (files, hooks)
|
||||
|
||||
Uploaded images and file attachments live under `organizations/{org}/documents/{doc}/files/{blockUID}` — the file id **is** the image or file block's `uid`, so a re-upload into the same block overwrites its object in place — and are tracked in `document_files`, which also records the original name, size and the content type detected from the bytes; retrieval (`GET /api/documents/{id}/files/{fileId}-{fileName}`: every uid is a 21-character nanoid, so the id is cut off the front and the name, which the frontend appends so the address reads as the file, is ignored) resolves the object by the row's key and serves the type from the row with a `Content-Disposition` that is `inline` only for a fixed browser-viewable allowlist (`file.Viewable`). That key is an object key in the S3-compatible store, or a path under the storage directory on a deployment running without one; `internal/storage` holds what the two backends share and `internal/storage/{s3,fs}` implement them. Hooks hold external resources too (changedetection.io watchers, GitHub tracking). Both are reclaimed by sweeps, not by request handlers:
|
||||
|
||||
- **The row outlives its owner.** `document_files` and `document_hooks` reference documents, branches and organizations with `ON DELETE SET NULL`, so a deleted document — or an org deleted by Better Auth — leaves rows behind rather than destroying the only record of the external resource. `document_files.storage_key` stores the object key outright so it stays reachable once the FKs are gone.
|
||||
- **`internal/document/file/manager`** ticks every 5 minutes: it trims expired history entries, then reclaims files. A file is referenced if its id appears in any branch content of its document, any retained `document_branch_history_entries` row, or any comment/reply — checked in SQL (`CheckDocumentFileReferenced`), so a spurious match only ever retains. Unreferenced files are stamped `unreferenced_at` and deleted a day later; rows with NULL FKs skip the wait; files younger than a day are never touched, which covers the gap between an upload and the first content persist that mentions it.
|
||||
- **`internal/document/hook/manager`** does the same for hooks: a row whose branch, document or organization went NULL gets `Hook.Delete` (external teardown) before the row is dropped. A merge replaces the target's hooks by nulling their branch and document (`DetachDocumentHooksByBranchID`) rather than soft-deleting them: the soft-delete mark means "block gone" and the sweep lifts it while the block is still in the branch, which after a merge it is.
|
||||
- **Organization deletion is announced, not discovered.** Better Auth owns the organizations table, so core cannot notice a deletion on its own. `beforeDeleteOrganization` (`auth-realtime/src/auth.ts`) calls `POST /api/x/organizations/{id}/teardown` while every row still exists: core tears down the org's hooks, queues the search removal, drops the `slack_apps` and `github_installations` rows, and deletes the logo object. The hook throws if core fails, so the organization survives instead of leaving orphans nothing can address. Document files still need no action — the cascade nulls their keys and the file manager reclaims them.
|
||||
- **Ordering is inverted between create and delete.** Creating writes the row before the object (upload, duplication); deleting removes the object before the row. Hooks follow the same rule from outside the transaction: `copyHooksToBranch` runs after the fork, merge or duplicate commits, because `hook.NewHook` creates the watcher as a side effect and a rollback cannot take that back; a failed insert tears the just-created watcher down again. Both leave at most a row without an object, which the sweep resolves — an object without a row is invisible to every cleanup path.
|
||||
- **Duplication copies, never shares.** `Document.Duplicate` regenerates every block uid, rewrites image and file block `src` paths to the new document (swapping only the path, never the host), and returns two old→new maps: the file ids the handler uses to copy each object server-side into the duplicate's own folder, and the block uids through which the source branch's hooks are re-anchored on the copy (a hook whose block the map does not name is dropped). Files are therefore owned by exactly one document.
|
||||
- **Search jobs apply in order, per document.** A diff is a delta against the state its predecessors left, so the manager holds back every later diff of a document whose earlier one failed, rather than replaying it on top of newer work; an organization removal is global and holds everything after it. Without this a failed add replayed after a delete re-inserts the blocks of a document that no longer exists, and no later job would ever remove them.
|
||||
- **Search index entries are branch-scoped.** Every branch of every document is indexed; an entry's id is `<branchId>-<blockUid>` (`<branchId>-docname` for the name entry), since a fork copies its source's uids, and it carries `documentId`, `branchId`, `branchName` and `branchDefault`. Every path that changes a branch queues its diff through `search.Jobs` in the same transaction: a persist on any branch, a fork (a full add of the new branch), a rename (every entry of the branch changes, since each carries the name), a merge (the target's diff). A branch deletion queues a `RemovedBranches` removal the manager applies by `branchId` filter, and a document deletion clears every branch at once: `agent.DeleteDocument` collects the subtree ids before the row goes away and returns them, so the manager clears the index by `documentId` filter — the descendants the cascade destroys have no other trace to clean up by. Index settings are declared in one object in `search.Client` and sent only when they differ from what the index reports, since a searchable or filterable attribute change re-indexes everything; every write task is awaited, so a batch Meilisearch rejects fails its job and is retried in order.
|
||||
- History entries pin the files they reference, so `DB_MAX_DOCUMENT_HISTORY_ENTRIES` (count, trimmed on insert) and `DB_DOCUMENT_HISTORY_RETENTION` (age, trimmed in the sweep) also decide how long a removed image survives.
|
||||
|
||||
The ProseMirror schema is defined in `server/auth-realtime/src/schema/` (one file per block kind, `index.ts` aggregates). The Go-side mirror is `server/core/internal/document/node.go` (`RootBlock`, `Block`, `Mark`).
|
||||
|
||||
## Database
|
||||
|
||||
Postgres (image `postgres:18.6-alpine`). Migrations are embedded in the core binary from `server/core/internal/db/migrations/` and applied automatically by `db.New` on startup via `rubenv/sql-migrate`. Add new migrations as the next-numbered `NNN_<name>.sql` file — **once the product ships**. Until the first release there is only `001_initial.sql` and no deployed database to migrate, so a schema change goes into it in place rather than arriving as a rename-the-table follow-up nobody will ever need. The cost is local: an existing dev volume keeps the old schema, because sql-migrate records `001` as applied and will not re-run it, so reset the dev stack (`docker compose -p oxynote -f docker/docker-compose.dev.yaml down -v`) after editing it. The e2e stacks carry no volumes and are unaffected.
|
||||
|
||||
**Tags are per branch, visibility per user.** `tags` holds an organization's tags — the name is unique per organization, `sort_index` orders them and carries no uniqueness of its own — `document_branch_tags` pairs a branch with a tag, cascading from both so a deleted branch, document or tag takes its assignments with it, and `user_tag_settings` records the tags a user keeps out of their own sidebar, cascading from both users and tags, with no row reading as visible. The sidebar's tag tree (`FetchTagTree`) lists a document under a tag by its default branch; the header reads the open branch's own tags from `GET /documents/{documentId}/branches/{branchId}/tags`, which is also where a tag is assigned and removed. A fork copies the source branch's tags in its transaction, a merge makes the target carry exactly the source's the way it takes the source's name and icon, and a duplicate copies the source default branch's tags onto the copy's default branch.
|
||||
|
||||
**The core migrations own all tables, including the Better Auth ones** (`users`, `user_accounts`, `organizations`, … are created by `001_initial.sql` with snake_case columns matching the `fields` mappings in `src/auth.ts`). The generated `better_auth_schema.sql` is **reference output only — never apply it directly**; regenerate it to diff what Better Auth expects after changing `src/auth.ts`:
|
||||
|
||||
```sh
|
||||
# from server/auth-realtime/, with OXYNOTE_AUTH_REALTIME_DB_DSN exported and other auth env vars set
|
||||
source ../../docker/env/auth-realtime.local.env && npx @better-auth/cli generate --output ./sql/better_auth_schema.sql
|
||||
```
|
||||
|
||||
Then the generated `server/auth-realtime/sql/better_auth_schema.sql` is executed against the Postgres container manually. See the README "Better Auth" section.
|
||||
|
||||
## Env vars
|
||||
|
||||
Env lives in `docker/env/`: committed `*.example.env` templates list **every** variable each service reads, explicitly, even when empty, with dev defaults preset. `make setup` copies each template to its gitignored `*.local.env` when missing — the dev compose reads **only** the `.local.env` files, so secrets and local tweaks go there.
|
||||
|
||||
- core vars are prefixed `OXYNOTE_CORE_`; `buildinfo.Getenv("FOO")` reads `OXYNOTE_CORE_FOO`.
|
||||
- auth-realtime vars are prefixed `OXYNOTE_AUTH_REALTIME_`.
|
||||
- Every integration is optional and keyed on one variable. Setting that key with the
|
||||
rest of its group missing or invalid is a boot error; leaving it empty disables the
|
||||
feature — core boots, the feature's routes answer `<domain>.not_configured`, and its
|
||||
background work is skipped. `GET /api/capabilities` reports what ended up enabled.
|
||||
- Keys: `GITHUB_APP_ID` (private key in `docker/github/`), `SLACK_CLIENT_ID`,
|
||||
`ASSISTANT_PROVIDER`, `MEILISEARCH_DSN`, `CHANGEDETECTION_API_URL`, `VALKEY_ADDRESS`,
|
||||
`S3_URL`, `EMAIL_SMTP_HOST`. `docker/env/*.example.env` lists every variable each
|
||||
service reads.
|
||||
- Two of these degrade rather than switch off, and both silently confine the deployment
|
||||
to one instance. An empty `S3_URL` keeps objects in a directory on the local
|
||||
filesystem (`internal/storage/fs`, the counterpart of `internal/storage/s3`) rooted at
|
||||
`S3_LOCAL_PATH` — that path has no default and an unset one is a boot error, so objects
|
||||
never land somewhere the deployment did not choose. An empty `VALKEY_ADDRESS` holds
|
||||
the assistant's conversation history, checkpoints and pending confirmations in the
|
||||
core process (`pkg/memkit`), losing every conversation on restart. A model too weak
|
||||
for the assistant likewise disables it with a warning instead of failing boot.
|
||||
|
||||
## Assistant prompt
|
||||
|
||||
The system prompt at `server/core/internal/assistant/prompt.go` is assembled from section
|
||||
constants. The block model, edit etiquette and aesthetics sections ship verbatim to both the
|
||||
chat model and MCP clients, so a rule that is only true on one surface (the confirmation
|
||||
flow, the Rubber Duck persona) lives in that surface's own section. Behaviour rules come
|
||||
first and are restated in two lines at the end, with the block-model reference between them:
|
||||
models weight the ends of a prompt over its middle.
|
||||
|
||||
Writing a rule:
|
||||
|
||||
- State the principle and the reason behind it in one or two sentences; the model
|
||||
generalises from the reason. Don't enumerate edge cases or add numbered steps.
|
||||
- Say what to do rather than what to avoid, unless the rule is a hard limit.
|
||||
- Keep per-tool facts (when to use a tool, what it returns, what it fails on) in the tool's
|
||||
description; the prompt carries only the workflow across tools.
|
||||
- Worked examples and tables belong in the prompt only when the model genuinely needs the
|
||||
structure to anchor a concept (the block-schema table and the split_doc example qualify;
|
||||
most rules don't). Wrap an example in `<example>` tags so it can't be read as a rule.
|
||||
- Plain language, no caps or MUST: current models overtrigger on emphasis.
|
||||
- No em dashes and British spelling throughout model-facing text; the model copies the
|
||||
punctuation it is shown, and the product bans em dashes. `prompt_test.go` and
|
||||
`tools_test.go` enforce both.
|
||||
|
||||
## Assistant tools
|
||||
|
||||
Tools are grouped by what they act on, a few per file: `document.go` (the tree, one
|
||||
document read whole, and a document's name, icon and place), `block.go` (reading one
|
||||
block and editing content), `tag.go` (the organisation's tags and which branch
|
||||
carries them), `hook.go` (the freshness hooks on a branch), `search.go`,
|
||||
`datasource.go` (reads against the organisation's outbound connections). `eino.go` is
|
||||
the odd one out — it holds the agent-framework adapter and `read_tool_output`, the one
|
||||
tool that exists because of eino rather than because of the domain. `tools.Set` is the
|
||||
only place that names every tool; adding one means writing its type and adding a line to
|
||||
that list.
|
||||
|
||||
**Tools do not implement eino's interfaces.** A tool implements this package's own
|
||||
`Tool` — `Info`, `Traits`, `Title`, `Summary`, `Execute` — and `eino.go` translates that
|
||||
into what the framework calls. Nothing else in the package imports eino. A surface
|
||||
outside the package reaches a tool through `Entry`: `Entry.Info` to describe it
|
||||
(`Info.Schema()` states the arguments once, for every surface that has to publish them)
|
||||
and `Entry.Tool`, a `Runner`, to run it.
|
||||
|
||||
**A call reports what it changed; it is never asked.** `Runner.Run` returns a `Result`
|
||||
carrying the output and `Documents`. Every document write goes through one of the
|
||||
`Input` methods — `ApplyEdit`, `CreateDocument`, `MoveDocument`, `DeleteDocument`,
|
||||
`AssignTag`, `UnassignTag`, `CreateHook`, `UpdateHook`, `ResetHook`, `DeleteHook` — and
|
||||
all but the document delete record the document as the mutation happens. A document
|
||||
delete records nothing: the document it names is gone. A hook delete does record the
|
||||
branch: it stays, and the editor showing it has to redraw. The tag-only writes
|
||||
(`CreateTag`, `UpdateTag`, `DeleteTag`, `MoveTag`) change no document and record none.
|
||||
|
||||
**Tag writes announce themselves like tree writes.** Every tag tool's `Execute` ends
|
||||
with `Input.NotifyTagTreeChange`, which reaches the tag handler's exported
|
||||
`NotifyTreeChange` through `tools.TagNotifier`; `server.NewServer` hands it to the
|
||||
manager with `SetTagNotifier` right beside `SetTreeNotifier`, so a tag changed by the
|
||||
assistant or an MCP client refreshes connected sidebars the way the HTTP handlers do.
|
||||
`assign_tag` and `unassign_tag` also call `NotifyBranchTagsChange`, which publishes on
|
||||
`change@documents.{documentId}.tags` so the header of an open document redraws its
|
||||
pills; the sidebar tree alone would not reach it, since the header reads the branch's
|
||||
own tags.
|
||||
`Input.FetchTag` resolves a tag id through the repository's `FetchTagTree` (the one
|
||||
read carrying name, colour and documents together), refusing an unknown id with
|
||||
`ErrUnknownTag`.
|
||||
|
||||
**Hook writes share the HTTP handler's lifecycle and announce themselves the same
|
||||
way.** `Input.CreateHook`, `UpdateHook`, `ResetHook` and `DeleteHook` drive
|
||||
`hook.NewHook`, `Hook.ApplyUpdate`, `Hook.Reset` and `Hook.Delete` with a `hook.Input`
|
||||
built from the GitHub manager and changedetection client `Deps` carries, so a watcher
|
||||
created or torn down by the assistant is the same watcher the HTTP handler would have
|
||||
made. Each write then calls the hook handler's exported `NotifyHooksChange` through
|
||||
`tools.HookNotifier` (`server.NewServer` wires it with `SetHookNotifier`, beside the tag
|
||||
one), which publishes on `change@documents.{documentId}.hooks` so an open editor redraws
|
||||
its hook indicators; the HTTP handler publishes on the same topic after its own writes.
|
||||
`CreateHook` refuses a `block_uid` the branch does not hold with `errUnknownBlock` and a
|
||||
github-tracking or url-watcher hook on a deployment without that integration, before
|
||||
anything is created; `Input.FetchHook` scopes the lookup to the organisation and refuses
|
||||
a hook of another document, or of a deleted one, with `errUnknownHook` naming the
|
||||
document addressed. Settings arrive as one `settings` object whose shape the hook type
|
||||
decides, the way a block's type decides its fields: `decodeHookSettings` switches on
|
||||
the type and unmarshals into the processor's own struct, requiring that type's fields
|
||||
and refusing any other type's naming its owner (`hookSettingOwner`). A scheduled
|
||||
reminder is stored with scale `linear` and duration `custom`, which is how the editor
|
||||
labels a concrete date.
|
||||
|
||||
**A block write reports what it wrote from the operation, never from a re-read.** The
|
||||
realtime service applies an edit to the live document and persists it on a debounce, so
|
||||
content read back right after a write may not show it. A write therefore expands its
|
||||
block once (`expandForWrite`) to learn the uids it will land with, ships the canonical
|
||||
form carrying those uids, and returns `blockRows` of the expanded tree; a write to an
|
||||
existing block reads it (`Input.FetchDocumentBlock`) before the edit and patches the change
|
||||
onto that. Depth in those rows counts from the block itself.
|
||||
|
||||
**Branches are addressed by id, always.** Every content tool (`get_document`, `read_block`,
|
||||
the block writes) requires `branch_id`; the default branch is a branch like any other, and
|
||||
its id travels with every listing (`list_documents` carries `default_branch_id`, a search
|
||||
hit carries the `branch_id` it was found on, `create_document` returns `branch_id`,
|
||||
`get_document` lists every branch with its id). `Input.FetchBranch`, `FetchDocumentContent`, `FetchDocumentBlock`, `ApplyEdit` and the
|
||||
placement checks take the branch id and resolve it through `FetchDocumentByBranchID`,
|
||||
refusing a branch that belongs to another document; an unknown id is refused with the
|
||||
branches the document has (`ErrUnknownBranch`, each as "name (id)"), and a protected branch
|
||||
reads but refuses every write, naming the unprotected branches to write to instead.
|
||||
`Input.FetchDocument` is the default-branch fetch the document-level tools use to name and
|
||||
change a document; they stay branch-free. Search covers every branch and each hit names
|
||||
its own. No tool creates, renames, deletes or merges a branch. MCP resources name a
|
||||
document and a branch (`oxynote://documents/{id}/branches/{branch_id}`); the list carries
|
||||
each document on its default branch. The chat client reports the document and branch it
|
||||
is viewing; the session stores both unchecked, as a hint the next call verifies.
|
||||
|
||||
**Every error names the next step.** The realtime service's operation errors are
|
||||
rewritten at the boundary (`describeOpError`): a uid it holds no block for points at
|
||||
`get_document`, and its operation kinds are named as the model's tools. Errors the
|
||||
package raises itself follow the same shape (`errUnknownDocument`, `errUnknownBlock`,
|
||||
`errBranchProtected`, `errUnknownParent`): what was wrong, then the tool or argument
|
||||
that fixes it. The service's own messages stay as they are; it is not a model surface.
|
||||
|
||||
**`Decode` is the only way into a call's arguments.** There is no lenient variant:
|
||||
`Title`, `Summary` and `Execute` all decode and all return an error, so no description is
|
||||
built from the zero values a failed unmarshal left behind. `Decode` takes an `Args` — a
|
||||
type with `Validate() error` — so a tool states what it requires next to the fields that
|
||||
carry it (`errRequired(key)` → `<tool>: <key> is required`). Every tool has a named
|
||||
`<tool>Args` type, read tools included.
|
||||
|
||||
Arguments are typed, not parsed: ids are `xid.ID` (optional ones
|
||||
`null.Value[xid.ID]`), timestamps `time.Time`, enums their own self-validating type, and
|
||||
`Decode` runs on `encoding/json/v2` so a rejected value is reported with its JSON path
|
||||
(`within "/document_id"`). An empty string is an invalid value, never "absent". Ids stay
|
||||
`xid.ID` all the way to the wire; only the protocol and MCP edges render strings.
|
||||
|
||||
A `Title` or `Summary` that names its target fetches it — `inp.FetchDocument`,
|
||||
`inp.FetchDataSource` — and uses the name on the row; a target that does not resolve is an
|
||||
error passed on, never a placeholder. An unreadable payload ends no turn: the gate hands
|
||||
the rejection back as the call's result, and `Set.Label` turns it into an empty label.
|
||||
|
||||
`Input` is built per call and carries its context, raw arguments, and every resource a
|
||||
tool reaches through, already scoped to the session's (organisation, user) pair — so a
|
||||
tool cannot reach another organisation's documents. Its reads keep the repository's
|
||||
`Fetch` prefix (`FetchDocument`, `FetchBranch`, `FetchTagTree`, ...), so a tool reads
|
||||
the same verb the db layer uses. `DescribeInput` is its read-only
|
||||
half, handed to `Title` and `Confirm`. Shared work that needs dependencies goes on
|
||||
`Input`; dependency-free helpers live in `util.go`.
|
||||
|
||||
**A write owns its invariants.** Checks that decide whether a mutation is legal live on
|
||||
the `Input` method that performs it, not in the tool's `Execute` — `MoveDocument`
|
||||
refuses a missing parent or a move under the document's own subtree, `CreateDocument`
|
||||
refuses a missing parent. A tool parses its arguments and hands them over.
|
||||
|
||||
**One tool interface.** Every tool satisfies `Tool`; the ones that propose nothing embed
|
||||
`plainSummary`, plain reads embed `plainTraits`, and tools too generic to announce embed
|
||||
`plainTitle`, so a read never restates the three lines a mixin already says. Whether a tool writes is asked of `Traits.Write` alone — never a marker
|
||||
interface, which would be a second fact to keep in step. What a tool is, it states in one
|
||||
`Traits()`:
|
||||
|
||||
- `Write` gates it behind user confirmation **and** protects its result from the context
|
||||
middlewares. A tool declaring it must describe the change in `Summary`, and is the only
|
||||
kind ever asked for one; `Test_New` checks that a write produces one and nothing else
|
||||
does.
|
||||
- `Destructive` keeps it outside an "approve all" answer. Only `delete_document`,
|
||||
`delete_block`, `delete_tag` and `delete_hook`.
|
||||
- `Overwrites` says the write replaces content the caller did not name — the target's
|
||||
nested blocks, and the uids comments and hooks hang off, go with it.
|
||||
`update_block_text` and `replace_block`. It is what MCP's destructive hint reports
|
||||
alongside `Destructive`, and is deliberately separate from it: a client deciding
|
||||
whether to auto-approve needs to know, while an approve-all inside a chat turn is
|
||||
meant to cover exactly these edits.
|
||||
- `DataSource` says the tool reads an outbound connection rather than the organisation's
|
||||
documents. Over MCP those answer to their own `data-sources:read` scope and are
|
||||
annotated open-world.
|
||||
- `Internal` keeps it off surfaces outside this process. Only `read_tool_output`, whose
|
||||
paths are minted by the reduction middleware during a chat turn.
|
||||
|
||||
The confirmation gate is applied by the registry from those traits, not by each tool, so
|
||||
a write cannot skip it by forgetting to ask.
|
||||
|
||||
The MCP surface (`internal/server/internal/mcp`) serves the same registry **ungated** via
|
||||
`Set.Entries`, minus the internal ones — MCP clients own the approval story, and the
|
||||
write/destructive/overwrites facts become MCP tool annotations. It builds each `mcp.Tool` inline
|
||||
from `Entry.Info` and calls `Entry.Tool.Run`, so it never touches eino; every document in
|
||||
`Result.Documents` comes back as a resource link. Adding a tool to `tools.Set` extends
|
||||
the MCP server for free.
|
||||
|
||||
### Tool descriptions
|
||||
|
||||
A description is what the model reads to pick a tool and fill its arguments, and it has to
|
||||
stand alone: an MCP client may never show the instructions text. Every description says, in
|
||||
three or more sentences for anything non-trivial:
|
||||
|
||||
- what the tool does and what it returns;
|
||||
- when to use it, and which sibling tool to use instead;
|
||||
- the precondition or failure it can hit (a protected document, an illegal placement, a
|
||||
missing uid);
|
||||
- for each argument, what it means, whether it is optional, and the default.
|
||||
|
||||
It says nothing about confirmation or approval: the chat surface confirms writes and MCP
|
||||
clients apply them at once, so approval belongs in the per-surface prompt sections and in
|
||||
the MCP annotations. Plain language, no caps or MUST, no em dashes, British spelling.
|
||||
|
||||
`testdata/tool_schemas.golden` pins every tool's model-facing description. Regenerate it
|
||||
with `UPDATE_GOLDEN=1 go test -run Test_Info_toEino ./internal/assistant/tools/` and review
|
||||
the diff as the change — a silently altered description degrades the assistant in ways no
|
||||
other test catches.
|
||||
|
||||
## auth-realtime
|
||||
|
||||
The service has its own [AGENTS.md](auth-realtime/AGENTS.md) covering its
|
||||
composition-root structure, zod-parsed environment, type-aware lint setup, and
|
||||
testing conventions. From here:
|
||||
|
||||
- **`src/index.ts` is the only module with side effects.** Everything else is
|
||||
a factory taking its dependencies as an argument — which is what lets the
|
||||
service be tested without a database, a redis, or a port. A change that
|
||||
reads `process.env` or opens a connection outside `index.ts` breaks that.
|
||||
- Prettier uses **tabs at width 8** (matching gofmt's convention on this side
|
||||
of the repo), no semicolons, trailing commas, double quotes. ESLint runs
|
||||
type-aware, and **ES module imports must include the explicit `.js`
|
||||
extension** (NodeNext + ESM), even when importing TypeScript files — e.g.
|
||||
`import { foo } from "./bar.js"` for a `./bar.ts` source file.
|
||||
|
||||
366
server/CLAUDE.md
366
server/CLAUDE.md
|
|
@ -1,365 +1 @@
|
|||
# CLAUDE.md
|
||||
|
||||
Guidance for the backend: `server/core`, `server/auth-realtime`, and the
|
||||
repo-root `datagen/`. Shared working principles and the TS/JS
|
||||
comment/whitespace rules live in the root [CLAUDE.md](../CLAUDE.md). Go
|
||||
engineering and testing standards (style, errors, logging, db layer, tests)
|
||||
live in [core/CLAUDE.md](core/CLAUDE.md) — this file covers only architecture,
|
||||
infrastructure, and the non-Go services.
|
||||
|
||||
## Stack overview
|
||||
|
||||
Three buildable components, orchestrated through `docker-compose`. What each one *is* is in the root [CLAUDE.md](../CLAUDE.md) map; what follows is the backend-specific detail.
|
||||
|
||||
- `server/core/` — one binary, `cmd/core` (`oxynote-core`), listening on `:8080`. Owns Postgres, Meilisearch, Valkey, object storage, the GitHub/Slack apps, the assistant, and the outbound data-source connections (`internal/datasource`). Its request surface is mapped below.
|
||||
- `server/auth-realtime/` — listens on `:8081`, runs the Better Auth organization plugin and Hocuspocus in one Hono process, and forwards non-auth `/api/...` traffic to core (`OXYNOTE_AUTH_REALTIME_BACKEND_URL`).
|
||||
- `datagen/` — separate Go module `github.com/oxynote/oxynote/datagen`. Synthesises demo Postgres/MariaDB content. Demo/testing only.
|
||||
|
||||
Go builds go through **goreleaser**: `make build` in `server/core` and `datagen` runs `goreleaser release --snapshot --clean`, producing binaries in `bin/` and the `ghcr.io/oxynote/{core,datagen}:dev` images via each module's `Dockerfile.dev`. The dev compose stack consumes those images; `auth-realtime` (and `web/`) are built from source by compose via their `Dockerfile.dev`. `make run` / `make start` from the repository root orchestrates both.
|
||||
|
||||
## Common commands
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
docker-compose -p oxynote -f docker/docker-compose.dev.yaml up # bring up the stack
|
||||
```
|
||||
|
||||
Per-component:
|
||||
|
||||
```sh
|
||||
# core
|
||||
cd server/core && make build # goreleaser release --snapshot --clean -> bin/
|
||||
|
||||
# datagen — same targets as core
|
||||
cd datagen && make build
|
||||
cd datagen && make test # go test -race ./... (pgdemo/mariademo need docker)
|
||||
cd datagen && make check-coverage # fails below COVERAGE_MIN
|
||||
cd datagen && make check-lint # golangci-lint; lint = the fixing variant
|
||||
|
||||
# auth-realtime — commands are in auth-realtime/CLAUDE.md
|
||||
```
|
||||
|
||||
Both Go modules share the same test/lint workflow, documented in
|
||||
[core/CLAUDE.md](core/CLAUDE.md) ("Commands & QA gates"); datagen carries its
|
||||
own copy of core's golangci-lint profile. Its `pgdemo` and `mariademo` suites
|
||||
start throwaway Postgres and MariaDB containers, so docker is a unit-test
|
||||
dependency for those two packages the same way it is for core's `internal/db`
|
||||
and `internal/datasource/processor`.
|
||||
|
||||
Go dependencies are fetched into the module cache at build time (`make deps` runs `go mod download`) — there is no vendoring. All dependencies, including the first-party `github.com/oxynote/wetsocks`, are public; no GOPRIVATE or git auth setup is needed.
|
||||
|
||||
## Caddy / port layout
|
||||
|
||||
`docker/Caddyfile` is the entrypoint for all client traffic:
|
||||
|
||||
- `:8080` — front door. `/core/*` is path-stripped and reverse-proxied to `core:8080`; `/auth-realtime/*` is path-stripped and reverse-proxied to `auth-realtime:8081` (Better Auth, Hocuspocus, the merge proxy — the service itself still serves `/api/...` and `/hocuspocus`); everything else goes to the web SSR container (`web:3000`).
|
||||
- `:8082` — changedetection.io
|
||||
- `:8083` — Grafana (direct, not via Caddy)
|
||||
|
||||
So from a frontend's point of view: auth + realtime is `:8080/auth-realtime/...`, the core API is `:8080/core/api/...`. Internal-only Go routes (`/api/x/...`) are not exposed by Caddy; the GitHub and Slack callback URLs configured in those apps must point at `:8080/core/api/apps/...`, which is.
|
||||
|
||||
## Core request surface
|
||||
|
||||
`server/core/internal/server/router.go` is the canonical map. Three layers:
|
||||
|
||||
- `/api/...` (public): auth middleware (`internal/server/internal/auth`) validates sessions by calling auth-realtime's `/api/auth/get-session` (configured via `SERVER_AUTH_BETTER_AUTH_URL`). Routes for documents, branches, comments, reviewers, hooks, files, GitHub, Slack, notifications, data-sources, AI chat. `POST /api/documents/{id}/branches/{branchId}/blocks/{blockUid}/run` runs one block — `internal/document/blockrun` resolves it from stored branch content and dispatches on its type, which today means probing whether a simulated metric block's data has arrived. `GET /api/capabilities` is the frontend's capability signal: one boolean per optional service (`github`, `slack`, `changeDetection`, `search`), snapshotted at boot from each client's/manager's `Configured()`, plus an `aiAssistant` object (`status` + `model`) carrying the assistant's model-tier verdict decided in main.
|
||||
- `/api/x/...` (internal): no auth at all — reverse proxy must firewall. Used by auth-realtime to fetch/store branch content (`/x/documents/{id}/branches`, `/x/documents/{id}/branch/{branchId}`), trigger emails, and initialize or tear down orgs.
|
||||
- `/api/apps/...` (public, sessionless): where GitHub and Slack deliver. `POST /apps/github/events` and `POST /apps/slack/{events,commands,slash}` are gated by the provider's request signature; `GET /apps/slack/install` completes the direct-install OAuth exchange. These must stay outside `/api/x` — third parties reach core through the same front door as browsers, and the proxy 403s the internal subtree.
|
||||
- `/api/mcp` (public, bearer-authed): the MCP surface (`internal/server/internal/mcp`), a streamable HTTP MCP server bridging the assistant's ungated tool registry (`tools.Set.Entries`) plus documents-as-resources. Bearer tokens are JWTs issued by auth-realtime's `@better-auth/mcp` OAuth provider; core validates each request against auth-realtime's internal `GET /api/internal/mcp/session` (JWKS verify + consent-row check, so revoking a client 401s immediately) and scopes the tool list by the token's `documents:read` / `documents:write` / `data-sources:read` scopes. Tokens are org-bound via an `org_id` claim minted at issuance. `SERVER_MCP_SESSION_URL` and `SERVER_MCP_RESOURCE_URL` are required env; the Caddyfile routes `/.well-known/oauth-*` and `/api/auth/*` at the front door to auth-realtime for OAuth discovery.
|
||||
- WebSocket topics under `/api/ws` (routed by `wetsocks/wsserver` from the first-party `github.com/oxynote/wetsocks` library): `change@document-tree`, `change@tag-tree`, `change@documents.{documentId}.comments|metadata|reviewers|maintainers|tags|hooks`, `post@slack.messages`, `creation@notifications`, `ping@version`. Topic binders live on the per-domain handler types under `internal/server/internal/...` (`Handler.BindXxx`).
|
||||
|
||||
Most public routes in the README (`/api/documents`, `/api/documents/tree`, etc.) are served by core; the README is the closest thing to a contract spec — when changing handlers, update it.
|
||||
|
||||
## The demo data source
|
||||
|
||||
Every organization is seeded at initialization with a Prometheus data source named "Demo" whose URL is `demo://engineering`, plus the welcome document whose charts query it, plus three tags — Production (`#22c55e`), Staging (`#f97316`) and Incidents (`#3b82f6`), in that order — with the welcome document's default branch under Production. This is unconditional — there is no env var and no way to opt out, so a fresh install shows populated charts and a tags section with nothing else deployed. The tags are inserted in the welcome document's transaction, so a failed seed rolls the whole initialization back; only the data source is written before it, for the reason given at the call site.
|
||||
|
||||
- `internal/datasource/demo` synthesises those metrics in-process. `runner.ensurePrepared` picks it over the real client when the URL is exactly `demo://engineering`; any other `demo://` URL is `demo.ErrUnknownSource` rather than a second demo. The data source's type stays `prometheus`, so the editor, autocomplete and charts cannot tell the difference.
|
||||
- **One `demo.Client` serves the whole process, and it lives on `datasource.Manager`.** The client owns its engine, parser and registry, and the registry's walks cache the history they replay. A runner is built per call, so building the client there instead would throw that cache away on every request and re-walk every tick since the epoch.
|
||||
- **The timeline is a pure function of the tick, not stored data.** A fixed epoch and a one-minute tick, values computed on demand from a per-series seed — so history never rewrites, a query over any window backfills for free, and the same window always answers identically. `walk.checkpoint` caches one value per day-long segment; without it a sample would replay every tick since the epoch.
|
||||
- Queries run through the official `promql` engine over `queryable`, a `storage.Queryable` that synthesises only the requested window. Samples are spaced at the query's own step and the lookback delta is widened to match, which is what lets a year-long range answer with ~100 points per series instead of half a million.
|
||||
- `registry.go` is the single declaration of what exists: ~15 gauge families driving both sample synthesis and the metadata/label/series endpoints. `Test_welcomeDocumentQueries` runs every query in `internal/document/files/available_metrics.json` against it, so a metric renamed in one place and not the other fails the build rather than emptying the welcome document silently.
|
||||
|
||||
## Document storage / Hocuspocus integration
|
||||
|
||||
- Documents are organized into **branches** (`document_branches` table). Every document has at least one branch; the oldest = the default/main branch.
|
||||
- The Hocuspocus `documentName` is encoded as `"<documentId>-<branchIdentifier>"`. `branchIdentifier` may be the literal string `"default"`, in which case `resolveBranchId` in `server/auth-realtime/src/hocuspocus.ts` looks up the default branch ID. Splitting on the **first** `-` (`indexOf("-")`) is intentional; XIDs do not contain dashes but the suffix path can.
|
||||
- Branch content is stored two ways in `document_branches`: structured ProseMirror JSON in `content` (JSONB) and the canonical Yjs binary state in `raw_content` (base64-encoded over the wire). `raw_content` is authoritative for CRDT continuity.
|
||||
- `onLoadDocument` / `onStoreDocument` round-trip through core's internal `/api/x/documents/{id}/branch/{branchId}` endpoints. **Do not** use `Y.applyUpdate` to seed a freshly-created doc with content from another doc — the differing `clientID`s CRDT-merge and silently duplicate content. The codebase's pattern is `replaceYdocContent` in `server/auth-realtime/src/ydocument.ts`, which deep-clones `Y.XmlElement`s (preserving non-string attrs that `XmlElement.clone()` drops). Read the comments on `onLoadDocument` in `server/auth-realtime/src/hocuspocus.ts` and on `applyMergeToOpenDocument` in `server/auth-realtime/src/routes.ts` before touching this area.
|
||||
- **The main branch is identified by its `"default"` flag, not by its name.** The tree join and `FetchMainBranchContent` key on the flag; `UpdateDocumentBranch` refuses to rename the default branch so the two never disagree. Anything that creates a document's first branch — `NewDocument`, `Duplicate` — must set `Default: true`, and forks must not.
|
||||
- **Maintainers accumulate; nothing removes them.** The `maintainers` field on a branch update is *who edited in that persist*, not the document's maintainer set — auth-realtime's store hook sends the current session's editors and system writes send none. `UpsertDocumentMaintainers` only ever adds, so a diff against the stored set would drop everyone not editing at that moment. Removing a maintainer needs a path of its own; there is none today.
|
||||
- **Fork, branch update, merge and branch delete go through auth-realtime.** The web client calls `POST /documents/:documentId/branches`, `PUT /documents/:documentId/branches/:branchId`, `PUT /documents/:documentId/merge` and `DELETE /documents/:documentId/branches/:branchId` on auth-realtime, which flushes the affected branch's pending Hocuspocus store (both sides for a merge; the `-default` alias too for the default branch) and refuses the operation if that store fails, then proxies to core's session-authed group under `/api/x` with the caller's headers. Core reads the branch's stored row, so without the flush a fork copies stale content, a merge reads it, and a protect toggle makes the pending user store be refused. After core confirms a protection change, auth-realtime drops the socket of every client on the branch's document (`resetConnections` in `hocuspocus.ts`): read-only is decided per connection in `onAuthenticate`, so each client reconnects with the permission the branch now has. Hocuspocus's own `closeConnections` is not that: it sends a close *message* on the still-open multiplexed socket, and the provider leaves the document closed until the page reloads. A delete flushes nothing — there is no row left to store into — but resets the branch's connections the same way once core confirms it, since a client still on the branch would otherwise keep editing a live document of a branch that is gone, with every later store refused. A merge then **directly mutates the in-memory target-branch Y.Doc** via `replaceYdocContent` (not `applyUpdate`) and immediately persists `rawContent` back through `/api/x/.../branch/:branchId` so a server restart can't reset the clientID and duplicate content on reconnect.
|
||||
|
||||
## External resource lifecycle (files, hooks)
|
||||
|
||||
Uploaded images and file attachments live under `organizations/{org}/documents/{doc}/files/{blockUID}` — the file id **is** the image or file block's `uid`, so a re-upload into the same block overwrites its object in place — and are tracked in `document_files`, which also records the original name, size and the content type detected from the bytes; retrieval (`GET /api/documents/{id}/files/{fileId}-{fileName}`: every uid is a 21-character nanoid, so the id is cut off the front and the name, which the frontend appends so the address reads as the file, is ignored) resolves the object by the row's key and serves the type from the row with a `Content-Disposition` that is `inline` only for a fixed browser-viewable allowlist (`file.Viewable`). That key is an object key in the S3-compatible store, or a path under the storage directory on a deployment running without one; `internal/storage` holds what the two backends share and `internal/storage/{s3,fs}` implement them. Hooks hold external resources too (changedetection.io watchers, GitHub tracking). Both are reclaimed by sweeps, not by request handlers:
|
||||
|
||||
- **The row outlives its owner.** `document_files` and `document_hooks` reference documents, branches and organizations with `ON DELETE SET NULL`, so a deleted document — or an org deleted by Better Auth — leaves rows behind rather than destroying the only record of the external resource. `document_files.storage_key` stores the object key outright so it stays reachable once the FKs are gone.
|
||||
- **`internal/document/file/manager`** ticks every 5 minutes: it trims expired history entries, then reclaims files. A file is referenced if its id appears in any branch content of its document, any retained `document_branch_history_entries` row, or any comment/reply — checked in SQL (`CheckDocumentFileReferenced`), so a spurious match only ever retains. Unreferenced files are stamped `unreferenced_at` and deleted a day later; rows with NULL FKs skip the wait; files younger than a day are never touched, which covers the gap between an upload and the first content persist that mentions it.
|
||||
- **`internal/document/hook/manager`** does the same for hooks: a row whose branch, document or organization went NULL gets `Hook.Delete` (external teardown) before the row is dropped. A merge replaces the target's hooks by nulling their branch and document (`DetachDocumentHooksByBranchID`) rather than soft-deleting them: the soft-delete mark means "block gone" and the sweep lifts it while the block is still in the branch, which after a merge it is.
|
||||
- **Organization deletion is announced, not discovered.** Better Auth owns the organizations table, so core cannot notice a deletion on its own. `beforeDeleteOrganization` (`auth-realtime/src/auth.ts`) calls `POST /api/x/organizations/{id}/teardown` while every row still exists: core tears down the org's hooks, queues the search removal, drops the `slack_apps` and `github_installations` rows, and deletes the logo object. The hook throws if core fails, so the organization survives instead of leaving orphans nothing can address. Document files still need no action — the cascade nulls their keys and the file manager reclaims them.
|
||||
- **Ordering is inverted between create and delete.** Creating writes the row before the object (upload, duplication); deleting removes the object before the row. Hooks follow the same rule from outside the transaction: `copyHooksToBranch` runs after the fork, merge or duplicate commits, because `hook.NewHook` creates the watcher as a side effect and a rollback cannot take that back; a failed insert tears the just-created watcher down again. Both leave at most a row without an object, which the sweep resolves — an object without a row is invisible to every cleanup path.
|
||||
- **Duplication copies, never shares.** `Document.Duplicate` regenerates every block uid, rewrites image and file block `src` paths to the new document (swapping only the path, never the host), and returns two old→new maps: the file ids the handler uses to copy each object server-side into the duplicate's own folder, and the block uids through which the source branch's hooks are re-anchored on the copy (a hook whose block the map does not name is dropped). Files are therefore owned by exactly one document.
|
||||
- **Search jobs apply in order, per document.** A diff is a delta against the state its predecessors left, so the manager holds back every later diff of a document whose earlier one failed, rather than replaying it on top of newer work; an organization removal is global and holds everything after it. Without this a failed add replayed after a delete re-inserts the blocks of a document that no longer exists, and no later job would ever remove them.
|
||||
- **Search index entries are branch-scoped.** Every branch of every document is indexed; an entry's id is `<branchId>-<blockUid>` (`<branchId>-docname` for the name entry), since a fork copies its source's uids, and it carries `documentId`, `branchId`, `branchName` and `branchDefault`. Every path that changes a branch queues its diff through `search.Jobs` in the same transaction: a persist on any branch, a fork (a full add of the new branch), a rename (every entry of the branch changes, since each carries the name), a merge (the target's diff). A branch deletion queues a `RemovedBranches` removal the manager applies by `branchId` filter, and a document deletion clears every branch at once: `agent.DeleteDocument` collects the subtree ids before the row goes away and returns them, so the manager clears the index by `documentId` filter — the descendants the cascade destroys have no other trace to clean up by. Index settings are declared in one object in `search.Client` and sent only when they differ from what the index reports, since a searchable or filterable attribute change re-indexes everything; every write task is awaited, so a batch Meilisearch rejects fails its job and is retried in order.
|
||||
- History entries pin the files they reference, so `DB_MAX_DOCUMENT_HISTORY_ENTRIES` (count, trimmed on insert) and `DB_DOCUMENT_HISTORY_RETENTION` (age, trimmed in the sweep) also decide how long a removed image survives.
|
||||
|
||||
The ProseMirror schema is defined in `server/auth-realtime/src/schema/` (one file per block kind, `index.ts` aggregates). The Go-side mirror is `server/core/internal/document/node.go` (`RootBlock`, `Block`, `Mark`).
|
||||
|
||||
## Database
|
||||
|
||||
Postgres (image `postgres:18.6-alpine`). Migrations are embedded in the core binary from `server/core/internal/db/migrations/` and applied automatically by `db.New` on startup via `rubenv/sql-migrate`. Add new migrations as the next-numbered `NNN_<name>.sql` file — **once the product ships**. Until the first release there is only `001_initial.sql` and no deployed database to migrate, so a schema change goes into it in place rather than arriving as a rename-the-table follow-up nobody will ever need. The cost is local: an existing dev volume keeps the old schema, because sql-migrate records `001` as applied and will not re-run it, so reset the dev stack (`docker compose -p oxynote -f docker/docker-compose.dev.yaml down -v`) after editing it. The e2e stacks carry no volumes and are unaffected.
|
||||
|
||||
**Tags are per branch, visibility per user.** `tags` holds an organization's tags — the name is unique per organization, `sort_index` orders them and carries no uniqueness of its own — `document_branch_tags` pairs a branch with a tag, cascading from both so a deleted branch, document or tag takes its assignments with it, and `user_tag_settings` records the tags a user keeps out of their own sidebar, cascading from both users and tags, with no row reading as visible. The sidebar's tag tree (`FetchTagTree`) lists a document under a tag by its default branch; the header reads the open branch's own tags from `GET /documents/{documentId}/branches/{branchId}/tags`, which is also where a tag is assigned and removed. A fork copies the source branch's tags in its transaction, a merge makes the target carry exactly the source's the way it takes the source's name and icon, and a duplicate copies the source default branch's tags onto the copy's default branch.
|
||||
|
||||
**The core migrations own all tables, including the Better Auth ones** (`users`, `user_accounts`, `organizations`, … are created by `001_initial.sql` with snake_case columns matching the `fields` mappings in `src/auth.ts`). The generated `better_auth_schema.sql` is **reference output only — never apply it directly**; regenerate it to diff what Better Auth expects after changing `src/auth.ts`:
|
||||
|
||||
```sh
|
||||
# from server/auth-realtime/, with OXYNOTE_AUTH_REALTIME_DB_DSN exported and other auth env vars set
|
||||
source ../../docker/env/auth-realtime.local.env && npx @better-auth/cli generate --output ./sql/better_auth_schema.sql
|
||||
```
|
||||
|
||||
Then the generated `server/auth-realtime/sql/better_auth_schema.sql` is executed against the Postgres container manually. See the README "Better Auth" section.
|
||||
|
||||
## Env vars
|
||||
|
||||
Env lives in `docker/env/`: committed `*.example.env` templates list **every** variable each service reads, explicitly, even when empty, with dev defaults preset. `make setup` copies each template to its gitignored `*.local.env` when missing — the dev compose reads **only** the `.local.env` files, so secrets and local tweaks go there.
|
||||
|
||||
- core vars are prefixed `OXYNOTE_CORE_`; `buildinfo.Getenv("FOO")` reads `OXYNOTE_CORE_FOO`.
|
||||
- auth-realtime vars are prefixed `OXYNOTE_AUTH_REALTIME_`.
|
||||
- Every integration is optional and keyed on one variable. Setting that key with the
|
||||
rest of its group missing or invalid is a boot error; leaving it empty disables the
|
||||
feature — core boots, the feature's routes answer `<domain>.not_configured`, and its
|
||||
background work is skipped. `GET /api/capabilities` reports what ended up enabled.
|
||||
- Keys: `GITHUB_APP_ID` (private key in `docker/github/`), `SLACK_CLIENT_ID`,
|
||||
`ASSISTANT_PROVIDER`, `MEILISEARCH_DSN`, `CHANGEDETECTION_API_URL`, `VALKEY_ADDRESS`,
|
||||
`S3_URL`, `EMAIL_SMTP_HOST`. `docker/env/*.example.env` lists every variable each
|
||||
service reads.
|
||||
- Two of these degrade rather than switch off, and both silently confine the deployment
|
||||
to one instance. An empty `S3_URL` keeps objects in a directory on the local
|
||||
filesystem (`internal/storage/fs`, the counterpart of `internal/storage/s3`) rooted at
|
||||
`S3_LOCAL_PATH` — that path has no default and an unset one is a boot error, so objects
|
||||
never land somewhere the deployment did not choose. An empty `VALKEY_ADDRESS` holds
|
||||
the assistant's conversation history, checkpoints and pending confirmations in the
|
||||
core process (`pkg/memkit`), losing every conversation on restart. A model too weak
|
||||
for the assistant likewise disables it with a warning instead of failing boot.
|
||||
|
||||
## Assistant prompt
|
||||
|
||||
The system prompt at `server/core/internal/assistant/prompt.go` is assembled from section
|
||||
constants. The block model, edit etiquette and aesthetics sections ship verbatim to both the
|
||||
chat model and MCP clients, so a rule that is only true on one surface (the confirmation
|
||||
flow, the Rubber Duck persona) lives in that surface's own section. Behaviour rules come
|
||||
first and are restated in two lines at the end, with the block-model reference between them:
|
||||
models weight the ends of a prompt over its middle.
|
||||
|
||||
Writing a rule:
|
||||
|
||||
- State the principle and the reason behind it in one or two sentences; the model
|
||||
generalises from the reason. Don't enumerate edge cases or add numbered steps.
|
||||
- Say what to do rather than what to avoid, unless the rule is a hard limit.
|
||||
- Keep per-tool facts (when to use a tool, what it returns, what it fails on) in the tool's
|
||||
description; the prompt carries only the workflow across tools.
|
||||
- Worked examples and tables belong in the prompt only when the model genuinely needs the
|
||||
structure to anchor a concept (the block-schema table and the split_doc example qualify;
|
||||
most rules don't). Wrap an example in `<example>` tags so it can't be read as a rule.
|
||||
- Plain language, no caps or MUST: current models overtrigger on emphasis.
|
||||
- No em dashes and British spelling throughout model-facing text; the model copies the
|
||||
punctuation it is shown, and the product bans em dashes. `prompt_test.go` and
|
||||
`tools_test.go` enforce both.
|
||||
|
||||
## Assistant tools
|
||||
|
||||
Tools are grouped by what they act on, a few per file: `document.go` (the tree, one
|
||||
document read whole, and a document's name, icon and place), `block.go` (reading one
|
||||
block and editing content), `tag.go` (the organisation's tags and which branch
|
||||
carries them), `hook.go` (the freshness hooks on a branch), `search.go`,
|
||||
`datasource.go` (reads against the organisation's outbound connections). `eino.go` is
|
||||
the odd one out — it holds the agent-framework adapter and `read_tool_output`, the one
|
||||
tool that exists because of eino rather than because of the domain. `tools.Set` is the
|
||||
only place that names every tool; adding one means writing its type and adding a line to
|
||||
that list.
|
||||
|
||||
**Tools do not implement eino's interfaces.** A tool implements this package's own
|
||||
`Tool` — `Info`, `Traits`, `Title`, `Summary`, `Execute` — and `eino.go` translates that
|
||||
into what the framework calls. Nothing else in the package imports eino. A surface
|
||||
outside the package reaches a tool through `Entry`: `Entry.Info` to describe it
|
||||
(`Info.Schema()` states the arguments once, for every surface that has to publish them)
|
||||
and `Entry.Tool`, a `Runner`, to run it.
|
||||
|
||||
**A call reports what it changed; it is never asked.** `Runner.Run` returns a `Result`
|
||||
carrying the output and `Documents`. Every document write goes through one of the
|
||||
`Input` methods — `ApplyEdit`, `CreateDocument`, `MoveDocument`, `DeleteDocument`,
|
||||
`AssignTag`, `UnassignTag`, `CreateHook`, `UpdateHook`, `ResetHook`, `DeleteHook` — and
|
||||
all but the document delete record the document as the mutation happens. A document
|
||||
delete records nothing: the document it names is gone. A hook delete does record the
|
||||
branch: it stays, and the editor showing it has to redraw. The tag-only writes
|
||||
(`CreateTag`, `UpdateTag`, `DeleteTag`, `MoveTag`) change no document and record none.
|
||||
|
||||
**Tag writes announce themselves like tree writes.** Every tag tool's `Execute` ends
|
||||
with `Input.NotifyTagTreeChange`, which reaches the tag handler's exported
|
||||
`NotifyTreeChange` through `tools.TagNotifier`; `server.NewServer` hands it to the
|
||||
manager with `SetTagNotifier` right beside `SetTreeNotifier`, so a tag changed by the
|
||||
assistant or an MCP client refreshes connected sidebars the way the HTTP handlers do.
|
||||
`assign_tag` and `unassign_tag` also call `NotifyBranchTagsChange`, which publishes on
|
||||
`change@documents.{documentId}.tags` so the header of an open document redraws its
|
||||
pills; the sidebar tree alone would not reach it, since the header reads the branch's
|
||||
own tags.
|
||||
`Input.FetchTag` resolves a tag id through the repository's `FetchTagTree` (the one
|
||||
read carrying name, colour and documents together), refusing an unknown id with
|
||||
`ErrUnknownTag`.
|
||||
|
||||
**Hook writes share the HTTP handler's lifecycle and announce themselves the same
|
||||
way.** `Input.CreateHook`, `UpdateHook`, `ResetHook` and `DeleteHook` drive
|
||||
`hook.NewHook`, `Hook.ApplyUpdate`, `Hook.Reset` and `Hook.Delete` with a `hook.Input`
|
||||
built from the GitHub manager and changedetection client `Deps` carries, so a watcher
|
||||
created or torn down by the assistant is the same watcher the HTTP handler would have
|
||||
made. Each write then calls the hook handler's exported `NotifyHooksChange` through
|
||||
`tools.HookNotifier` (`server.NewServer` wires it with `SetHookNotifier`, beside the tag
|
||||
one), which publishes on `change@documents.{documentId}.hooks` so an open editor redraws
|
||||
its hook indicators; the HTTP handler publishes on the same topic after its own writes.
|
||||
`CreateHook` refuses a `block_uid` the branch does not hold with `errUnknownBlock` and a
|
||||
github-tracking or url-watcher hook on a deployment without that integration, before
|
||||
anything is created; `Input.FetchHook` scopes the lookup to the organisation and refuses
|
||||
a hook of another document, or of a deleted one, with `errUnknownHook` naming the
|
||||
document addressed. Settings arrive as one `settings` object whose shape the hook type
|
||||
decides, the way a block's type decides its fields: `decodeHookSettings` switches on
|
||||
the type and unmarshals into the processor's own struct, requiring that type's fields
|
||||
and refusing any other type's naming its owner (`hookSettingOwner`). A scheduled
|
||||
reminder is stored with scale `linear` and duration `custom`, which is how the editor
|
||||
labels a concrete date.
|
||||
|
||||
**A block write reports what it wrote from the operation, never from a re-read.** The
|
||||
realtime service applies an edit to the live document and persists it on a debounce, so
|
||||
content read back right after a write may not show it. A write therefore expands its
|
||||
block once (`expandForWrite`) to learn the uids it will land with, ships the canonical
|
||||
form carrying those uids, and returns `blockRows` of the expanded tree; a write to an
|
||||
existing block reads it (`Input.FetchDocumentBlock`) before the edit and patches the change
|
||||
onto that. Depth in those rows counts from the block itself.
|
||||
|
||||
**Branches are addressed by id, always.** Every content tool (`get_document`, `read_block`,
|
||||
the block writes) requires `branch_id`; the default branch is a branch like any other, and
|
||||
its id travels with every listing (`list_documents` carries `default_branch_id`, a search
|
||||
hit carries the `branch_id` it was found on, `create_document` returns `branch_id`,
|
||||
`get_document` lists every branch with its id). `Input.FetchBranch`, `FetchDocumentContent`, `FetchDocumentBlock`, `ApplyEdit` and the
|
||||
placement checks take the branch id and resolve it through `FetchDocumentByBranchID`,
|
||||
refusing a branch that belongs to another document; an unknown id is refused with the
|
||||
branches the document has (`ErrUnknownBranch`, each as "name (id)"), and a protected branch
|
||||
reads but refuses every write, naming the unprotected branches to write to instead.
|
||||
`Input.FetchDocument` is the default-branch fetch the document-level tools use to name and
|
||||
change a document; they stay branch-free. Search covers every branch and each hit names
|
||||
its own. No tool creates, renames, deletes or merges a branch. MCP resources name a
|
||||
document and a branch (`oxynote://documents/{id}/branches/{branch_id}`); the list carries
|
||||
each document on its default branch. The chat client reports the document and branch it
|
||||
is viewing; the session stores both unchecked, as a hint the next call verifies.
|
||||
|
||||
**Every error names the next step.** The realtime service's operation errors are
|
||||
rewritten at the boundary (`describeOpError`): a uid it holds no block for points at
|
||||
`get_document`, and its operation kinds are named as the model's tools. Errors the
|
||||
package raises itself follow the same shape (`errUnknownDocument`, `errUnknownBlock`,
|
||||
`errBranchProtected`, `errUnknownParent`): what was wrong, then the tool or argument
|
||||
that fixes it. The service's own messages stay as they are; it is not a model surface.
|
||||
|
||||
**`Decode` is the only way into a call's arguments.** There is no lenient variant:
|
||||
`Title`, `Summary` and `Execute` all decode and all return an error, so no description is
|
||||
built from the zero values a failed unmarshal left behind. `Decode` takes an `Args` — a
|
||||
type with `Validate() error` — so a tool states what it requires next to the fields that
|
||||
carry it (`errRequired(key)` → `<tool>: <key> is required`). Every tool has a named
|
||||
`<tool>Args` type, read tools included.
|
||||
|
||||
Arguments are typed, not parsed: ids are `xid.ID` (optional ones
|
||||
`null.Value[xid.ID]`), timestamps `time.Time`, enums their own self-validating type, and
|
||||
`Decode` runs on `encoding/json/v2` so a rejected value is reported with its JSON path
|
||||
(`within "/document_id"`). An empty string is an invalid value, never "absent". Ids stay
|
||||
`xid.ID` all the way to the wire; only the protocol and MCP edges render strings.
|
||||
|
||||
A `Title` or `Summary` that names its target fetches it — `inp.FetchDocument`,
|
||||
`inp.FetchDataSource` — and uses the name on the row; a target that does not resolve is an
|
||||
error passed on, never a placeholder. An unreadable payload ends no turn: the gate hands
|
||||
the rejection back as the call's result, and `Set.Label` turns it into an empty label.
|
||||
|
||||
`Input` is built per call and carries its context, raw arguments, and every resource a
|
||||
tool reaches through, already scoped to the session's (organisation, user) pair — so a
|
||||
tool cannot reach another organisation's documents. Its reads keep the repository's
|
||||
`Fetch` prefix (`FetchDocument`, `FetchBranch`, `FetchTagTree`, ...), so a tool reads
|
||||
the same verb the db layer uses. `DescribeInput` is its read-only
|
||||
half, handed to `Title` and `Confirm`. Shared work that needs dependencies goes on
|
||||
`Input`; dependency-free helpers live in `util.go`.
|
||||
|
||||
**A write owns its invariants.** Checks that decide whether a mutation is legal live on
|
||||
the `Input` method that performs it, not in the tool's `Execute` — `MoveDocument`
|
||||
refuses a missing parent or a move under the document's own subtree, `CreateDocument`
|
||||
refuses a missing parent. A tool parses its arguments and hands them over.
|
||||
|
||||
**One tool interface.** Every tool satisfies `Tool`; the ones that propose nothing embed
|
||||
`plainSummary`, plain reads embed `plainTraits`, and tools too generic to announce embed
|
||||
`plainTitle`, so a read never restates the three lines a mixin already says. Whether a tool writes is asked of `Traits.Write` alone — never a marker
|
||||
interface, which would be a second fact to keep in step. What a tool is, it states in one
|
||||
`Traits()`:
|
||||
|
||||
- `Write` gates it behind user confirmation **and** protects its result from the context
|
||||
middlewares. A tool declaring it must describe the change in `Summary`, and is the only
|
||||
kind ever asked for one; `Test_New` checks that a write produces one and nothing else
|
||||
does.
|
||||
- `Destructive` keeps it outside an "approve all" answer. Only `delete_document`,
|
||||
`delete_block`, `delete_tag` and `delete_hook`.
|
||||
- `Overwrites` says the write replaces content the caller did not name — the target's
|
||||
nested blocks, and the uids comments and hooks hang off, go with it.
|
||||
`update_block_text` and `replace_block`. It is what MCP's destructive hint reports
|
||||
alongside `Destructive`, and is deliberately separate from it: a client deciding
|
||||
whether to auto-approve needs to know, while an approve-all inside a chat turn is
|
||||
meant to cover exactly these edits.
|
||||
- `DataSource` says the tool reads an outbound connection rather than the organisation's
|
||||
documents. Over MCP those answer to their own `data-sources:read` scope and are
|
||||
annotated open-world.
|
||||
- `Internal` keeps it off surfaces outside this process. Only `read_tool_output`, whose
|
||||
paths are minted by the reduction middleware during a chat turn.
|
||||
|
||||
The confirmation gate is applied by the registry from those traits, not by each tool, so
|
||||
a write cannot skip it by forgetting to ask.
|
||||
|
||||
The MCP surface (`internal/server/internal/mcp`) serves the same registry **ungated** via
|
||||
`Set.Entries`, minus the internal ones — MCP clients own the approval story, and the
|
||||
write/destructive/overwrites facts become MCP tool annotations. It builds each `mcp.Tool` inline
|
||||
from `Entry.Info` and calls `Entry.Tool.Run`, so it never touches eino; every document in
|
||||
`Result.Documents` comes back as a resource link. Adding a tool to `tools.Set` extends
|
||||
the MCP server for free.
|
||||
|
||||
### Tool descriptions
|
||||
|
||||
A description is what the model reads to pick a tool and fill its arguments, and it has to
|
||||
stand alone: an MCP client may never show the instructions text. Every description says, in
|
||||
three or more sentences for anything non-trivial:
|
||||
|
||||
- what the tool does and what it returns;
|
||||
- when to use it, and which sibling tool to use instead;
|
||||
- the precondition or failure it can hit (a protected document, an illegal placement, a
|
||||
missing uid);
|
||||
- for each argument, what it means, whether it is optional, and the default.
|
||||
|
||||
It says nothing about confirmation or approval: the chat surface confirms writes and MCP
|
||||
clients apply them at once, so approval belongs in the per-surface prompt sections and in
|
||||
the MCP annotations. Plain language, no caps or MUST, no em dashes, British spelling.
|
||||
|
||||
`testdata/tool_schemas.golden` pins every tool's model-facing description. Regenerate it
|
||||
with `UPDATE_GOLDEN=1 go test -run Test_Info_toEino ./internal/assistant/tools/` and review
|
||||
the diff as the change — a silently altered description degrades the assistant in ways no
|
||||
other test catches.
|
||||
|
||||
## auth-realtime
|
||||
|
||||
The service has its own [CLAUDE.md](auth-realtime/CLAUDE.md) covering its
|
||||
composition-root structure, zod-parsed environment, type-aware lint setup, and
|
||||
testing conventions. From here:
|
||||
|
||||
- **`src/index.ts` is the only module with side effects.** Everything else is
|
||||
a factory taking its dependencies as an argument — which is what lets the
|
||||
service be tested without a database, a redis, or a port. A change that
|
||||
reads `process.env` or opens a connection outside `index.ts` breaks that.
|
||||
- Prettier uses **tabs at width 8** (matching gofmt's convention on this side
|
||||
of the repo), no semicolons, trailing commas, double quotes. ESLint runs
|
||||
type-aware, and **ES module imports must include the explicit `.js`
|
||||
extension** (NodeNext + ESM), even when importing TypeScript files — e.g.
|
||||
`import { foo } from "./bar.js"` for a `./bar.ts` source file.
|
||||
|
||||
@AGENTS.md
|
||||
|
|
|
|||
385
server/auth-realtime/AGENTS.md
Normal file
385
server/auth-realtime/AGENTS.md
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
# AGENTS.md
|
||||
|
||||
Guidance for `server/auth-realtime`. Shared working principles and the TS/JS
|
||||
comment/whitespace rules live in the root [AGENTS.md](../../AGENTS.md);
|
||||
backend architecture, the Caddy/port layout, and the document-storage model
|
||||
live in [server/AGENTS.md](../AGENTS.md).
|
||||
|
||||
## Project
|
||||
|
||||
`@oxynote/auth-realtime` — a TypeScript ES-module service running Better Auth
|
||||
and a Hocuspocus (Yjs) server in a single Hono process on port `8081`. It is a
|
||||
thin layer: it owns sessions, the realtime document connection, and the
|
||||
service-to-service endpoints core calls back into. Everything about documents
|
||||
themselves belongs to core.
|
||||
|
||||
## Common commands
|
||||
|
||||
Package manager is **pnpm** (own workspace).
|
||||
|
||||
```bash
|
||||
pnpm install # install dependencies
|
||||
pnpm dev # tsx watch, sentry preloaded via --import
|
||||
pnpm build # tsc -p tsconfig.build.json -> dist/
|
||||
pnpm build:bundle # esbuild single-file bundle -> dist/bundle.mjs;
|
||||
# what both docker images run (sentry folded in,
|
||||
# optional native accelerators left external)
|
||||
pnpm start # run the built service
|
||||
|
||||
pnpm check-lint # check-types + check-eslint + check-fmt +
|
||||
# check-knip (read-only)
|
||||
pnpm lint # fixing variant: check-types + knip --fix
|
||||
# (removes dead exports/files!) + eslint --fix +
|
||||
# prettier --write
|
||||
pnpm test # vitest run --coverage
|
||||
pnpm test-watch # vitest in watch mode
|
||||
pnpm qa # check-lint + test; qa-fix = lint + test
|
||||
```
|
||||
|
||||
## Composition root
|
||||
|
||||
**`src/index.ts` is the only module with side effects.** It is the one place
|
||||
that reads the environment, opens a database pool, connects to Valkey (only
|
||||
when `OXYNOTE_AUTH_REALTIME_VALKEY_DSN` is set — without it better-auth is
|
||||
built with no secondary storage at all), or listens on a port. Everything
|
||||
else is a factory taking what it needs:
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts composition root: env -> wiring -> listen
|
||||
sentry.ts the sentry bootstrap, loaded before the app graph exists
|
||||
bundle.ts the docker entry: sentry + index folded into one esbuild
|
||||
artifact (build:bundle), so no --import preload and no
|
||||
node_modules ship in the images
|
||||
env.ts zod-parsed, typed configuration
|
||||
core.ts the client for every call this service makes into core
|
||||
db.ts the Store: every query this service runs, behind one
|
||||
interface
|
||||
reporting.ts reported() / bestEffort(), the two ways a failure reaches
|
||||
sentry
|
||||
logging.ts createLogger(level, destination): the pino logger
|
||||
everything the service writes goes through
|
||||
headers.ts normalizing whatever shape a transport hands over
|
||||
auth.ts createAuth(deps) + the callbacks better-auth invokes
|
||||
hocuspocus.ts createHocuspocus(deps) / createDocumentHooks(deps); the
|
||||
flushDocument the branch routes run before core reads a
|
||||
branch's stored row
|
||||
routes.ts createRoutes(deps) -> a Hono app
|
||||
operations.ts pure: applying the assistant's edit ops to a Y.Doc
|
||||
ydocument.ts pure: replacing a Y.Doc's content without CRDT merging
|
||||
schema/ the ProseMirror schema, mirroring web's tiptap extensions
|
||||
```
|
||||
|
||||
Rules that follow, and that a change must not erode:
|
||||
|
||||
- **A module never reaches for `process.env`.** It takes an `Env`. `loadEnv`
|
||||
is called once, in `index.ts`.
|
||||
- **A factory takes its dependencies as an argument**, narrowed to what it
|
||||
actually uses. `createDocumentHooks` asks for a `SessionResolver` with one
|
||||
method rather than the whole better-auth instance, and `createRoutes` asks
|
||||
for a `DocumentRegistry` rather than the whole Hocuspocus server. That
|
||||
narrowing is what lets a test drive them with a two-line stub.
|
||||
- **A callback a framework will invoke is a named function, not an inline
|
||||
arrow**, whenever the framework does not hand it back. The organization
|
||||
plugin keeps its hooks to itself, so they live in
|
||||
`createOrganizationHooks`; better-auth exposes `auth.options`, so the
|
||||
email callbacks stay inline and the tests reach them there.
|
||||
- Module-level mutable state belongs in the factory's closure (the maintainer
|
||||
map in `createDocumentHooks`, the cached JWKS in `createRoutes`), never at
|
||||
module scope, so nothing carries over between servers — or between tests.
|
||||
- **No query builder leaves [db.ts](src/db.ts).** Every query the service
|
||||
runs is a method on `Store`, named for what it answers rather than the SQL
|
||||
it builds (`hasOAuthConsent`, `userOrganizationId`). A caller takes the
|
||||
`Store`, so a table or column rename touches one file and a test stubs four
|
||||
methods instead of a chain of builder calls.
|
||||
- **A failure reaches sentry through
|
||||
[reporting.ts](src/reporting.ts), not an inline `catch`.** `reported(fn)`
|
||||
reports and rethrows, for work whose failure the caller must still see —
|
||||
the frameworks this service hands callbacks to swallow the cause, so
|
||||
without it a failed hook leaves only a status code. `bestEffort(fn)` reports
|
||||
and swallows, for an effect whose failure must not fail work that has
|
||||
already succeeded (persisting rawContent after a document is already
|
||||
seeded, closing a direct connection). A `catch` that also builds a response
|
||||
stays written out, because neither helper can express that.
|
||||
|
||||
**Every line goes through [logging.ts](src/logging.ts)** — pino, at
|
||||
`OXYNOTE_AUTH_REALTIME_LOG_LEVEL` (`DEBUG`/`INFO`/`WARN`/`ERROR`, INFO by
|
||||
default; the production image lowers it to WARN). Its records match core's
|
||||
slog output, so both services in a deployment log alike. That includes the
|
||||
two libraries that would otherwise print on their own terms: better-auth
|
||||
takes the matching level and a `log` callback, and hocuspocus's Logger
|
||||
extension is routed to DEBUG.
|
||||
|
||||
`createLogger` takes its destination as an argument — writing to stdout is a
|
||||
side effect, so the sink belongs to `index.ts`, and a test passes a stream
|
||||
it reads back. `Logger` is a four-method interface rather than pino's own
|
||||
type, so a suite hands over four `vi.fn()`s.
|
||||
|
||||
**Nothing logs an address or a port**, here or in core: a container's bound
|
||||
port is not the one anything reaches the service on.
|
||||
|
||||
`src/sentry.ts` is the exception: node loads it through `--import` before the
|
||||
app graph exists, so it reads `process.env` directly and must not import
|
||||
anything of ours. Sentry's own docs call this file `instrument.js` — the name
|
||||
comes from "instrumentation", the APM term for the monkey-patching the SDK
|
||||
does at load — but nothing outside `package.json` and the Dockerfile's `CMD`
|
||||
depends on the filename, and `sentry.ts` matches how the rest of the
|
||||
repository names its Sentry setup (`web/sentry.client.config.ts`,
|
||||
`web/sentry.server.config.ts`).
|
||||
|
||||
## Environment
|
||||
|
||||
Every variable is declared in [src/env.ts](src/env.ts) and parsed once at boot
|
||||
with zod. **A missing, malformed, or half-configured value is a boot error** —
|
||||
the same policy core applies to its own env — so nothing surfaces later as an
|
||||
`undefined` deep inside better-auth. Specifically:
|
||||
|
||||
- URLs are scheme-pinned to http/https: bare `z.url()` accepts `core:8080` as
|
||||
a valid URL with a `core:` scheme, which would boot and then fail on use.
|
||||
- Counters must be positive integers; a typo is an error rather than a `NaN`
|
||||
limit that compares false against every count.
|
||||
- Boolean flags accept only `"true"` and `"false"`, so a flag spelled `1`
|
||||
cannot read as false while the operator believes it is set.
|
||||
- A social provider needs both halves of its credentials or neither. One half
|
||||
is a boot error rather than a broken OAuth redirect a user discovers.
|
||||
- Docker's env files list every variable including the blank ones, so an
|
||||
unset value arrives as `""`. `loadEnv` drops those before parsing, which is
|
||||
what makes a required variable report as missing rather than empty.
|
||||
|
||||
Adding a variable means adding it to the schema, to the `Env` interface, and
|
||||
to `docker/env/auth-realtime.example.env`. One reaching the production image
|
||||
also needs a line in the launcher's `mapping.ts`, which is where the public
|
||||
`OXYNOTE_*` namespace is translated into each component's own.
|
||||
|
||||
## Formatting & TS
|
||||
|
||||
Prettier uses **tabs at width 8** (matching gofmt's convention on the backend
|
||||
side), no semicolons, trailing commas, double quotes — see
|
||||
[prettier.config.js](prettier.config.js).
|
||||
|
||||
ESLint ([eslint.config.mjs](eslint.config.mjs)) runs **type-aware**:
|
||||
`eslint:recommended` plus typescript-eslint **strictTypeChecked** and
|
||||
**stylisticTypeChecked**, resolved through the tsconfig by `projectService`.
|
||||
Consequences worth knowing:
|
||||
|
||||
- Linting builds a full TS program, so a cold run takes a few seconds. Runs
|
||||
are cached (`node_modules/.cache/eslint`); after cross-file type changes a
|
||||
cached file can report stale results locally, and `rm -rf
|
||||
node_modules/.cache/eslint` forces a full run. CI always runs cold.
|
||||
- Root config files (`eslint.config.mjs`, `knip.ts`, `prettier.config.js`,
|
||||
`vitest.config.ts`) sit outside the tsconfig project and opt out of typed
|
||||
linting; the two type-aware rule overrides live in the `src/**` block for
|
||||
the same reason, since applying them to an untyped file crashes the run.
|
||||
- Every `eslint-disable` **must** state a reason after `--`. It is for false
|
||||
positives only, never to avoid a fix. Stale disables are errors
|
||||
(`reportUnusedDisableDirectives`), and eslint runs with `--max-warnings 0`.
|
||||
- `@typescript-eslint/no-explicit-any` is off. The `no-unsafe-*` family is
|
||||
**on**, and the codebase answers it by typing the boundary rather than
|
||||
relaxing the rule: untrusted JSON is read as `unknown` and narrowed
|
||||
(`readString`, `upstreamResponse`, `seedContent`), and tiptap's
|
||||
`any`-typed extension API gets an explicit cast at each use, the same way
|
||||
[web](../../web/app/components/editor/blocks/) does it.
|
||||
|
||||
TypeScript is `module: NodeNext`, `target: ESNext`, fully strict, with
|
||||
`noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexedAccess`,
|
||||
`noImplicitOverride`, `verbatimModuleSyntax`, and `allowUnreachableCode:
|
||||
false`. Two configs: [tsconfig.json](tsconfig.json) covers all of `src/`
|
||||
(tests included) for `check-types` and eslint;
|
||||
[tsconfig.build.json](tsconfig.build.json) excludes the test files so `pnpm
|
||||
build` cannot emit them into `dist/`.
|
||||
|
||||
**ES module imports must include the explicit `.js` extension** (NodeNext +
|
||||
ESM requirement), even when importing TypeScript files — e.g. `import { foo }
|
||||
from "./bar.js"` for a `./bar.ts` source file.
|
||||
|
||||
**knip** ([knip.ts](knip.ts)) guards dead exports, unused files, and unused
|
||||
dependencies. `ignoreExportsUsedInFile` is on for interfaces and types only: a
|
||||
type exported so another export in the same file can name it — a union
|
||||
member, a table in the `Database` map, a factory's dependency bag — is part
|
||||
of that file's contract, not a dead export. Values are deliberately left out,
|
||||
because an exported const nobody imports is worth hearing about. Knip runs
|
||||
with `--cache`, and the cache is keyed by knip version, **not** config —
|
||||
after editing `knip.ts`, run `rm -rf node_modules/.cache/knip` or results are
|
||||
stale.
|
||||
|
||||
## Testing
|
||||
|
||||
### Layout
|
||||
|
||||
- **Tests are co-located**, paired 1:1 with the file they test:
|
||||
`ydocument.ts` → `ydocument.test.ts`. Never a parallel `tests/` mirror tree.
|
||||
- **One environment, one suffix.** The service is plain node — no DOM, no
|
||||
framework runtime — so every test is `.test.ts` running in vitest's node
|
||||
environment. There is no suffix taxonomy to learn.
|
||||
- When a test exercises behaviour spanning two source files, it lives with
|
||||
the file that initiates the behaviour.
|
||||
- **The test file mirrors the source file's order**: top-level `describe`
|
||||
blocks appear in the same order as the functions they test appear in the
|
||||
source, so the two files read side by side.
|
||||
- Helpers used by more than one suite live in
|
||||
[src/test-helpers.ts](src/test-helpers.ts) (`stubCore`, `stubStore`,
|
||||
`testEnv`, `fragmentXml`). It is excluded from coverage and from the build.
|
||||
Fixtures used by one suite stay file-local.
|
||||
- [db.test.ts](src/db.test.ts) is the only suite that knows kysely builds a
|
||||
query by chaining. It stubs the builder to pin the table and column names
|
||||
each `Store` method touches; every other suite takes a `stubStore()`.
|
||||
- **A yjs type must be added to a document before a test reads from it.**
|
||||
A detached `Y.XmlElement` or `Y.XmlText` keeps its attributes and children
|
||||
in prelim state, invisible to the public getters, and yjs answers the read
|
||||
with a console warning rather than an error — so the fixture is silently
|
||||
wrong and the test still passes. Build fixtures through an `attached()`
|
||||
helper (see `ydocument.test.ts` and `operations.test.ts`).
|
||||
[src/test-setup.ts](src/test-setup.ts) turns that warning into a failure so
|
||||
it cannot be missed: vitest prints intercepted stderr only under the
|
||||
verbose reporter, so in a piped or CI run the warning is invisible.
|
||||
- `src/schema/` is the one place that departs from 1:1 pairing: the whole
|
||||
directory is tested through [src/schema/index.test.ts](src/schema/index.test.ts)
|
||||
rather than a test file per node. The files are one unit — a single
|
||||
editor schema, re-exported through `index.ts` — and the tests that
|
||||
matter run against the assembled whole: a node round-tripped through
|
||||
the transformer proves it is registered, which no per-file test can.
|
||||
The per-node HTML assertions are data tables in the same file for the
|
||||
same reason.
|
||||
|
||||
What the suite pins there is the contract with the two services on
|
||||
either side. **Round trips** (prosemirror JSON → Y.Doc → prosemirror
|
||||
JSON) prove the schema knows each node, because a node it does not know
|
||||
is silently dropped on the way through — including the non-string
|
||||
attributes (`queries`, `thresholds`) that `cloneXmlElement` exists to
|
||||
protect. **The uid list** on the `UniqueID` extension is checked to
|
||||
contain every addressable block and to name only extensions that
|
||||
exist, because `operations.ts` addresses blocks by uid and a block
|
||||
missing from that list is one the assistant can never edit. **The
|
||||
`data-type` strings** are pinned because this service never serializes
|
||||
to HTML — those callbacks exist purely to stay identical to web's
|
||||
definitions, so nothing else would notice them drifting apart.
|
||||
|
||||
### Naming
|
||||
|
||||
**The rule: `describe` names the subject, `it` completes the sentence
|
||||
"it …" with observable behaviour.**
|
||||
|
||||
```ts
|
||||
describe("resolveBranchId", () => {
|
||||
it("returns a concrete branch identifier without asking core")
|
||||
it("resolves 'default' to the branch core flags as default")
|
||||
it("throws when no branch is flagged as default")
|
||||
})
|
||||
```
|
||||
|
||||
Read it aloud: "resolveBranchId throws when no branch is flagged as default."
|
||||
If the concatenation isn't a grammatical sentence, the name is off — reporters
|
||||
print exactly that concatenation on failure.
|
||||
|
||||
- Every test file wraps its tests in a root `describe` naming the unit by its
|
||||
real, greppable identifier. Never bare top-level `it` calls.
|
||||
- Present tense, third person, **no "should"**.
|
||||
- State behaviour, not implementation: `it("propagates a failed teardown so
|
||||
the organization survives")`, not `it("calls core.teardownOrganization")`.
|
||||
The name should survive a behaviour-preserving refactor.
|
||||
- Be specific enough that a failure is informative. `it("works")` and
|
||||
`it("handles errors")` are useless.
|
||||
- One level of `describe` nesting is usually right, two is the ceiling.
|
||||
|
||||
### Parameterized tests
|
||||
|
||||
**Use `it.for` wherever cases differ only in inputs and expected outputs.**
|
||||
The moment cases differ in setup, mock wiring, or assertions, they are
|
||||
separate `it`s — divergent cases in a shared callback breed conditionals, and
|
||||
neither a breakpoint nor a reporter line can target a single row.
|
||||
|
||||
- Case objects use the same field vocabulary everywhere: `name`, `input`,
|
||||
`expected`, and the title interpolates them (`"rejects $name"`).
|
||||
- Always `it.for`, never `it.each`: `.for` passes the test context as the
|
||||
second callback argument, which the concurrency rule below requires.
|
||||
- Hoist bulky case data into named builders above the table.
|
||||
|
||||
### Mocks & path coverage
|
||||
|
||||
- **Every exported function is covered** — a module's exports are its
|
||||
testable contract.
|
||||
- **Every path gets a test**: the success path and each failure path,
|
||||
including one per collaborator that can fail. Name them after the
|
||||
observable behaviour (`it("propagates a failed content fetch")`).
|
||||
- **Dependencies are injected, not module-mocked.** The factory pattern above
|
||||
exists so a test can pass a stub; module mocks are file-level singletons
|
||||
that cannot vary per test and force suites out of concurrent execution.
|
||||
[reporting.test.ts](src/reporting.test.ts) is the one exception, and shows
|
||||
what one costs: sentry is a global reporter rather than an injected
|
||||
dependency, so asserting that a failure reaches it needs
|
||||
`vi.mock("@sentry/node")`, which in turn needs `{ concurrent: false }` on
|
||||
every describe that counts its calls and a `beforeEach` clearing it —
|
||||
vitest's `restoreMocks` does not reset a hand-made `vi.fn()`. Everywhere
|
||||
else, `reported` and `bestEffort` are asserted through what they do to the
|
||||
caller (rethrow or swallow), not through sentry.
|
||||
- **No real IO.** No network, no database, no redis, no port. `createAuth` is
|
||||
exercised against a lazily-constructed pg pool that is never dialled, and
|
||||
everything else takes a stub.
|
||||
- **One act per test**: arrange once, invoke the unit once, then assert as
|
||||
many facets of that single outcome as needed. Needing a second invocation
|
||||
is the real "and" smell — split the test.
|
||||
- **Account for every injected dependency**: assert the calls that must have
|
||||
happened (count and arguments) *and* the zero counts of the ones that must
|
||||
not. `expect(core.fetchBranches).toHaveBeenCalledTimes(0)` after a
|
||||
rejected connection is as load-bearing as any positive assertion.
|
||||
- Test through the module's public exports. Never export something solely for
|
||||
tests — an internal complex enough to need direct tests is extracted into
|
||||
its own module (which is how `createOrganizationHooks` came to exist).
|
||||
- Plain `expect` for guards and preconditions; `expect.soft` is permitted in
|
||||
a final outcome-accounting block, where it reports the whole broken
|
||||
accounting at once.
|
||||
- A branch that genuinely cannot be reproduced carries a `// NOCOV: <lowercase
|
||||
reason>.` comment as the first line inside the branch body. NOCOV is a
|
||||
reviewer covenant, not a tool directive — it is deliberately not wired to
|
||||
the coverage provider (whose ignore hints need the banned `/* */` comment
|
||||
form), so coverage reports stay truthful and the marker explains the
|
||||
residual gap in place. It never excuses a testable path.
|
||||
|
||||
### Independence & concurrency
|
||||
|
||||
- **Every test is completely independent**: it creates its own fresh state (a
|
||||
new `Y.Doc`, a new stub, a new app) and never depends on execution order.
|
||||
- **All tests run concurrently** (`sequence.concurrent: true`). Under
|
||||
concurrency, assertions must use the `expect` from the local test context —
|
||||
`it("…", ({ expect }) => …)` — because the global `expect` cannot reliably
|
||||
attribute a failure to the right test when tests interleave. This applies
|
||||
to every test in the suite, `it.for` rows included.
|
||||
- Mock and global state cannot leak: the config sets `restoreMocks`,
|
||||
`unstubGlobals`, and `unstubEnvs`, so spies, stubbed globals, and env stubs
|
||||
are restored after every test without per-file cleanup.
|
||||
|
||||
### Determinism
|
||||
|
||||
- No `setTimeout`-based waiting. Await concrete signals — a promise the unit
|
||||
returns, an emitted event.
|
||||
- Time is driven, not waited on: `vi.useFakeTimers()` +
|
||||
`advanceTimersByTime()`, `vi.setSystemTime()` for deterministic timestamps.
|
||||
Assert exact values, never wall-clock deltas.
|
||||
|
||||
### Coverage
|
||||
|
||||
`pnpm test` runs with coverage and **fails under the thresholds** in
|
||||
[vitest.config.ts](vitest.config.ts). They are set from the measured baseline
|
||||
and ratcheted up as suites grow — **never lowered**. 100% is not the target:
|
||||
genuinely untestable branches stay visible in the report, marked with NOCOV
|
||||
in place. `src/index.ts` is excluded because evaluating it *is* starting the
|
||||
service; what it wires together is covered through the factories it calls.
|
||||
|
||||
## Yjs
|
||||
|
||||
Read the document-storage section of [server/AGENTS.md](../AGENTS.md) before
|
||||
touching branch content. The rule that matters most here: **never seed or
|
||||
merge one Y.Doc from another with `Y.applyUpdate`** — the differing
|
||||
`clientID`s CRDT-merge and silently duplicate every block. Use
|
||||
`replaceYdocContent` ([src/ydocument.ts](src/ydocument.ts)), and persist
|
||||
`rawContent` immediately after replacing it, so a restart cannot rebuild the
|
||||
document under a new clientID. Both invariants are pinned by tests; if you
|
||||
change that code, the tests in `ydocument.test.ts`, `hocuspocus.test.ts` and
|
||||
`routes.test.ts` are the specification.
|
||||
|
||||
## Better Auth schema
|
||||
|
||||
The core migrations own all tables, including Better Auth's. The generated
|
||||
`sql/better_auth_schema.sql` is **reference output only — never apply it
|
||||
directly**; regenerate it to diff what Better Auth expects after changing
|
||||
`src/auth.ts`. See the "Database" section of [server/AGENTS.md](../AGENTS.md).
|
||||
|
|
@ -1,385 +1 @@
|
|||
# CLAUDE.md
|
||||
|
||||
Guidance for `server/auth-realtime`. Shared working principles and the TS/JS
|
||||
comment/whitespace rules live in the root [CLAUDE.md](../../CLAUDE.md);
|
||||
backend architecture, the Caddy/port layout, and the document-storage model
|
||||
live in [server/CLAUDE.md](../CLAUDE.md).
|
||||
|
||||
## Project
|
||||
|
||||
`@oxynote/auth-realtime` — a TypeScript ES-module service running Better Auth
|
||||
and a Hocuspocus (Yjs) server in a single Hono process on port `8081`. It is a
|
||||
thin layer: it owns sessions, the realtime document connection, and the
|
||||
service-to-service endpoints core calls back into. Everything about documents
|
||||
themselves belongs to core.
|
||||
|
||||
## Common commands
|
||||
|
||||
Package manager is **pnpm** (own workspace).
|
||||
|
||||
```bash
|
||||
pnpm install # install dependencies
|
||||
pnpm dev # tsx watch, sentry preloaded via --import
|
||||
pnpm build # tsc -p tsconfig.build.json -> dist/
|
||||
pnpm build:bundle # esbuild single-file bundle -> dist/bundle.mjs;
|
||||
# what both docker images run (sentry folded in,
|
||||
# optional native accelerators left external)
|
||||
pnpm start # run the built service
|
||||
|
||||
pnpm check-lint # check-types + check-eslint + check-fmt +
|
||||
# check-knip (read-only)
|
||||
pnpm lint # fixing variant: check-types + knip --fix
|
||||
# (removes dead exports/files!) + eslint --fix +
|
||||
# prettier --write
|
||||
pnpm test # vitest run --coverage
|
||||
pnpm test-watch # vitest in watch mode
|
||||
pnpm qa # check-lint + test; qa-fix = lint + test
|
||||
```
|
||||
|
||||
## Composition root
|
||||
|
||||
**`src/index.ts` is the only module with side effects.** It is the one place
|
||||
that reads the environment, opens a database pool, connects to Valkey (only
|
||||
when `OXYNOTE_AUTH_REALTIME_VALKEY_DSN` is set — without it better-auth is
|
||||
built with no secondary storage at all), or listens on a port. Everything
|
||||
else is a factory taking what it needs:
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts composition root: env -> wiring -> listen
|
||||
sentry.ts the sentry bootstrap, loaded before the app graph exists
|
||||
bundle.ts the docker entry: sentry + index folded into one esbuild
|
||||
artifact (build:bundle), so no --import preload and no
|
||||
node_modules ship in the images
|
||||
env.ts zod-parsed, typed configuration
|
||||
core.ts the client for every call this service makes into core
|
||||
db.ts the Store: every query this service runs, behind one
|
||||
interface
|
||||
reporting.ts reported() / bestEffort(), the two ways a failure reaches
|
||||
sentry
|
||||
logging.ts createLogger(level, destination): the pino logger
|
||||
everything the service writes goes through
|
||||
headers.ts normalizing whatever shape a transport hands over
|
||||
auth.ts createAuth(deps) + the callbacks better-auth invokes
|
||||
hocuspocus.ts createHocuspocus(deps) / createDocumentHooks(deps); the
|
||||
flushDocument the branch routes run before core reads a
|
||||
branch's stored row
|
||||
routes.ts createRoutes(deps) -> a Hono app
|
||||
operations.ts pure: applying the assistant's edit ops to a Y.Doc
|
||||
ydocument.ts pure: replacing a Y.Doc's content without CRDT merging
|
||||
schema/ the ProseMirror schema, mirroring web's tiptap extensions
|
||||
```
|
||||
|
||||
Rules that follow, and that a change must not erode:
|
||||
|
||||
- **A module never reaches for `process.env`.** It takes an `Env`. `loadEnv`
|
||||
is called once, in `index.ts`.
|
||||
- **A factory takes its dependencies as an argument**, narrowed to what it
|
||||
actually uses. `createDocumentHooks` asks for a `SessionResolver` with one
|
||||
method rather than the whole better-auth instance, and `createRoutes` asks
|
||||
for a `DocumentRegistry` rather than the whole Hocuspocus server. That
|
||||
narrowing is what lets a test drive them with a two-line stub.
|
||||
- **A callback a framework will invoke is a named function, not an inline
|
||||
arrow**, whenever the framework does not hand it back. The organization
|
||||
plugin keeps its hooks to itself, so they live in
|
||||
`createOrganizationHooks`; better-auth exposes `auth.options`, so the
|
||||
email callbacks stay inline and the tests reach them there.
|
||||
- Module-level mutable state belongs in the factory's closure (the maintainer
|
||||
map in `createDocumentHooks`, the cached JWKS in `createRoutes`), never at
|
||||
module scope, so nothing carries over between servers — or between tests.
|
||||
- **No query builder leaves [db.ts](src/db.ts).** Every query the service
|
||||
runs is a method on `Store`, named for what it answers rather than the SQL
|
||||
it builds (`hasOAuthConsent`, `userOrganizationId`). A caller takes the
|
||||
`Store`, so a table or column rename touches one file and a test stubs four
|
||||
methods instead of a chain of builder calls.
|
||||
- **A failure reaches sentry through
|
||||
[reporting.ts](src/reporting.ts), not an inline `catch`.** `reported(fn)`
|
||||
reports and rethrows, for work whose failure the caller must still see —
|
||||
the frameworks this service hands callbacks to swallow the cause, so
|
||||
without it a failed hook leaves only a status code. `bestEffort(fn)` reports
|
||||
and swallows, for an effect whose failure must not fail work that has
|
||||
already succeeded (persisting rawContent after a document is already
|
||||
seeded, closing a direct connection). A `catch` that also builds a response
|
||||
stays written out, because neither helper can express that.
|
||||
|
||||
**Every line goes through [logging.ts](src/logging.ts)** — pino, at
|
||||
`OXYNOTE_AUTH_REALTIME_LOG_LEVEL` (`DEBUG`/`INFO`/`WARN`/`ERROR`, INFO by
|
||||
default; the production image lowers it to WARN). Its records match core's
|
||||
slog output, so both services in a deployment log alike. That includes the
|
||||
two libraries that would otherwise print on their own terms: better-auth
|
||||
takes the matching level and a `log` callback, and hocuspocus's Logger
|
||||
extension is routed to DEBUG.
|
||||
|
||||
`createLogger` takes its destination as an argument — writing to stdout is a
|
||||
side effect, so the sink belongs to `index.ts`, and a test passes a stream
|
||||
it reads back. `Logger` is a four-method interface rather than pino's own
|
||||
type, so a suite hands over four `vi.fn()`s.
|
||||
|
||||
**Nothing logs an address or a port**, here or in core: a container's bound
|
||||
port is not the one anything reaches the service on.
|
||||
|
||||
`src/sentry.ts` is the exception: node loads it through `--import` before the
|
||||
app graph exists, so it reads `process.env` directly and must not import
|
||||
anything of ours. Sentry's own docs call this file `instrument.js` — the name
|
||||
comes from "instrumentation", the APM term for the monkey-patching the SDK
|
||||
does at load — but nothing outside `package.json` and the Dockerfile's `CMD`
|
||||
depends on the filename, and `sentry.ts` matches how the rest of the
|
||||
repository names its Sentry setup (`web/sentry.client.config.ts`,
|
||||
`web/sentry.server.config.ts`).
|
||||
|
||||
## Environment
|
||||
|
||||
Every variable is declared in [src/env.ts](src/env.ts) and parsed once at boot
|
||||
with zod. **A missing, malformed, or half-configured value is a boot error** —
|
||||
the same policy core applies to its own env — so nothing surfaces later as an
|
||||
`undefined` deep inside better-auth. Specifically:
|
||||
|
||||
- URLs are scheme-pinned to http/https: bare `z.url()` accepts `core:8080` as
|
||||
a valid URL with a `core:` scheme, which would boot and then fail on use.
|
||||
- Counters must be positive integers; a typo is an error rather than a `NaN`
|
||||
limit that compares false against every count.
|
||||
- Boolean flags accept only `"true"` and `"false"`, so a flag spelled `1`
|
||||
cannot read as false while the operator believes it is set.
|
||||
- A social provider needs both halves of its credentials or neither. One half
|
||||
is a boot error rather than a broken OAuth redirect a user discovers.
|
||||
- Docker's env files list every variable including the blank ones, so an
|
||||
unset value arrives as `""`. `loadEnv` drops those before parsing, which is
|
||||
what makes a required variable report as missing rather than empty.
|
||||
|
||||
Adding a variable means adding it to the schema, to the `Env` interface, and
|
||||
to `docker/env/auth-realtime.example.env`. One reaching the production image
|
||||
also needs a line in the launcher's `mapping.ts`, which is where the public
|
||||
`OXYNOTE_*` namespace is translated into each component's own.
|
||||
|
||||
## Formatting & TS
|
||||
|
||||
Prettier uses **tabs at width 8** (matching gofmt's convention on the backend
|
||||
side), no semicolons, trailing commas, double quotes — see
|
||||
[prettier.config.js](prettier.config.js).
|
||||
|
||||
ESLint ([eslint.config.mjs](eslint.config.mjs)) runs **type-aware**:
|
||||
`eslint:recommended` plus typescript-eslint **strictTypeChecked** and
|
||||
**stylisticTypeChecked**, resolved through the tsconfig by `projectService`.
|
||||
Consequences worth knowing:
|
||||
|
||||
- Linting builds a full TS program, so a cold run takes a few seconds. Runs
|
||||
are cached (`node_modules/.cache/eslint`); after cross-file type changes a
|
||||
cached file can report stale results locally, and `rm -rf
|
||||
node_modules/.cache/eslint` forces a full run. CI always runs cold.
|
||||
- Root config files (`eslint.config.mjs`, `knip.ts`, `prettier.config.js`,
|
||||
`vitest.config.ts`) sit outside the tsconfig project and opt out of typed
|
||||
linting; the two type-aware rule overrides live in the `src/**` block for
|
||||
the same reason, since applying them to an untyped file crashes the run.
|
||||
- Every `eslint-disable` **must** state a reason after `--`. It is for false
|
||||
positives only, never to avoid a fix. Stale disables are errors
|
||||
(`reportUnusedDisableDirectives`), and eslint runs with `--max-warnings 0`.
|
||||
- `@typescript-eslint/no-explicit-any` is off. The `no-unsafe-*` family is
|
||||
**on**, and the codebase answers it by typing the boundary rather than
|
||||
relaxing the rule: untrusted JSON is read as `unknown` and narrowed
|
||||
(`readString`, `upstreamResponse`, `seedContent`), and tiptap's
|
||||
`any`-typed extension API gets an explicit cast at each use, the same way
|
||||
[web](../../web/app/components/editor/blocks/) does it.
|
||||
|
||||
TypeScript is `module: NodeNext`, `target: ESNext`, fully strict, with
|
||||
`noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexedAccess`,
|
||||
`noImplicitOverride`, `verbatimModuleSyntax`, and `allowUnreachableCode:
|
||||
false`. Two configs: [tsconfig.json](tsconfig.json) covers all of `src/`
|
||||
(tests included) for `check-types` and eslint;
|
||||
[tsconfig.build.json](tsconfig.build.json) excludes the test files so `pnpm
|
||||
build` cannot emit them into `dist/`.
|
||||
|
||||
**ES module imports must include the explicit `.js` extension** (NodeNext +
|
||||
ESM requirement), even when importing TypeScript files — e.g. `import { foo }
|
||||
from "./bar.js"` for a `./bar.ts` source file.
|
||||
|
||||
**knip** ([knip.ts](knip.ts)) guards dead exports, unused files, and unused
|
||||
dependencies. `ignoreExportsUsedInFile` is on for interfaces and types only: a
|
||||
type exported so another export in the same file can name it — a union
|
||||
member, a table in the `Database` map, a factory's dependency bag — is part
|
||||
of that file's contract, not a dead export. Values are deliberately left out,
|
||||
because an exported const nobody imports is worth hearing about. Knip runs
|
||||
with `--cache`, and the cache is keyed by knip version, **not** config —
|
||||
after editing `knip.ts`, run `rm -rf node_modules/.cache/knip` or results are
|
||||
stale.
|
||||
|
||||
## Testing
|
||||
|
||||
### Layout
|
||||
|
||||
- **Tests are co-located**, paired 1:1 with the file they test:
|
||||
`ydocument.ts` → `ydocument.test.ts`. Never a parallel `tests/` mirror tree.
|
||||
- **One environment, one suffix.** The service is plain node — no DOM, no
|
||||
framework runtime — so every test is `.test.ts` running in vitest's node
|
||||
environment. There is no suffix taxonomy to learn.
|
||||
- When a test exercises behaviour spanning two source files, it lives with
|
||||
the file that initiates the behaviour.
|
||||
- **The test file mirrors the source file's order**: top-level `describe`
|
||||
blocks appear in the same order as the functions they test appear in the
|
||||
source, so the two files read side by side.
|
||||
- Helpers used by more than one suite live in
|
||||
[src/test-helpers.ts](src/test-helpers.ts) (`stubCore`, `stubStore`,
|
||||
`testEnv`, `fragmentXml`). It is excluded from coverage and from the build.
|
||||
Fixtures used by one suite stay file-local.
|
||||
- [db.test.ts](src/db.test.ts) is the only suite that knows kysely builds a
|
||||
query by chaining. It stubs the builder to pin the table and column names
|
||||
each `Store` method touches; every other suite takes a `stubStore()`.
|
||||
- **A yjs type must be added to a document before a test reads from it.**
|
||||
A detached `Y.XmlElement` or `Y.XmlText` keeps its attributes and children
|
||||
in prelim state, invisible to the public getters, and yjs answers the read
|
||||
with a console warning rather than an error — so the fixture is silently
|
||||
wrong and the test still passes. Build fixtures through an `attached()`
|
||||
helper (see `ydocument.test.ts` and `operations.test.ts`).
|
||||
[src/test-setup.ts](src/test-setup.ts) turns that warning into a failure so
|
||||
it cannot be missed: vitest prints intercepted stderr only under the
|
||||
verbose reporter, so in a piped or CI run the warning is invisible.
|
||||
- `src/schema/` is the one place that departs from 1:1 pairing: the whole
|
||||
directory is tested through [src/schema/index.test.ts](src/schema/index.test.ts)
|
||||
rather than a test file per node. The files are one unit — a single
|
||||
editor schema, re-exported through `index.ts` — and the tests that
|
||||
matter run against the assembled whole: a node round-tripped through
|
||||
the transformer proves it is registered, which no per-file test can.
|
||||
The per-node HTML assertions are data tables in the same file for the
|
||||
same reason.
|
||||
|
||||
What the suite pins there is the contract with the two services on
|
||||
either side. **Round trips** (prosemirror JSON → Y.Doc → prosemirror
|
||||
JSON) prove the schema knows each node, because a node it does not know
|
||||
is silently dropped on the way through — including the non-string
|
||||
attributes (`queries`, `thresholds`) that `cloneXmlElement` exists to
|
||||
protect. **The uid list** on the `UniqueID` extension is checked to
|
||||
contain every addressable block and to name only extensions that
|
||||
exist, because `operations.ts` addresses blocks by uid and a block
|
||||
missing from that list is one the assistant can never edit. **The
|
||||
`data-type` strings** are pinned because this service never serializes
|
||||
to HTML — those callbacks exist purely to stay identical to web's
|
||||
definitions, so nothing else would notice them drifting apart.
|
||||
|
||||
### Naming
|
||||
|
||||
**The rule: `describe` names the subject, `it` completes the sentence
|
||||
"it …" with observable behaviour.**
|
||||
|
||||
```ts
|
||||
describe("resolveBranchId", () => {
|
||||
it("returns a concrete branch identifier without asking core")
|
||||
it("resolves 'default' to the branch core flags as default")
|
||||
it("throws when no branch is flagged as default")
|
||||
})
|
||||
```
|
||||
|
||||
Read it aloud: "resolveBranchId throws when no branch is flagged as default."
|
||||
If the concatenation isn't a grammatical sentence, the name is off — reporters
|
||||
print exactly that concatenation on failure.
|
||||
|
||||
- Every test file wraps its tests in a root `describe` naming the unit by its
|
||||
real, greppable identifier. Never bare top-level `it` calls.
|
||||
- Present tense, third person, **no "should"**.
|
||||
- State behaviour, not implementation: `it("propagates a failed teardown so
|
||||
the organization survives")`, not `it("calls core.teardownOrganization")`.
|
||||
The name should survive a behaviour-preserving refactor.
|
||||
- Be specific enough that a failure is informative. `it("works")` and
|
||||
`it("handles errors")` are useless.
|
||||
- One level of `describe` nesting is usually right, two is the ceiling.
|
||||
|
||||
### Parameterized tests
|
||||
|
||||
**Use `it.for` wherever cases differ only in inputs and expected outputs.**
|
||||
The moment cases differ in setup, mock wiring, or assertions, they are
|
||||
separate `it`s — divergent cases in a shared callback breed conditionals, and
|
||||
neither a breakpoint nor a reporter line can target a single row.
|
||||
|
||||
- Case objects use the same field vocabulary everywhere: `name`, `input`,
|
||||
`expected`, and the title interpolates them (`"rejects $name"`).
|
||||
- Always `it.for`, never `it.each`: `.for` passes the test context as the
|
||||
second callback argument, which the concurrency rule below requires.
|
||||
- Hoist bulky case data into named builders above the table.
|
||||
|
||||
### Mocks & path coverage
|
||||
|
||||
- **Every exported function is covered** — a module's exports are its
|
||||
testable contract.
|
||||
- **Every path gets a test**: the success path and each failure path,
|
||||
including one per collaborator that can fail. Name them after the
|
||||
observable behaviour (`it("propagates a failed content fetch")`).
|
||||
- **Dependencies are injected, not module-mocked.** The factory pattern above
|
||||
exists so a test can pass a stub; module mocks are file-level singletons
|
||||
that cannot vary per test and force suites out of concurrent execution.
|
||||
[reporting.test.ts](src/reporting.test.ts) is the one exception, and shows
|
||||
what one costs: sentry is a global reporter rather than an injected
|
||||
dependency, so asserting that a failure reaches it needs
|
||||
`vi.mock("@sentry/node")`, which in turn needs `{ concurrent: false }` on
|
||||
every describe that counts its calls and a `beforeEach` clearing it —
|
||||
vitest's `restoreMocks` does not reset a hand-made `vi.fn()`. Everywhere
|
||||
else, `reported` and `bestEffort` are asserted through what they do to the
|
||||
caller (rethrow or swallow), not through sentry.
|
||||
- **No real IO.** No network, no database, no redis, no port. `createAuth` is
|
||||
exercised against a lazily-constructed pg pool that is never dialled, and
|
||||
everything else takes a stub.
|
||||
- **One act per test**: arrange once, invoke the unit once, then assert as
|
||||
many facets of that single outcome as needed. Needing a second invocation
|
||||
is the real "and" smell — split the test.
|
||||
- **Account for every injected dependency**: assert the calls that must have
|
||||
happened (count and arguments) *and* the zero counts of the ones that must
|
||||
not. `expect(core.fetchBranches).toHaveBeenCalledTimes(0)` after a
|
||||
rejected connection is as load-bearing as any positive assertion.
|
||||
- Test through the module's public exports. Never export something solely for
|
||||
tests — an internal complex enough to need direct tests is extracted into
|
||||
its own module (which is how `createOrganizationHooks` came to exist).
|
||||
- Plain `expect` for guards and preconditions; `expect.soft` is permitted in
|
||||
a final outcome-accounting block, where it reports the whole broken
|
||||
accounting at once.
|
||||
- A branch that genuinely cannot be reproduced carries a `// NOCOV: <lowercase
|
||||
reason>.` comment as the first line inside the branch body. NOCOV is a
|
||||
reviewer covenant, not a tool directive — it is deliberately not wired to
|
||||
the coverage provider (whose ignore hints need the banned `/* */` comment
|
||||
form), so coverage reports stay truthful and the marker explains the
|
||||
residual gap in place. It never excuses a testable path.
|
||||
|
||||
### Independence & concurrency
|
||||
|
||||
- **Every test is completely independent**: it creates its own fresh state (a
|
||||
new `Y.Doc`, a new stub, a new app) and never depends on execution order.
|
||||
- **All tests run concurrently** (`sequence.concurrent: true`). Under
|
||||
concurrency, assertions must use the `expect` from the local test context —
|
||||
`it("…", ({ expect }) => …)` — because the global `expect` cannot reliably
|
||||
attribute a failure to the right test when tests interleave. This applies
|
||||
to every test in the suite, `it.for` rows included.
|
||||
- Mock and global state cannot leak: the config sets `restoreMocks`,
|
||||
`unstubGlobals`, and `unstubEnvs`, so spies, stubbed globals, and env stubs
|
||||
are restored after every test without per-file cleanup.
|
||||
|
||||
### Determinism
|
||||
|
||||
- No `setTimeout`-based waiting. Await concrete signals — a promise the unit
|
||||
returns, an emitted event.
|
||||
- Time is driven, not waited on: `vi.useFakeTimers()` +
|
||||
`advanceTimersByTime()`, `vi.setSystemTime()` for deterministic timestamps.
|
||||
Assert exact values, never wall-clock deltas.
|
||||
|
||||
### Coverage
|
||||
|
||||
`pnpm test` runs with coverage and **fails under the thresholds** in
|
||||
[vitest.config.ts](vitest.config.ts). They are set from the measured baseline
|
||||
and ratcheted up as suites grow — **never lowered**. 100% is not the target:
|
||||
genuinely untestable branches stay visible in the report, marked with NOCOV
|
||||
in place. `src/index.ts` is excluded because evaluating it *is* starting the
|
||||
service; what it wires together is covered through the factories it calls.
|
||||
|
||||
## Yjs
|
||||
|
||||
Read the document-storage section of [server/CLAUDE.md](../CLAUDE.md) before
|
||||
touching branch content. The rule that matters most here: **never seed or
|
||||
merge one Y.Doc from another with `Y.applyUpdate`** — the differing
|
||||
`clientID`s CRDT-merge and silently duplicate every block. Use
|
||||
`replaceYdocContent` ([src/ydocument.ts](src/ydocument.ts)), and persist
|
||||
`rawContent` immediately after replacing it, so a restart cannot rebuild the
|
||||
document under a new clientID. Both invariants are pinned by tests; if you
|
||||
change that code, the tests in `ydocument.test.ts`, `hocuspocus.test.ts` and
|
||||
`routes.test.ts` are the specification.
|
||||
|
||||
## Better Auth schema
|
||||
|
||||
The core migrations own all tables, including Better Auth's. The generated
|
||||
`sql/better_auth_schema.sql` is **reference output only — never apply it
|
||||
directly**; regenerate it to diff what Better Auth expects after changing
|
||||
`src/auth.ts`. See the "Database" section of [server/CLAUDE.md](../CLAUDE.md).
|
||||
@AGENTS.md
|
||||
|
|
|
|||
1263
server/core/AGENTS.md
Normal file
1263
server/core/AGENTS.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
621
web/AGENTS.md
Normal file
621
web/AGENTS.md
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
# AGENTS.md
|
||||
|
||||
Guidance for the `web/` frontend. Shared working principles and the TS/JS
|
||||
comment/whitespace rules live in the root [AGENTS.md](../AGENTS.md).
|
||||
|
||||
## Project
|
||||
|
||||
**Oxynote** web frontend — Nuxt 4 + Vue 3 frontend that ships as both a web app (Cloudflare Pages, SSR) and an Electron desktop app (static SPA).
|
||||
|
||||
## Common commands
|
||||
|
||||
Package manager is **pnpm** (workspace; uses `nodeLinker: hoisted` so Electron Forge's npm-style layout works).
|
||||
|
||||
```bash
|
||||
pnpm install # installs deps; runs `prepare` (nuxt prepare)
|
||||
pnpm setup # deps + explicit prepare (guarantees fresh .nuxt
|
||||
# types) + playwright chromium for browser-mode
|
||||
# tests
|
||||
|
||||
pnpm start:dev:web # nuxt dev on :3000 (web build)
|
||||
pnpm start:dev:desktop # concurrently runs nuxt dev + electron-forge start with DESKTOP_BUILD=hybrid
|
||||
pnpm build:web # production web build (cloudflare_pages preset)
|
||||
pnpm package:desktop # nuxt generate (static) + electron-forge package
|
||||
pnpm make:desktop # nuxt generate (static) + electron-forge make (installers)
|
||||
|
||||
pnpm check-lint # pnpm dedupe --check + check-types +
|
||||
# check-eslint + check-fmt + check-knip
|
||||
# (read-only)
|
||||
pnpm lint # fixing variant: pnpm dedupe + check-types +
|
||||
# knip --fix (removes dead exports/deps/files!)
|
||||
# + eslint --fix + prettier --write
|
||||
pnpm test # vitest run --coverage (node + browser + nuxt
|
||||
# projects; browser needs the playwright chromium
|
||||
# that `pnpm setup` installs)
|
||||
pnpm test-watch # vitest in watch mode
|
||||
pnpm qa # check-lint + test; qa-fix = lint + test
|
||||
pnpm check-types # nuxt typecheck (regenerates .nuxt types, then
|
||||
# vue-tsc -b --noEmit; plain vue-tsc --noEmit on
|
||||
# the solution-style root tsconfig checks nothing)
|
||||
# + tsc on electron/ (own tsconfig, not part of
|
||||
# the nuxt solution)
|
||||
pnpm check-eslint # eslint --max-warnings 0 . (eslint / fmt are the
|
||||
# fixing counterparts)
|
||||
pnpm check-knip # dead exports/files/dependencies ([knip.ts](knip.ts))
|
||||
pnpm dedupe --check # fails when the lockfile resolves one package to
|
||||
# several versions that could share one
|
||||
|
||||
```
|
||||
|
||||
|
||||
## Build modes (critical)
|
||||
|
||||
The `DESKTOP_BUILD` env var drives a Vite `define` that materializes `__DESKTOP_BUILD__` in the bundle. See [nuxt.config.ts:9-24](nuxt.config.ts#L9-L24) and [index.d.ts:1-9](index.d.ts#L1-L9):
|
||||
|
||||
- `DESKTOP_BUILD=0` / unset → web build. `__DESKTOP_BUILD__` is literal `false`. SSR enabled. Nitro preset `cloudflare_pages`, overridable via `NITRO_PRESET` (the docker image builds with `node-server`).
|
||||
- `DESKTOP_BUILD=1` → pure desktop build. Literal `true`. SSR off. Nitro preset `static`. Renderer is served from `oxynote://app/index.html` out of `.output/public/`.
|
||||
- `DESKTOP_BUILD=hybrid` → dev-only, used by `start:dev:desktop`. One Nuxt dev server is hit by *both* the Electron renderer and the system browser opened for OAuth. `__DESKTOP_BUILD__` becomes a *runtime probe* of `window.__isElectron` (set by [electron/preload.ts](electron/preload.ts)) so each context picks the right branch from the same bundle.
|
||||
|
||||
When writing code that branches on platform, always use `__DESKTOP_BUILD__` — never `process.platform`, `import.meta.client`, or feature-detect on `window`. The literal-substitution is the security boundary: in the desktop bundle, no code path can carry a session cookie.
|
||||
|
||||
## Auth architecture
|
||||
|
||||
Uses **Better Auth** with the `@better-auth/electron` plugin. Two distinct flows:
|
||||
|
||||
- **Web**: `app/plugins/02.auth.ts` instantiates a standard `createAuthClient` with `credentials: "include"`. Session cookies flow normally.
|
||||
- **Desktop**: the renderer's `createAuthClient` is forced to `credentials: "omit"`. The real session lives in **main**'s [electron/auth-client.ts](electron/auth-client.ts) backed by `electron-store` (encrypted via `safeStorage` + Electron's `EnableCookieEncryption` fuse). The renderer reaches auth only through `window.__host.auth.*` IPC bridges defined in [electron/auth-ipc.ts](electron/auth-ipc.ts) and [electron/preload.ts](electron/preload.ts). To add a new auth operation: add a handler in `auth-ipc.ts`, expose it in `preload.ts`, type it in [index.d.ts](index.d.ts), and add the desktop branch in [app/composables/useAuthSession.ts](app/composables/useAuthSession.ts).
|
||||
|
||||
Main's [electron/main.ts](electron/main.ts) injects the session cookie into renderer-originated requests via `webRequest.onBeforeSendHeaders` — this is why the renderer never holds the cookie. The `/api/auth/*` path is carved out of that injection because those calls must keep going through the IPC bridge.
|
||||
|
||||
OAuth flow on desktop: renderer calls `window.requestAuth({ provider })` → opens system browser at `${APP_BASE_URL}/login` → web flow completes → server redirects to `oxynote://` deep-link → OS focuses Electron → Better Auth's `setupMain()` exchanges the code → main fires `onAuthenticated` over the bridge → [app/plugins/electron-auth.client.ts](app/plugins/electron-auth.client.ts) refetches session and navigates.
|
||||
|
||||
## API clients
|
||||
|
||||
`app/plugins/03.api-fetch.ts` provides two `$fetch` instances:
|
||||
|
||||
- `$coreAPIClient` → core API (`NUXT_PUBLIC_CORE_API_BASE_HTTP_URL`)
|
||||
- `$authRealtimeAPIClient` → auth-realtime API (`NUXT_PUBLIC_AUTH_REALTIME_API_BASE_HTTP_URL`)
|
||||
|
||||
Both propagate SSR request headers (captured eagerly during plugin setup — the H3 context is lost inside `onRequest` callbacks on Cloudflare workerd) and redirect to `/login` on 401.
|
||||
|
||||
When the server-only runtime config keys `coreAPIInternalHttpURL` / `authRealtimeAPIInternalHttpURL` (`NUXT_CORE_API_INTERNAL_HTTP_URL` / `NUXT_AUTH_REALTIME_API_INTERNAL_HTTP_URL`) are set, SSR fetches (including the auth client in `app/plugins/02.auth.ts`) use them instead of the public URLs — required when the app runs inside a container where the public localhost origin is unreachable.
|
||||
|
||||
## Data layer
|
||||
|
||||
**Pinia Colada** (`useQuery` / `useMutation`) is the standard data-fetching primitive. Auto-refetch plugin is configured but *disabled by default* — opt in per-query with `autoRefetch: true` (typed augmentation in [index.d.ts:34-38](index.d.ts#L34-L38)). See [colada.options.ts](colada.options.ts).
|
||||
|
||||
API composables live in [app/composables/api/](app/composables/api/) and are re-exported by [app/composables/index.ts](app/composables/index.ts) for auto-import. Request/response types live in [app/utils/api/](app/utils/api/) and are re-exported by [app/utils/index.ts](app/utils/index.ts).
|
||||
|
||||
## Editor (TipTap + Yjs)
|
||||
|
||||
Document editor is in [app/components/editor/](app/components/editor/). Real-time collaboration uses **Yjs** + **Hocuspocus** (`NUXT_PUBLIC_AUTH_REALTIME_API_BASE_WS_URL`). Notable subsystems:
|
||||
|
||||
- `blocks/` — custom node types (mermaid, metrics, code-block, figma, image, file, callout, split-documentation); `upload-handler.ts` routes dropped and pasted files to the image or file block by type
|
||||
- `comments/` — comment marks + node-comment extension
|
||||
- `diff/` — branch diffing UI (compute, render, decorations)
|
||||
- `drag-handle/`, `slash/`, `link/`, `ai/` — editor UX extensions
|
||||
- `hooks/` — block-level integrations (github tracking, container image watcher, scheduled reminders, URL watcher)
|
||||
|
||||
Editor-wide state (active document, branch, locks, metric configs, diff statuses) lives in [app/stores/editor.ts](app/stores/editor.ts).
|
||||
|
||||
## Routing
|
||||
|
||||
Single dynamic page handles the workspace: [app/pages/[[organizationSlug]]/[[documentSlug]].vue](app/pages/[[organizationSlug]]/[[documentSlug]].vue). The global middleware [app/middleware/01.redirect.global.ts](app/middleware/01.redirect.global.ts) handles auth gating, onboarding, and root-path redirection to the first document.
|
||||
|
||||
Pages with `definePageMeta({ skipAuth: true })` are reachable when signed-out (login, signup, accept-invite, verify-email, desktop-auth).
|
||||
|
||||
## i18n
|
||||
|
||||
All user-facing text **must** live under [i18n/locales/en/](i18n/locales/en/) — never inline. The setup combines multiple JSON files into one big i18n object, so each file must have a **root namespace key** (e.g. `{ "sidebar": { ... } }`). To add a new namespace, also register the filename in `nuxt.config.ts` under `i18n.locales[0].files`.
|
||||
|
||||
`<i18n-t>` carries **`scope="global"`** on every use. Its default scope is `parent`, which looks for an ancestor that opened its own i18n scope; nothing in this app does, so without the prop vue-i18n warns once per render and falls back to the global scope anyway.
|
||||
|
||||
Form validation (vee-validate) uses its own internal messages — see [README.md](README.md).
|
||||
|
||||
## UI
|
||||
|
||||
- **shadcn-vue** components live in [app/components/shadcn/ui/](app/components/shadcn/ui/) with prefix `ShadcnUi`. Configured in [components.json](components.json).
|
||||
- **Tailwind v4** via `@tailwindcss/vite`, theme in [app/assets/css/main.css](app/assets/css/main.css).
|
||||
- Icons via `@nuxt/icon` (CSS mode). The client bundle holds the icons the scanner finds in app code, plus — on the desktop build only — the document title icons listed by `selectableIconList()` in [app/utils/icon.ts](app/utils/icon.ts). The web build resolves those on demand through the server so they stay off the startup path. The picker itself does not go through `<Icon>`: [modules/icon-picker-css.ts](modules/icon-picker-css.ts) generates one stylesheet with a rule per selectable icon, served as `virtual:icon-picker.css` and imported by the picker provider once the page's load event has fired. Custom SVGs live in [app/assets/custom-icons/](app/assets/custom-icons/) and are reachable under the `custom-icons:` prefix.
|
||||
- **Every dialog describes itself.** reka-ui points `DialogContent`'s `aria-describedby` at a `DialogDescription`, so each dialog and sheet renders one — `ShadcnUiDialogDescription` (or the sheet variant) with `sr-only` when nothing visible plays that role, `as-child` around an `<i18n-t tag="p">` when the visible description is interpolated. The settings action components render theirs inside the `ActionModal` around them, which is why their tests mount through `mountUnderDialogRoot`. A dialog missing one makes reka-ui warn once per mount in `nuxt dev` and in test output.
|
||||
|
||||
## Formatting & TS
|
||||
|
||||
Prettier uses **tabs**, no semicolons, trailing commas — see [prettier.config.js](prettier.config.js).
|
||||
|
||||
ESLint ([eslint.config.mjs](eslint.config.mjs)) runs **type-aware**: `eslint.config.typescript.tsconfigPath` in `nuxt.config.ts` switches the generated Nuxt preset to the `*-type-checked` rule sets, on top of which the config adds `eslint:recommended` (the Nuxt preset ships no base JS rules), typescript-eslint **strict + stylistic**, `@intlify/eslint-plugin-vue-i18n`, and `eslint-plugin-vuejs-accessibility` (its recommended set; the shadcn wrappers are exempt from the label-association and static-element rules, since the association is made where they are used). Consequences worth knowing:
|
||||
|
||||
- Linting builds a full TS program, so a cold `check-eslint` takes about a
|
||||
minute. Runs are cached (`node_modules/.cache/eslint`) — unchanged files
|
||||
are near-instant. Caveat: the cache is per-file while type-aware results
|
||||
depend on *other* files' types, so after cross-file type changes a cached
|
||||
file can report stale results locally. CI always runs cold and is the
|
||||
gate of record; locally, `rm -rf node_modules/.cache/eslint` forces a
|
||||
full run.
|
||||
- ESLint's TS program cannot resolve `.vue` imports (only `vue-tsc` can), so calls through a component ref are reported as unsafe. Those carry an inline disable with the reason `eslint's ts program resolves .vue imports as error typed, vue-tsc accepts this`.
|
||||
- Every `eslint-disable` **must** state a reason after `--`. It is for false positives only, never to avoid a fix. Stale disables are errors (`reportUnusedDisableDirectives`), and eslint runs with `--max-warnings 0`, so warnings fail the gate too.
|
||||
- `@typescript-eslint/no-explicit-any` is off; `prefer-function-type` is off (keeps the `defineEmits<{ (e: ...): void }>()` style).
|
||||
|
||||
**knip** ([knip.ts](knip.ts)) guards dead exports, unused files, and unused dependencies. Its nuxt plugin resolves auto-imports through the generated `.nuxt` maps, so unused components, stores, and utils **are** detected despite having no import statements. Remaining blind spots, documented in the config: `app/composables` are entry points (the auto-import map does not connect their `export default function useX` style, so their internal exports are unreported), and `app/components/shadcn/` is ignored (vendored spares are expected). Knip's `unlisted` check is **off** by policy: the hoisted node_modules layout makes transitive packages importable, that is relied on deliberately, and `package.json` declares top-level intent only. Knip runs with `--cache`, and the cache is keyed by knip version, **not** config — after editing [knip.ts](knip.ts), run `rm -rf node_modules/.cache/knip` or results are stale.
|
||||
|
||||
**`pnpm dedupe --check`** is a lint gate because `nodeLinker: hoisted` puts every resolved version of a package on disk, and Nitro externalizes whatever it finds. A lockfile holding two versions of one package therefore ships both into the server bundle. For a package that keeps module-level state — `vue` above all, whose `currentRenderingInstance` lives in `@vue/runtime-core` — that is a crash: one copy renders a component while the other's variable is still null, and SSR dies with `Cannot read properties of null`. Dependency bumps produce the split without touching `package.json`, when a bumped package moves to a new version while a peer-resolved entry keeps the old one.
|
||||
|
||||
TypeScript strictness (set in [nuxt.config.ts](nuxt.config.ts)): `noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexedAccess`, `noImplicitOverride`, `verbatimModuleSyntax`, and `noImplicitAny` all on; `allowUnreachableCode: false` (mirrored in [electron/tsconfig.json](electron/tsconfig.json)). Path aliases `@/*` and `~/*` both point to `app/*`.
|
||||
|
||||
Note `noUncheckedIndexedAccess` makes every index access `T | undefined`, and TypeScript does not track assignments made inside a callback — so a variable filled in by a `forEach`/`descendants` callback stays narrowed to its initializer afterwards. Prefer a sentinel value or a restructure over a non-null assertion when that happens.
|
||||
|
||||
## Testing
|
||||
|
||||
### Layout
|
||||
|
||||
- **All tests except e2e are co-located**: the test file lives in the same
|
||||
directory as the file it tests, paired 1:1 — `string.ts` →
|
||||
`string.test.ts`, `CalendarInput.vue` → `CalendarInput.nuxt.test.ts`.
|
||||
Never a parallel `tests/unit/...` mirror tree.
|
||||
- When a test exercises behaviour spanning two source files, it lives
|
||||
with the file that initiates the behaviour (a round-trip test lands
|
||||
beside the file that starts the round-trip).
|
||||
- **The test file mirrors the source file's order**: top-level
|
||||
`describe` blocks appear in the same order as the functions they test
|
||||
appear in the source, so the two files read side by side.
|
||||
- **The suffix encodes the test environment** — never `.spec.ts`, never an
|
||||
`@vitest-environment` pragma comment:
|
||||
|
||||
| suffix | environment | for |
|
||||
| --- | --- | --- |
|
||||
| `.test.ts` | node, no DOM | pure logic: utils, editor diff/blocks helpers, `electron/` (with `vi.mock("electron")`) |
|
||||
| `.nuxt.test.ts` | nuxt runtime | composables/stores (auto-imports, `registerEndpoint`) and **all component tests** (`mountSuspended`, real children, mocked network) |
|
||||
| `.browser.test.ts` | vitest browser mode (headless chromium via playwright) | DOM behavior happy-dom fakes (e.g. real `getBoundingClientRect` geometry in tiptap overlays, DOMPurify) |
|
||||
| `.test-d.ts` | typecheck only | compile-time contracts (`expectTypeOf`), e.g. `app/utils/api/` types |
|
||||
| `.bench.ts` | `vitest bench` | perf on hot paths (diff/lcs) — add only when a regression bites |
|
||||
|
||||
- Only **e2e** (Playwright, incl. visual regression via `toHaveScreenshot`)
|
||||
lives outside this component altogether, in the repo-root
|
||||
[e2e/](../e2e/) package. Those tests drive the whole composed product
|
||||
against a real backend, so they belong to no single component and stay
|
||||
out of `web/`'s toolchain — see [e2e/AGENTS.md](../e2e/AGENTS.md).
|
||||
- There is no separate "integration" tier: component tests are the
|
||||
integration layer (real children, mocked IO); e2e covers cross-system.
|
||||
- Co-location inside `app/` is safe by design: Nuxt's default `ignore`
|
||||
list excludes `**/*.{spec,test}.*` from all scanners, so a test file
|
||||
next to a plugin or middleware is never registered as one. The
|
||||
`.nuxt.test.ts` suffix is the officially documented environment opt-in;
|
||||
the vitest config still declares the env-by-suffix globs explicitly so
|
||||
the mapping is configuration, not tool default.
|
||||
- **`app/components/shadcn/` is out of scope for the suite**: those
|
||||
components are vendored and regenerated by the shadcn CLI, so they get
|
||||
no tests of their own. Every vitest project excludes the directory from
|
||||
test collection and it is excluded from coverage, so a test file placed
|
||||
there would simply never run — do not write one. Rendering a shadcn
|
||||
component as a real child of a component under test is expected and
|
||||
fine; so is importing one as a test harness (see
|
||||
`mountUnderSidebarProvider` below).
|
||||
|
||||
### Naming
|
||||
|
||||
**The rule: `describe` names the subject, `it` completes the sentence
|
||||
"it …" with observable behaviour.**
|
||||
|
||||
```ts
|
||||
describe("useCart", () => {
|
||||
describe("addItem", () => {
|
||||
it("increments the total by the item price")
|
||||
it("merges duplicate items into one line with summed quantity")
|
||||
it("throws when the item is out of stock")
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Read it aloud: "useCart addItem increments the total by the item price."
|
||||
If the concatenation isn't a grammatical sentence, the name is off. This
|
||||
matters practically because reporters print exactly that concatenation on
|
||||
failure, and a good name lets you diagnose without opening the file.
|
||||
|
||||
**`describe`**
|
||||
|
||||
- Top level: **always required** — every test file wraps its tests in a
|
||||
root `describe` identifying the testable unit by its real identifier —
|
||||
`useCart`, `<PriceTag>`, `formatDate`. Never bare top-level `it` calls.
|
||||
Don't paraphrase ("cart logic"), use the greppable name.
|
||||
- Nested level (optional): a method, prop, or condition — `addItem`,
|
||||
`when the user is anonymous`. One level of nesting is usually right,
|
||||
two is the ceiling. Deeply nested describes with `beforeEach` at each
|
||||
level are a classic readability trap: state gets assembled across four
|
||||
scopes and no single test is comprehensible alone.
|
||||
- `describe("when X", ...)` grouping is good when several tests share a
|
||||
precondition; otherwise put the condition in the `it` name.
|
||||
|
||||
**`it`**
|
||||
|
||||
- Present tense, third person, no "should".
|
||||
`it("returns null for empty input")`, not `it("should return null…")` —
|
||||
"should" is eight wasted characters on every line and adds nothing.
|
||||
- State behaviour + condition, not implementation:
|
||||
`it("retries the request twice before failing")`, not
|
||||
`it("calls fetchWithRetry with maxRetries=2")`. The test name should
|
||||
survive a refactor that preserves behaviour.
|
||||
- Be specific enough that a failure is informative. `it("works")`,
|
||||
`it("handles errors")`, `it("renders correctly")` are useless — which
|
||||
error, what is correct? `it("renders the fallback avatar when the image
|
||||
404s")` tells you what broke.
|
||||
- Edge cases worth naming explicitly: empty input, boundary values, error
|
||||
paths. A suite skeleton often reads: happy path first, then
|
||||
`it("returns [] when the list is empty")`,
|
||||
`it("throws TypeError for non-ISO strings")`, etc.
|
||||
|
||||
For components, name from the user's perspective where possible:
|
||||
`it("shows the discount badge when price is reduced")` rather than
|
||||
`it("sets showBadge to true")`.
|
||||
|
||||
Parameterized tests: use the format placeholders so each case gets a
|
||||
distinct name — `it.for(cases)("parses %s as %i", ...)` — otherwise
|
||||
failures all report the same string.
|
||||
|
||||
### Parameterized tests
|
||||
|
||||
**Use `it.for` wherever cases differ only in inputs and expected
|
||||
outputs — pure data tables.** The moment cases differ in behaviour —
|
||||
different mock wiring, different setup, different assertions — they are
|
||||
separate `it`s inside the method's `describe` (see the next section),
|
||||
never rows sharing one callback: divergent cases in a shared callback
|
||||
breed conditionals, and neither a breakpoint nor a reporter line can
|
||||
target a single row.
|
||||
|
||||
For data tables, writing a separate test per case gets repetitive;
|
||||
`it.for` defines the cases as data and runs the same test logic for all
|
||||
of them:
|
||||
|
||||
```ts
|
||||
it.for([
|
||||
[1, 1, 2],
|
||||
[1, 2, 3],
|
||||
[2, 1, 3],
|
||||
])("add(%i, %i) -> %i", ([a, b, expected], { expect }) => {
|
||||
expect(a + b).toBe(expected)
|
||||
})
|
||||
```
|
||||
|
||||
The placeholders `%i`, `%s`, and `%f` in the test name are replaced with
|
||||
the corresponding values from each row, so the output shows
|
||||
`add(1, 1) -> 2`, `add(1, 2) -> 3`, and so on.
|
||||
|
||||
When cases have more than two or three values, objects are more readable.
|
||||
Use `$property` in the name to interpolate fields:
|
||||
|
||||
```ts
|
||||
it.for([
|
||||
{ input: "", expected: [] },
|
||||
{ input: "a", expected: ["a"] },
|
||||
{ input: "a,b", expected: ["a", "b"] },
|
||||
])("splits $input into $expected", ({ input, expected }, { expect }) => {
|
||||
expect(split(input)).toEqual(expected)
|
||||
})
|
||||
```
|
||||
|
||||
- When placeholder interpolation cannot form a grammatical sentence, give
|
||||
the case an explicit `name` field and use it as the whole title:
|
||||
`it.for(cases)("$name", …)`.
|
||||
- Case objects use the same field vocabulary everywhere: `name`, `input`,
|
||||
`expected`.
|
||||
- Hoist bulky case data into named builders above the table instead of
|
||||
inlining large literals. Store mutable case values as thunks
|
||||
(`makeDoc: () => …`) invoked inside the test body, so a case object
|
||||
can never leak state between tests.
|
||||
- Always `it.for`, never `it.each`: `.for` passes the test context as
|
||||
the second callback argument — which the concurrency rule below
|
||||
requires for `expect` — while `.each` spreads the case and provides no
|
||||
context.
|
||||
|
||||
### Mocks & path coverage
|
||||
|
||||
- **Every exported function is covered by unit tests** — a module's
|
||||
exports are its testable contract, and none of them ships untested.
|
||||
Modules with no exports whose evaluation wires things up (entry
|
||||
points like `electron/main.ts` and `electron/preload.ts`) are tested
|
||||
through their module side effects: mock the boundaries, import the
|
||||
module, assert the wiring.
|
||||
- **Every path gets a test** — the success path and each failure path,
|
||||
including one per collaborator that can fail, especially in unit tests.
|
||||
A `describe` per method groups them; the linear `it`s are the rows,
|
||||
named after the observable behaviour
|
||||
(`it("propagates the error from db.createItem")`).
|
||||
- A branch that genuinely cannot be reproduced in tests carries a
|
||||
`// NOCOV: <lowercase reason>.` comment as the first line inside the
|
||||
branch body. Environment-bound code is the typical legitimate case:
|
||||
`__DESKTOP_BUILD__` splits, SSR guards, browser-quirk workarounds.
|
||||
- NOCOV is a reviewer covenant, not a tool directive: it is deliberately
|
||||
not wired to the coverage provider (whose ignore hints require the
|
||||
banned `/* */` comment form), so coverage reports stay truthful and
|
||||
the marker explains the residual gap in place. It never excuses a
|
||||
testable path.
|
||||
- Per-test mock behaviour is configured inline with `vi.fn()`
|
||||
(`mockResolvedValue`, `mockRejectedValue`, `mockImplementation`).
|
||||
`vi.mock` module mocks are hoisted and file-level — they cannot vary
|
||||
per test, so anything that must differ between tests is injected, not
|
||||
module-mocked.
|
||||
- Repeated stub shapes become local factory closures
|
||||
(`const stubDb = (err?: Error) => …`) defined next to the tests that
|
||||
use them; dependencies shared across many tests become `test.extend`
|
||||
fixtures.
|
||||
- Helpers repeated across a directory's test files live in a colocated
|
||||
`test-helpers.ts` module (e.g.
|
||||
[app/composables/api/test-helpers.ts](app/composables/api/test-helpers.ts)).
|
||||
It must never be re-exported for app use (in `composables/`, keep it
|
||||
out of `index.ts` — nested files only enter the auto-imports through
|
||||
that re-export), and the `app/**/test-helpers.ts` coverage exclude in
|
||||
[vitest.config.ts](vitest.config.ts) keeps it out of the coverage
|
||||
denominator. Domain-specific fixtures and factories stay file-local.
|
||||
- **One act per test**: arrange once, invoke the unit once, then assert
|
||||
as many facets of that single outcome as needed — result, error, and
|
||||
interactions all belong in the same test when they describe one
|
||||
scenario. Needing a second invocation of the unit is the real "and"
|
||||
smell — split the test, not the assertions.
|
||||
- **Every injected dependency is accounted for in every test**: assert
|
||||
the calls that must have happened (count and arguments) and the zero
|
||||
counts of the ones that must not —
|
||||
`expect(db.createItem).toHaveBeenCalledTimes(0)` after a failed
|
||||
precondition is as load-bearing as any positive assertion. This holds
|
||||
even when the return value already proves the outcome: a call is a
|
||||
potential side effect, and output assertions cannot reveal a stray one.
|
||||
- Interaction assertions at injected boundaries are behaviour, not
|
||||
implementation: they pin the unit's contract — which effects occur,
|
||||
with what, and when — and survive any behaviour-preserving refactor of
|
||||
the unit's internals.
|
||||
- **Only e2e is allowed a real backend**: unit and component tests never
|
||||
perform real IO — network, filesystem, IPC — every such boundary is
|
||||
mocked (`registerEndpoint`, injected stubs).
|
||||
- Test through the module's public exports. Never export something
|
||||
solely for tests — an internal complex enough to need direct tests is
|
||||
extracted into its own module.
|
||||
- Plain `expect` for guards and preconditions; `expect.soft` is
|
||||
permitted in the final outcome-accounting block, where it reports the
|
||||
whole broken accounting at once instead of stopping at the first
|
||||
failed count.
|
||||
|
||||
### Component tests
|
||||
|
||||
Component tests mount the real component inside the nuxt runtime with
|
||||
`mountSuspended` and drive it the way a user does. Shared helpers live in
|
||||
[app/components/test-helpers.ts](app/components/test-helpers.ts) — the
|
||||
things that come up in nearly every suite:
|
||||
|
||||
- **Never hardcode a translation.** User-facing text is asserted through
|
||||
`t()` from
|
||||
[app/components/test-helpers.ts](app/components/test-helpers.ts), which
|
||||
resolves the key against the same i18n instance the component renders
|
||||
with — `expect(wrapper.text()).toContain(t("sidebar.sections.top.inbox"))`,
|
||||
never `toContain("Inbox")`. A copied english string is a second,
|
||||
unmaintained definition of the message: it keeps passing when the copy
|
||||
changes and, in the other direction, a message edit silently breaks a
|
||||
suite that has nothing to do with it. This covers every place the text
|
||||
appears — assertions, the text a lookup helper searches for
|
||||
(`findButtonByText(wrapper, t("..."))`), and props the test feeds in.
|
||||
Rules that follow from it:
|
||||
- **Interpolated messages take their values through `t()` too**, the
|
||||
same ones the component passes:
|
||||
`t("sidebar.search.no-results", { query: "run" })` — never a hand-spliced
|
||||
`"No results found for " + query`, and never a bare prefix like
|
||||
`toContain("No results found for")` that skips the interpolation
|
||||
entirely.
|
||||
- **Pick the key the component actually renders.** Many messages share
|
||||
an english value (six different `cancel-button` keys all read
|
||||
"Cancel"), so the wrong key still passes today and only diverges when
|
||||
one of them is reworded. Follow the component to its key rather than
|
||||
grepping the locale file for the text.
|
||||
- **`t()` needs the nuxt app context**, so it only runs inside a test
|
||||
body or a hook — never at module scope. Text shared across a suite
|
||||
goes in a helper function, not a top-level `const`.
|
||||
- **Context providers.** `useSidebar`, `useSidebarWidth` and reka-ui's
|
||||
tooltip context are installed once at page level in the app, so a
|
||||
component that reads one cannot mount on its own. Mount it through
|
||||
`mountUnderSidebarProvider` / `mountUnderTooltipProvider`.
|
||||
- **Overlays live in `<body>`.** Tooltip, dropdown, dialog and sheet
|
||||
bodies are teleported out of the wrapper, so `wrapper.text()` never
|
||||
sees them — reach them with `menuItem()`, `teleportedButton()` and
|
||||
`openTooltipText()`. Unmounting does not always take a teleported node
|
||||
with it (a pending presence transition outlives it) and reka-ui reuses
|
||||
its generated ids across mounts, so suites that drive overlays call
|
||||
`clearTeleportedOverlays()` from a `beforeEach`. Sheet-based suites
|
||||
must **not**: ripping out a node vue still has mounted breaks its next
|
||||
patch.
|
||||
- **No real IO, again.** The global route middleware asks better-auth for
|
||||
the session on every navigation, which means every mount — so
|
||||
[vitest.nuxt-setup.ts](vitest.nuxt-setup.ts) stubs a signed-out session
|
||||
for the whole nuxt project (unstubbed it is a real request that costs
|
||||
~5s per mount). `@nuxt/icon` is pinned to `provider: "none"` in the
|
||||
same config for the same reason. Suites that need a signed-in state
|
||||
seed the cache with `seedAuthSession` / `seedAuthOrganization` /
|
||||
`seedAuthAccounts`, or register endpoints.
|
||||
- **An endpoint is registered under the url the client actually asks
|
||||
for**, which is not the same string everywhere: `$coreAPIClient` has an
|
||||
empty base in tests, so it matches a bare path (`/api/documents/tree`),
|
||||
while `$authRealtimeAPIClient` has an absolute one and matches the full
|
||||
url. better-auth needs *both* spellings — the absolute one keeps the
|
||||
request off the real network, the path one is what the test-time h3 app
|
||||
matches a handler on — so it goes through `mockAuthEndpoint()`.
|
||||
- **A failing endpoint answers with an h3 error**, never a bare `throw
|
||||
new Error(...)`. h3 treats anything that is not an `H3Error` as
|
||||
unhandled and dumps it with its full stack to stderr — for an outcome
|
||||
the test is asserting on. `throw createError({ statusCode: 500 })`
|
||||
fails the request the same way and stays quiet.
|
||||
- **Viewport-dependent layouts.** happy-dom has no `matchMedia`, so
|
||||
`useMediaQuery` reports "not matching" for every query — pick a side
|
||||
deliberately with `stubViewportMatches()`.
|
||||
- **Pointer and geometry behaviour** (the sidebar drag) is driven the way
|
||||
[app/composables/useSidebarDraggable.nuxt.test.ts](app/composables/useSidebarDraggable.nuxt.test.ts)
|
||||
does it: `mockNuxtImport` the vueuse primitives (`useDraggable`,
|
||||
`useMouseInElement`, `useElementBounding`), then drive the captured
|
||||
callbacks and refs. Everything between them and the component stays
|
||||
real. Return **refs**, not plain objects — a plain `{ value }` leaves
|
||||
every watcher and computed behind it inert, and the assertions then
|
||||
pass or fail on flush timing rather than on behaviour.
|
||||
- **Settling.** A pinia-colada mutation that invalidates queries on
|
||||
success resolves several macrotasks deep; use `settleMutations()`, or
|
||||
`settleActionSubmit()` for the settings action modals (which also hold
|
||||
their submit for `delay(300)`, so they mount through
|
||||
`mountWithFrozenClock()`). better-auth backs off internally before
|
||||
surfacing a failed request, and vee-validate renders messages on its
|
||||
own scheduler — neither exposes a signal, so those assertions are the
|
||||
documented `vi.waitFor` exception.
|
||||
- **Shared app state forces `{ concurrent: false }`.** Beyond `vi.mock`
|
||||
singletons, the same applies to nuxt `useState`, pinia stores,
|
||||
cookie-backed `usePersistentState`, `vi.stubGlobal`, fake timers and
|
||||
the teleport target — all of them are shared by every mount in a file.
|
||||
Mark **each** describe that touches them, nested ones included: the
|
||||
option only covers the suite it is on.
|
||||
- **A component that keeps watching after its test** — one holding a
|
||||
colour-mode watcher, a branch sync, or a teleported overlay vue still
|
||||
patches — is taken down with `enableAutoUnmount(afterEach)` rather than
|
||||
left mounted. A leftover instance answers the next test too, and
|
||||
`clearTeleportedOverlays()` cannot be used on it: ripping out a node
|
||||
vue still has mounted breaks its next patch.
|
||||
|
||||
### Editor component tests
|
||||
|
||||
The editor suites reuse a few things the rest of the components do not:
|
||||
|
||||
- **Node views** ([app/components/editor/test-helpers/node-view.ts](app/components/editor/test-helpers/node-view.ts))
|
||||
mount on their own through `mountNodeView()`, which supplies every
|
||||
`nodeViewProps` entry plus the `onDragStart` / `decorationClasses`
|
||||
injections tiptap's renderer would provide. `makeNode()` and
|
||||
`makeEditor()` stand in for the node and the editor; the command chain
|
||||
a node view builds is recorded, so a suite asserts *which* commands ran
|
||||
and with what rather than reaching into prosemirror.
|
||||
- **Theme colours** are resolved by painting CSS variables onto a canvas,
|
||||
which happy-dom has no 2d context for — anything rendering a chart, a
|
||||
metric block or a caret needs `stubThemeColorContext()` from
|
||||
[app/components/editor/test-helpers/theme.ts](app/components/editor/test-helpers/theme.ts)
|
||||
first.
|
||||
- **Charts** are asserted through the option object handed to echarts
|
||||
(`chartOption()`), with `vue-echarts` replaced by a stub — echarts
|
||||
itself needs a canvas.
|
||||
- **Virtualized lists** (`vue-virtual-scroller`) measure row heights,
|
||||
which happy-dom reports as zero, so nothing is ever drawn: suites that
|
||||
need the rows mock the module with a pass-through component.
|
||||
- **`editor.commands` is rebuilt on every access**, so a spy on one of
|
||||
them is never the object the component calls. Shadow the getter with a
|
||||
recording proxy when a suite needs to see which commands ran.
|
||||
- **A live editor** is built with the real extensions when the behaviour
|
||||
under test reads the document (link marks, comment marks, the merged
|
||||
diff). A collaboration-backed component also needs a provider
|
||||
stand-in carrying `document`, `awareness` and `on`/`off`.
|
||||
|
||||
### Snapshots
|
||||
|
||||
Snapshots are golden files, not lazy assertion dumps: use them only for
|
||||
golden-style serialized outputs (ProseMirror JSON, diff structures) —
|
||||
`toMatchInlineSnapshot` for small values, file snapshots for large ones,
|
||||
explicit assertions everywhere else. Under concurrency they require the
|
||||
context-local `expect` (see below).
|
||||
|
||||
### Independence & concurrency
|
||||
|
||||
- **Every test is completely independent**: it creates its own fresh
|
||||
state (a new instance, store, or mount per test) and never depends on
|
||||
execution order or on state left behind by another test. Each
|
||||
`describe` block focuses on one method; each test verifies one specific
|
||||
behaviour — the suite reads like a specification of the unit, and a
|
||||
failure's name plus assertion tell you exactly what broke without
|
||||
opening the file.
|
||||
- Repeating the same setup in every test is a candidate for `beforeEach`
|
||||
or a `test.extend` fixture — never for module-level shared mutable
|
||||
state.
|
||||
- **All tests run concurrently**: the vitest config sets
|
||||
`sequence.concurrent: true`. Test independence is what makes this safe;
|
||||
a test that needs sequential execution is a smell, not a config
|
||||
exception. The one principled exception: suites asserting call
|
||||
accounting on shared module-level mocks (`vi.mock` singletons) run
|
||||
under `describe("…", { concurrent: false }, …)` with a comment —
|
||||
module mocks are per-file singletons and cannot be isolated across
|
||||
interleaving tests. Prefer per-test injected mocks wherever the design
|
||||
allows; they stay concurrency-safe.
|
||||
- Under concurrency, snapshots and assertions must use the `expect` from
|
||||
the local test context (`it("…", ({ expect }) => …)`) — the global
|
||||
`expect` cannot reliably attribute them to the right test when tests
|
||||
interleave. See
|
||||
[test.concurrent](https://vitest.dev/api/test#test-concurrent).
|
||||
- Mock and global state cannot leak between tests by construction: the
|
||||
vitest config sets `restoreMocks: true`, `unstubGlobals: true`, and
|
||||
`unstubEnvs: true`, so `vi.spyOn` spies, stubbed globals, and env stubs
|
||||
are restored after every test without per-file `afterEach` cleanup.
|
||||
Caveat: since vitest 4, `restoreMocks` does **not** touch hand-made
|
||||
`vi.fn()` singletons — suites holding them (module-mock trees) reset
|
||||
them explicitly in a `beforeEach`.
|
||||
|
||||
### Determinism
|
||||
|
||||
- Never sleepy: no `setTimeout`-based waiting in tests. Await concrete
|
||||
signals — `nextTick()`, `flushPromises()`, an emitted event, a promise
|
||||
returned by the unit itself.
|
||||
- Time is driven, not waited on: `vi.useFakeTimers()` +
|
||||
`advanceTimersByTime()` for timer-dependent code, `vi.setSystemTime()`
|
||||
for deterministic timestamps — assert exact dates, never wall-clock
|
||||
deltas.
|
||||
- `vi.waitFor` is the last resort for genuinely nondeterministic
|
||||
scheduling and always carries a comment justifying why no concrete
|
||||
signal exists.
|
||||
|
||||
### Prior art
|
||||
|
||||
Co-location is established practice across the ecosystem, including
|
||||
dependencies of this app:
|
||||
[Reka UI](https://github.com/unovue/reka-ui/tree/v2/packages/core/src/Dialog)
|
||||
(`Dialog.test.ts` among the Dialog `.vue` components),
|
||||
[Pinia Colada](https://github.com/posva/pinia-colada/tree/main/src)
|
||||
(co-located specs plus `.test-d.ts` type tests),
|
||||
[VueUse](https://github.com/vueuse/vueuse/tree/main/packages/core/useMouse),
|
||||
[PrimeVue](https://github.com/primefaces/primevue/tree/master/packages/primevue/src/button)
|
||||
(`Button.spec.js` beside `Button.vue`),
|
||||
[SvelteKit](https://github.com/sveltejs/kit/tree/main/packages/kit/src/utils),
|
||||
[immich](https://github.com/immich-app/immich) (co-located specs + a
|
||||
dedicated `e2e/` package), and — on nuxt 4's `app/` layout specifically —
|
||||
[kun-galgame-forum](https://github.com/KunMoe/kun-galgame-forum/tree/master/apps/web/app/components/editkit)
|
||||
(specs beside components inside `app/`).
|
||||
|
||||
## Code style
|
||||
|
||||
### Assignments
|
||||
|
||||
**One assignment per statement.** Never chain them, and never assign from inside
|
||||
an expression — enforced by `no-multi-assign`.
|
||||
|
||||
```ts
|
||||
// no: the line both mutates the map and binds a local
|
||||
const group = (groups[key] ??= { items: [] })
|
||||
|
||||
// yes
|
||||
let group = groups[key]
|
||||
if (!group) {
|
||||
group = { items: [] }
|
||||
groups[key] = group
|
||||
}
|
||||
```
|
||||
|
||||
`??=` itself is fine and used across the codebase; keep it as its own
|
||||
statement (`elem.children ??= []`).
|
||||
|
||||
### Vue `<script setup>` ordering
|
||||
|
||||
Within a Vue setup script, order the contents as follows:
|
||||
|
||||
1. Imports
|
||||
2. `defineProps`, `defineEmits`, `defineExpose`
|
||||
3. Store and composable initializations (`useFoo()`, `useStore()`, etc.)
|
||||
4. `ref` and `computed` declarations
|
||||
5. `onMounted` / `onUnmounted` (and other lifecycle hooks)
|
||||
6. Watchers (`watch`, `watchEffect`, `watchImmediate`)
|
||||
7. Functions
|
||||
|
||||
### Styling
|
||||
|
||||
Use **Tailwind utility classes** by default. When a utility expression can't express what you need, add the custom rule to [app/assets/css/main.css](app/assets/css/main.css) and apply it via a class name — do not use `<style>` blocks in components or inline `style=` attributes for non-dynamic values.
|
||||
|
||||
## The PromQL grammar
|
||||
|
||||
PromQL parsing goes through [@oxynote/lezer-promql](https://github.com/oxynote/lezer-promql), a fork of Prometheus's grammar that adds Grafana-style dynamic duration placeholders (`$__interval`, etc.). It is a published package, not a workspace one, and reaches the app through a pnpm override in [pnpm-workspace.yaml](pnpm-workspace.yaml) rather than a bundler alias — `@prometheus-io/codemirror-promql` imports the grammar itself, so the fork has to win that resolution too. Changing the grammar means releasing it from its own repository; a new version needs its version bumped in the override and added to `minimumReleaseAgeExclude`.
|
||||
|
||||
## Electron packaging notes
|
||||
|
||||
`forge.config.ts` allowlists only `/.vite` (compiled main+preload) and `/.output/public` (renderer SPA) into the package — anything else is excluded. The `oxynote://` protocol is registered at install time for OAuth deep-links. Production Fuses disable `RunAsNode`, `EnableNodeOptionsEnvironmentVariable`, and require ASAR integrity. Main and preload are forced to `.cjs` output (see [vite.electron.config.ts](vite.electron.config.ts)) because root `package.json` is `"type": "module"`.
|
||||
|
||||
`__API_BASE_URL__` and `__APP_BASE_URL__` are **baked in at build time** for the Electron bundle — runtime env cannot override them. The build fails if those env vars are missing.
|
||||
622
web/CLAUDE.md
622
web/CLAUDE.md
|
|
@ -1,621 +1 @@
|
|||
# CLAUDE.md
|
||||
|
||||
Guidance for the `web/` frontend. Shared working principles and the TS/JS
|
||||
comment/whitespace rules live in the root [CLAUDE.md](../CLAUDE.md).
|
||||
|
||||
## Project
|
||||
|
||||
**Oxynote** web frontend — Nuxt 4 + Vue 3 frontend that ships as both a web app (Cloudflare Pages, SSR) and an Electron desktop app (static SPA).
|
||||
|
||||
## Common commands
|
||||
|
||||
Package manager is **pnpm** (workspace; uses `nodeLinker: hoisted` so Electron Forge's npm-style layout works).
|
||||
|
||||
```bash
|
||||
pnpm install # installs deps; runs `prepare` (nuxt prepare)
|
||||
pnpm setup # deps + explicit prepare (guarantees fresh .nuxt
|
||||
# types) + playwright chromium for browser-mode
|
||||
# tests
|
||||
|
||||
pnpm start:dev:web # nuxt dev on :3000 (web build)
|
||||
pnpm start:dev:desktop # concurrently runs nuxt dev + electron-forge start with DESKTOP_BUILD=hybrid
|
||||
pnpm build:web # production web build (cloudflare_pages preset)
|
||||
pnpm package:desktop # nuxt generate (static) + electron-forge package
|
||||
pnpm make:desktop # nuxt generate (static) + electron-forge make (installers)
|
||||
|
||||
pnpm check-lint # pnpm dedupe --check + check-types +
|
||||
# check-eslint + check-fmt + check-knip
|
||||
# (read-only)
|
||||
pnpm lint # fixing variant: pnpm dedupe + check-types +
|
||||
# knip --fix (removes dead exports/deps/files!)
|
||||
# + eslint --fix + prettier --write
|
||||
pnpm test # vitest run --coverage (node + browser + nuxt
|
||||
# projects; browser needs the playwright chromium
|
||||
# that `pnpm setup` installs)
|
||||
pnpm test-watch # vitest in watch mode
|
||||
pnpm qa # check-lint + test; qa-fix = lint + test
|
||||
pnpm check-types # nuxt typecheck (regenerates .nuxt types, then
|
||||
# vue-tsc -b --noEmit; plain vue-tsc --noEmit on
|
||||
# the solution-style root tsconfig checks nothing)
|
||||
# + tsc on electron/ (own tsconfig, not part of
|
||||
# the nuxt solution)
|
||||
pnpm check-eslint # eslint --max-warnings 0 . (eslint / fmt are the
|
||||
# fixing counterparts)
|
||||
pnpm check-knip # dead exports/files/dependencies ([knip.ts](knip.ts))
|
||||
pnpm dedupe --check # fails when the lockfile resolves one package to
|
||||
# several versions that could share one
|
||||
|
||||
```
|
||||
|
||||
|
||||
## Build modes (critical)
|
||||
|
||||
The `DESKTOP_BUILD` env var drives a Vite `define` that materializes `__DESKTOP_BUILD__` in the bundle. See [nuxt.config.ts:9-24](nuxt.config.ts#L9-L24) and [index.d.ts:1-9](index.d.ts#L1-L9):
|
||||
|
||||
- `DESKTOP_BUILD=0` / unset → web build. `__DESKTOP_BUILD__` is literal `false`. SSR enabled. Nitro preset `cloudflare_pages`, overridable via `NITRO_PRESET` (the docker image builds with `node-server`).
|
||||
- `DESKTOP_BUILD=1` → pure desktop build. Literal `true`. SSR off. Nitro preset `static`. Renderer is served from `oxynote://app/index.html` out of `.output/public/`.
|
||||
- `DESKTOP_BUILD=hybrid` → dev-only, used by `start:dev:desktop`. One Nuxt dev server is hit by *both* the Electron renderer and the system browser opened for OAuth. `__DESKTOP_BUILD__` becomes a *runtime probe* of `window.__isElectron` (set by [electron/preload.ts](electron/preload.ts)) so each context picks the right branch from the same bundle.
|
||||
|
||||
When writing code that branches on platform, always use `__DESKTOP_BUILD__` — never `process.platform`, `import.meta.client`, or feature-detect on `window`. The literal-substitution is the security boundary: in the desktop bundle, no code path can carry a session cookie.
|
||||
|
||||
## Auth architecture
|
||||
|
||||
Uses **Better Auth** with the `@better-auth/electron` plugin. Two distinct flows:
|
||||
|
||||
- **Web**: `app/plugins/02.auth.ts` instantiates a standard `createAuthClient` with `credentials: "include"`. Session cookies flow normally.
|
||||
- **Desktop**: the renderer's `createAuthClient` is forced to `credentials: "omit"`. The real session lives in **main**'s [electron/auth-client.ts](electron/auth-client.ts) backed by `electron-store` (encrypted via `safeStorage` + Electron's `EnableCookieEncryption` fuse). The renderer reaches auth only through `window.__host.auth.*` IPC bridges defined in [electron/auth-ipc.ts](electron/auth-ipc.ts) and [electron/preload.ts](electron/preload.ts). To add a new auth operation: add a handler in `auth-ipc.ts`, expose it in `preload.ts`, type it in [index.d.ts](index.d.ts), and add the desktop branch in [app/composables/useAuthSession.ts](app/composables/useAuthSession.ts).
|
||||
|
||||
Main's [electron/main.ts](electron/main.ts) injects the session cookie into renderer-originated requests via `webRequest.onBeforeSendHeaders` — this is why the renderer never holds the cookie. The `/api/auth/*` path is carved out of that injection because those calls must keep going through the IPC bridge.
|
||||
|
||||
OAuth flow on desktop: renderer calls `window.requestAuth({ provider })` → opens system browser at `${APP_BASE_URL}/login` → web flow completes → server redirects to `oxynote://` deep-link → OS focuses Electron → Better Auth's `setupMain()` exchanges the code → main fires `onAuthenticated` over the bridge → [app/plugins/electron-auth.client.ts](app/plugins/electron-auth.client.ts) refetches session and navigates.
|
||||
|
||||
## API clients
|
||||
|
||||
`app/plugins/03.api-fetch.ts` provides two `$fetch` instances:
|
||||
|
||||
- `$coreAPIClient` → core API (`NUXT_PUBLIC_CORE_API_BASE_HTTP_URL`)
|
||||
- `$authRealtimeAPIClient` → auth-realtime API (`NUXT_PUBLIC_AUTH_REALTIME_API_BASE_HTTP_URL`)
|
||||
|
||||
Both propagate SSR request headers (captured eagerly during plugin setup — the H3 context is lost inside `onRequest` callbacks on Cloudflare workerd) and redirect to `/login` on 401.
|
||||
|
||||
When the server-only runtime config keys `coreAPIInternalHttpURL` / `authRealtimeAPIInternalHttpURL` (`NUXT_CORE_API_INTERNAL_HTTP_URL` / `NUXT_AUTH_REALTIME_API_INTERNAL_HTTP_URL`) are set, SSR fetches (including the auth client in `app/plugins/02.auth.ts`) use them instead of the public URLs — required when the app runs inside a container where the public localhost origin is unreachable.
|
||||
|
||||
## Data layer
|
||||
|
||||
**Pinia Colada** (`useQuery` / `useMutation`) is the standard data-fetching primitive. Auto-refetch plugin is configured but *disabled by default* — opt in per-query with `autoRefetch: true` (typed augmentation in [index.d.ts:34-38](index.d.ts#L34-L38)). See [colada.options.ts](colada.options.ts).
|
||||
|
||||
API composables live in [app/composables/api/](app/composables/api/) and are re-exported by [app/composables/index.ts](app/composables/index.ts) for auto-import. Request/response types live in [app/utils/api/](app/utils/api/) and are re-exported by [app/utils/index.ts](app/utils/index.ts).
|
||||
|
||||
## Editor (TipTap + Yjs)
|
||||
|
||||
Document editor is in [app/components/editor/](app/components/editor/). Real-time collaboration uses **Yjs** + **Hocuspocus** (`NUXT_PUBLIC_AUTH_REALTIME_API_BASE_WS_URL`). Notable subsystems:
|
||||
|
||||
- `blocks/` — custom node types (mermaid, metrics, code-block, figma, image, file, callout, split-documentation); `upload-handler.ts` routes dropped and pasted files to the image or file block by type
|
||||
- `comments/` — comment marks + node-comment extension
|
||||
- `diff/` — branch diffing UI (compute, render, decorations)
|
||||
- `drag-handle/`, `slash/`, `link/`, `ai/` — editor UX extensions
|
||||
- `hooks/` — block-level integrations (github tracking, container image watcher, scheduled reminders, URL watcher)
|
||||
|
||||
Editor-wide state (active document, branch, locks, metric configs, diff statuses) lives in [app/stores/editor.ts](app/stores/editor.ts).
|
||||
|
||||
## Routing
|
||||
|
||||
Single dynamic page handles the workspace: [app/pages/[[organizationSlug]]/[[documentSlug]].vue](app/pages/[[organizationSlug]]/[[documentSlug]].vue). The global middleware [app/middleware/01.redirect.global.ts](app/middleware/01.redirect.global.ts) handles auth gating, onboarding, and root-path redirection to the first document.
|
||||
|
||||
Pages with `definePageMeta({ skipAuth: true })` are reachable when signed-out (login, signup, accept-invite, verify-email, desktop-auth).
|
||||
|
||||
## i18n
|
||||
|
||||
All user-facing text **must** live under [i18n/locales/en/](i18n/locales/en/) — never inline. The setup combines multiple JSON files into one big i18n object, so each file must have a **root namespace key** (e.g. `{ "sidebar": { ... } }`). To add a new namespace, also register the filename in `nuxt.config.ts` under `i18n.locales[0].files`.
|
||||
|
||||
`<i18n-t>` carries **`scope="global"`** on every use. Its default scope is `parent`, which looks for an ancestor that opened its own i18n scope; nothing in this app does, so without the prop vue-i18n warns once per render and falls back to the global scope anyway.
|
||||
|
||||
Form validation (vee-validate) uses its own internal messages — see [README.md](README.md).
|
||||
|
||||
## UI
|
||||
|
||||
- **shadcn-vue** components live in [app/components/shadcn/ui/](app/components/shadcn/ui/) with prefix `ShadcnUi`. Configured in [components.json](components.json).
|
||||
- **Tailwind v4** via `@tailwindcss/vite`, theme in [app/assets/css/main.css](app/assets/css/main.css).
|
||||
- Icons via `@nuxt/icon` (CSS mode). The client bundle holds the icons the scanner finds in app code, plus — on the desktop build only — the document title icons listed by `selectableIconList()` in [app/utils/icon.ts](app/utils/icon.ts). The web build resolves those on demand through the server so they stay off the startup path. The picker itself does not go through `<Icon>`: [modules/icon-picker-css.ts](modules/icon-picker-css.ts) generates one stylesheet with a rule per selectable icon, served as `virtual:icon-picker.css` and imported by the picker provider once the page's load event has fired. Custom SVGs live in [app/assets/custom-icons/](app/assets/custom-icons/) and are reachable under the `custom-icons:` prefix.
|
||||
- **Every dialog describes itself.** reka-ui points `DialogContent`'s `aria-describedby` at a `DialogDescription`, so each dialog and sheet renders one — `ShadcnUiDialogDescription` (or the sheet variant) with `sr-only` when nothing visible plays that role, `as-child` around an `<i18n-t tag="p">` when the visible description is interpolated. The settings action components render theirs inside the `ActionModal` around them, which is why their tests mount through `mountUnderDialogRoot`. A dialog missing one makes reka-ui warn once per mount in `nuxt dev` and in test output.
|
||||
|
||||
## Formatting & TS
|
||||
|
||||
Prettier uses **tabs**, no semicolons, trailing commas — see [prettier.config.js](prettier.config.js).
|
||||
|
||||
ESLint ([eslint.config.mjs](eslint.config.mjs)) runs **type-aware**: `eslint.config.typescript.tsconfigPath` in `nuxt.config.ts` switches the generated Nuxt preset to the `*-type-checked` rule sets, on top of which the config adds `eslint:recommended` (the Nuxt preset ships no base JS rules), typescript-eslint **strict + stylistic**, `@intlify/eslint-plugin-vue-i18n`, and `eslint-plugin-vuejs-accessibility` (its recommended set; the shadcn wrappers are exempt from the label-association and static-element rules, since the association is made where they are used). Consequences worth knowing:
|
||||
|
||||
- Linting builds a full TS program, so a cold `check-eslint` takes about a
|
||||
minute. Runs are cached (`node_modules/.cache/eslint`) — unchanged files
|
||||
are near-instant. Caveat: the cache is per-file while type-aware results
|
||||
depend on *other* files' types, so after cross-file type changes a cached
|
||||
file can report stale results locally. CI always runs cold and is the
|
||||
gate of record; locally, `rm -rf node_modules/.cache/eslint` forces a
|
||||
full run.
|
||||
- ESLint's TS program cannot resolve `.vue` imports (only `vue-tsc` can), so calls through a component ref are reported as unsafe. Those carry an inline disable with the reason `eslint's ts program resolves .vue imports as error typed, vue-tsc accepts this`.
|
||||
- Every `eslint-disable` **must** state a reason after `--`. It is for false positives only, never to avoid a fix. Stale disables are errors (`reportUnusedDisableDirectives`), and eslint runs with `--max-warnings 0`, so warnings fail the gate too.
|
||||
- `@typescript-eslint/no-explicit-any` is off; `prefer-function-type` is off (keeps the `defineEmits<{ (e: ...): void }>()` style).
|
||||
|
||||
**knip** ([knip.ts](knip.ts)) guards dead exports, unused files, and unused dependencies. Its nuxt plugin resolves auto-imports through the generated `.nuxt` maps, so unused components, stores, and utils **are** detected despite having no import statements. Remaining blind spots, documented in the config: `app/composables` are entry points (the auto-import map does not connect their `export default function useX` style, so their internal exports are unreported), and `app/components/shadcn/` is ignored (vendored spares are expected). Knip's `unlisted` check is **off** by policy: the hoisted node_modules layout makes transitive packages importable, that is relied on deliberately, and `package.json` declares top-level intent only. Knip runs with `--cache`, and the cache is keyed by knip version, **not** config — after editing [knip.ts](knip.ts), run `rm -rf node_modules/.cache/knip` or results are stale.
|
||||
|
||||
**`pnpm dedupe --check`** is a lint gate because `nodeLinker: hoisted` puts every resolved version of a package on disk, and Nitro externalizes whatever it finds. A lockfile holding two versions of one package therefore ships both into the server bundle. For a package that keeps module-level state — `vue` above all, whose `currentRenderingInstance` lives in `@vue/runtime-core` — that is a crash: one copy renders a component while the other's variable is still null, and SSR dies with `Cannot read properties of null`. Dependency bumps produce the split without touching `package.json`, when a bumped package moves to a new version while a peer-resolved entry keeps the old one.
|
||||
|
||||
TypeScript strictness (set in [nuxt.config.ts](nuxt.config.ts)): `noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexedAccess`, `noImplicitOverride`, `verbatimModuleSyntax`, and `noImplicitAny` all on; `allowUnreachableCode: false` (mirrored in [electron/tsconfig.json](electron/tsconfig.json)). Path aliases `@/*` and `~/*` both point to `app/*`.
|
||||
|
||||
Note `noUncheckedIndexedAccess` makes every index access `T | undefined`, and TypeScript does not track assignments made inside a callback — so a variable filled in by a `forEach`/`descendants` callback stays narrowed to its initializer afterwards. Prefer a sentinel value or a restructure over a non-null assertion when that happens.
|
||||
|
||||
## Testing
|
||||
|
||||
### Layout
|
||||
|
||||
- **All tests except e2e are co-located**: the test file lives in the same
|
||||
directory as the file it tests, paired 1:1 — `string.ts` →
|
||||
`string.test.ts`, `CalendarInput.vue` → `CalendarInput.nuxt.test.ts`.
|
||||
Never a parallel `tests/unit/...` mirror tree.
|
||||
- When a test exercises behaviour spanning two source files, it lives
|
||||
with the file that initiates the behaviour (a round-trip test lands
|
||||
beside the file that starts the round-trip).
|
||||
- **The test file mirrors the source file's order**: top-level
|
||||
`describe` blocks appear in the same order as the functions they test
|
||||
appear in the source, so the two files read side by side.
|
||||
- **The suffix encodes the test environment** — never `.spec.ts`, never an
|
||||
`@vitest-environment` pragma comment:
|
||||
|
||||
| suffix | environment | for |
|
||||
| --- | --- | --- |
|
||||
| `.test.ts` | node, no DOM | pure logic: utils, editor diff/blocks helpers, `electron/` (with `vi.mock("electron")`) |
|
||||
| `.nuxt.test.ts` | nuxt runtime | composables/stores (auto-imports, `registerEndpoint`) and **all component tests** (`mountSuspended`, real children, mocked network) |
|
||||
| `.browser.test.ts` | vitest browser mode (headless chromium via playwright) | DOM behavior happy-dom fakes (e.g. real `getBoundingClientRect` geometry in tiptap overlays, DOMPurify) |
|
||||
| `.test-d.ts` | typecheck only | compile-time contracts (`expectTypeOf`), e.g. `app/utils/api/` types |
|
||||
| `.bench.ts` | `vitest bench` | perf on hot paths (diff/lcs) — add only when a regression bites |
|
||||
|
||||
- Only **e2e** (Playwright, incl. visual regression via `toHaveScreenshot`)
|
||||
lives outside this component altogether, in the repo-root
|
||||
[e2e/](../e2e/) package. Those tests drive the whole composed product
|
||||
against a real backend, so they belong to no single component and stay
|
||||
out of `web/`'s toolchain — see [e2e/CLAUDE.md](../e2e/CLAUDE.md).
|
||||
- There is no separate "integration" tier: component tests are the
|
||||
integration layer (real children, mocked IO); e2e covers cross-system.
|
||||
- Co-location inside `app/` is safe by design: Nuxt's default `ignore`
|
||||
list excludes `**/*.{spec,test}.*` from all scanners, so a test file
|
||||
next to a plugin or middleware is never registered as one. The
|
||||
`.nuxt.test.ts` suffix is the officially documented environment opt-in;
|
||||
the vitest config still declares the env-by-suffix globs explicitly so
|
||||
the mapping is configuration, not tool default.
|
||||
- **`app/components/shadcn/` is out of scope for the suite**: those
|
||||
components are vendored and regenerated by the shadcn CLI, so they get
|
||||
no tests of their own. Every vitest project excludes the directory from
|
||||
test collection and it is excluded from coverage, so a test file placed
|
||||
there would simply never run — do not write one. Rendering a shadcn
|
||||
component as a real child of a component under test is expected and
|
||||
fine; so is importing one as a test harness (see
|
||||
`mountUnderSidebarProvider` below).
|
||||
|
||||
### Naming
|
||||
|
||||
**The rule: `describe` names the subject, `it` completes the sentence
|
||||
"it …" with observable behaviour.**
|
||||
|
||||
```ts
|
||||
describe("useCart", () => {
|
||||
describe("addItem", () => {
|
||||
it("increments the total by the item price")
|
||||
it("merges duplicate items into one line with summed quantity")
|
||||
it("throws when the item is out of stock")
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Read it aloud: "useCart addItem increments the total by the item price."
|
||||
If the concatenation isn't a grammatical sentence, the name is off. This
|
||||
matters practically because reporters print exactly that concatenation on
|
||||
failure, and a good name lets you diagnose without opening the file.
|
||||
|
||||
**`describe`**
|
||||
|
||||
- Top level: **always required** — every test file wraps its tests in a
|
||||
root `describe` identifying the testable unit by its real identifier —
|
||||
`useCart`, `<PriceTag>`, `formatDate`. Never bare top-level `it` calls.
|
||||
Don't paraphrase ("cart logic"), use the greppable name.
|
||||
- Nested level (optional): a method, prop, or condition — `addItem`,
|
||||
`when the user is anonymous`. One level of nesting is usually right,
|
||||
two is the ceiling. Deeply nested describes with `beforeEach` at each
|
||||
level are a classic readability trap: state gets assembled across four
|
||||
scopes and no single test is comprehensible alone.
|
||||
- `describe("when X", ...)` grouping is good when several tests share a
|
||||
precondition; otherwise put the condition in the `it` name.
|
||||
|
||||
**`it`**
|
||||
|
||||
- Present tense, third person, no "should".
|
||||
`it("returns null for empty input")`, not `it("should return null…")` —
|
||||
"should" is eight wasted characters on every line and adds nothing.
|
||||
- State behaviour + condition, not implementation:
|
||||
`it("retries the request twice before failing")`, not
|
||||
`it("calls fetchWithRetry with maxRetries=2")`. The test name should
|
||||
survive a refactor that preserves behaviour.
|
||||
- Be specific enough that a failure is informative. `it("works")`,
|
||||
`it("handles errors")`, `it("renders correctly")` are useless — which
|
||||
error, what is correct? `it("renders the fallback avatar when the image
|
||||
404s")` tells you what broke.
|
||||
- Edge cases worth naming explicitly: empty input, boundary values, error
|
||||
paths. A suite skeleton often reads: happy path first, then
|
||||
`it("returns [] when the list is empty")`,
|
||||
`it("throws TypeError for non-ISO strings")`, etc.
|
||||
|
||||
For components, name from the user's perspective where possible:
|
||||
`it("shows the discount badge when price is reduced")` rather than
|
||||
`it("sets showBadge to true")`.
|
||||
|
||||
Parameterized tests: use the format placeholders so each case gets a
|
||||
distinct name — `it.for(cases)("parses %s as %i", ...)` — otherwise
|
||||
failures all report the same string.
|
||||
|
||||
### Parameterized tests
|
||||
|
||||
**Use `it.for` wherever cases differ only in inputs and expected
|
||||
outputs — pure data tables.** The moment cases differ in behaviour —
|
||||
different mock wiring, different setup, different assertions — they are
|
||||
separate `it`s inside the method's `describe` (see the next section),
|
||||
never rows sharing one callback: divergent cases in a shared callback
|
||||
breed conditionals, and neither a breakpoint nor a reporter line can
|
||||
target a single row.
|
||||
|
||||
For data tables, writing a separate test per case gets repetitive;
|
||||
`it.for` defines the cases as data and runs the same test logic for all
|
||||
of them:
|
||||
|
||||
```ts
|
||||
it.for([
|
||||
[1, 1, 2],
|
||||
[1, 2, 3],
|
||||
[2, 1, 3],
|
||||
])("add(%i, %i) -> %i", ([a, b, expected], { expect }) => {
|
||||
expect(a + b).toBe(expected)
|
||||
})
|
||||
```
|
||||
|
||||
The placeholders `%i`, `%s`, and `%f` in the test name are replaced with
|
||||
the corresponding values from each row, so the output shows
|
||||
`add(1, 1) -> 2`, `add(1, 2) -> 3`, and so on.
|
||||
|
||||
When cases have more than two or three values, objects are more readable.
|
||||
Use `$property` in the name to interpolate fields:
|
||||
|
||||
```ts
|
||||
it.for([
|
||||
{ input: "", expected: [] },
|
||||
{ input: "a", expected: ["a"] },
|
||||
{ input: "a,b", expected: ["a", "b"] },
|
||||
])("splits $input into $expected", ({ input, expected }, { expect }) => {
|
||||
expect(split(input)).toEqual(expected)
|
||||
})
|
||||
```
|
||||
|
||||
- When placeholder interpolation cannot form a grammatical sentence, give
|
||||
the case an explicit `name` field and use it as the whole title:
|
||||
`it.for(cases)("$name", …)`.
|
||||
- Case objects use the same field vocabulary everywhere: `name`, `input`,
|
||||
`expected`.
|
||||
- Hoist bulky case data into named builders above the table instead of
|
||||
inlining large literals. Store mutable case values as thunks
|
||||
(`makeDoc: () => …`) invoked inside the test body, so a case object
|
||||
can never leak state between tests.
|
||||
- Always `it.for`, never `it.each`: `.for` passes the test context as
|
||||
the second callback argument — which the concurrency rule below
|
||||
requires for `expect` — while `.each` spreads the case and provides no
|
||||
context.
|
||||
|
||||
### Mocks & path coverage
|
||||
|
||||
- **Every exported function is covered by unit tests** — a module's
|
||||
exports are its testable contract, and none of them ships untested.
|
||||
Modules with no exports whose evaluation wires things up (entry
|
||||
points like `electron/main.ts` and `electron/preload.ts`) are tested
|
||||
through their module side effects: mock the boundaries, import the
|
||||
module, assert the wiring.
|
||||
- **Every path gets a test** — the success path and each failure path,
|
||||
including one per collaborator that can fail, especially in unit tests.
|
||||
A `describe` per method groups them; the linear `it`s are the rows,
|
||||
named after the observable behaviour
|
||||
(`it("propagates the error from db.createItem")`).
|
||||
- A branch that genuinely cannot be reproduced in tests carries a
|
||||
`// NOCOV: <lowercase reason>.` comment as the first line inside the
|
||||
branch body. Environment-bound code is the typical legitimate case:
|
||||
`__DESKTOP_BUILD__` splits, SSR guards, browser-quirk workarounds.
|
||||
- NOCOV is a reviewer covenant, not a tool directive: it is deliberately
|
||||
not wired to the coverage provider (whose ignore hints require the
|
||||
banned `/* */` comment form), so coverage reports stay truthful and
|
||||
the marker explains the residual gap in place. It never excuses a
|
||||
testable path.
|
||||
- Per-test mock behaviour is configured inline with `vi.fn()`
|
||||
(`mockResolvedValue`, `mockRejectedValue`, `mockImplementation`).
|
||||
`vi.mock` module mocks are hoisted and file-level — they cannot vary
|
||||
per test, so anything that must differ between tests is injected, not
|
||||
module-mocked.
|
||||
- Repeated stub shapes become local factory closures
|
||||
(`const stubDb = (err?: Error) => …`) defined next to the tests that
|
||||
use them; dependencies shared across many tests become `test.extend`
|
||||
fixtures.
|
||||
- Helpers repeated across a directory's test files live in a colocated
|
||||
`test-helpers.ts` module (e.g.
|
||||
[app/composables/api/test-helpers.ts](app/composables/api/test-helpers.ts)).
|
||||
It must never be re-exported for app use (in `composables/`, keep it
|
||||
out of `index.ts` — nested files only enter the auto-imports through
|
||||
that re-export), and the `app/**/test-helpers.ts` coverage exclude in
|
||||
[vitest.config.ts](vitest.config.ts) keeps it out of the coverage
|
||||
denominator. Domain-specific fixtures and factories stay file-local.
|
||||
- **One act per test**: arrange once, invoke the unit once, then assert
|
||||
as many facets of that single outcome as needed — result, error, and
|
||||
interactions all belong in the same test when they describe one
|
||||
scenario. Needing a second invocation of the unit is the real "and"
|
||||
smell — split the test, not the assertions.
|
||||
- **Every injected dependency is accounted for in every test**: assert
|
||||
the calls that must have happened (count and arguments) and the zero
|
||||
counts of the ones that must not —
|
||||
`expect(db.createItem).toHaveBeenCalledTimes(0)` after a failed
|
||||
precondition is as load-bearing as any positive assertion. This holds
|
||||
even when the return value already proves the outcome: a call is a
|
||||
potential side effect, and output assertions cannot reveal a stray one.
|
||||
- Interaction assertions at injected boundaries are behaviour, not
|
||||
implementation: they pin the unit's contract — which effects occur,
|
||||
with what, and when — and survive any behaviour-preserving refactor of
|
||||
the unit's internals.
|
||||
- **Only e2e is allowed a real backend**: unit and component tests never
|
||||
perform real IO — network, filesystem, IPC — every such boundary is
|
||||
mocked (`registerEndpoint`, injected stubs).
|
||||
- Test through the module's public exports. Never export something
|
||||
solely for tests — an internal complex enough to need direct tests is
|
||||
extracted into its own module.
|
||||
- Plain `expect` for guards and preconditions; `expect.soft` is
|
||||
permitted in the final outcome-accounting block, where it reports the
|
||||
whole broken accounting at once instead of stopping at the first
|
||||
failed count.
|
||||
|
||||
### Component tests
|
||||
|
||||
Component tests mount the real component inside the nuxt runtime with
|
||||
`mountSuspended` and drive it the way a user does. Shared helpers live in
|
||||
[app/components/test-helpers.ts](app/components/test-helpers.ts) — the
|
||||
things that come up in nearly every suite:
|
||||
|
||||
- **Never hardcode a translation.** User-facing text is asserted through
|
||||
`t()` from
|
||||
[app/components/test-helpers.ts](app/components/test-helpers.ts), which
|
||||
resolves the key against the same i18n instance the component renders
|
||||
with — `expect(wrapper.text()).toContain(t("sidebar.sections.top.inbox"))`,
|
||||
never `toContain("Inbox")`. A copied english string is a second,
|
||||
unmaintained definition of the message: it keeps passing when the copy
|
||||
changes and, in the other direction, a message edit silently breaks a
|
||||
suite that has nothing to do with it. This covers every place the text
|
||||
appears — assertions, the text a lookup helper searches for
|
||||
(`findButtonByText(wrapper, t("..."))`), and props the test feeds in.
|
||||
Rules that follow from it:
|
||||
- **Interpolated messages take their values through `t()` too**, the
|
||||
same ones the component passes:
|
||||
`t("sidebar.search.no-results", { query: "run" })` — never a hand-spliced
|
||||
`"No results found for " + query`, and never a bare prefix like
|
||||
`toContain("No results found for")` that skips the interpolation
|
||||
entirely.
|
||||
- **Pick the key the component actually renders.** Many messages share
|
||||
an english value (six different `cancel-button` keys all read
|
||||
"Cancel"), so the wrong key still passes today and only diverges when
|
||||
one of them is reworded. Follow the component to its key rather than
|
||||
grepping the locale file for the text.
|
||||
- **`t()` needs the nuxt app context**, so it only runs inside a test
|
||||
body or a hook — never at module scope. Text shared across a suite
|
||||
goes in a helper function, not a top-level `const`.
|
||||
- **Context providers.** `useSidebar`, `useSidebarWidth` and reka-ui's
|
||||
tooltip context are installed once at page level in the app, so a
|
||||
component that reads one cannot mount on its own. Mount it through
|
||||
`mountUnderSidebarProvider` / `mountUnderTooltipProvider`.
|
||||
- **Overlays live in `<body>`.** Tooltip, dropdown, dialog and sheet
|
||||
bodies are teleported out of the wrapper, so `wrapper.text()` never
|
||||
sees them — reach them with `menuItem()`, `teleportedButton()` and
|
||||
`openTooltipText()`. Unmounting does not always take a teleported node
|
||||
with it (a pending presence transition outlives it) and reka-ui reuses
|
||||
its generated ids across mounts, so suites that drive overlays call
|
||||
`clearTeleportedOverlays()` from a `beforeEach`. Sheet-based suites
|
||||
must **not**: ripping out a node vue still has mounted breaks its next
|
||||
patch.
|
||||
- **No real IO, again.** The global route middleware asks better-auth for
|
||||
the session on every navigation, which means every mount — so
|
||||
[vitest.nuxt-setup.ts](vitest.nuxt-setup.ts) stubs a signed-out session
|
||||
for the whole nuxt project (unstubbed it is a real request that costs
|
||||
~5s per mount). `@nuxt/icon` is pinned to `provider: "none"` in the
|
||||
same config for the same reason. Suites that need a signed-in state
|
||||
seed the cache with `seedAuthSession` / `seedAuthOrganization` /
|
||||
`seedAuthAccounts`, or register endpoints.
|
||||
- **An endpoint is registered under the url the client actually asks
|
||||
for**, which is not the same string everywhere: `$coreAPIClient` has an
|
||||
empty base in tests, so it matches a bare path (`/api/documents/tree`),
|
||||
while `$authRealtimeAPIClient` has an absolute one and matches the full
|
||||
url. better-auth needs *both* spellings — the absolute one keeps the
|
||||
request off the real network, the path one is what the test-time h3 app
|
||||
matches a handler on — so it goes through `mockAuthEndpoint()`.
|
||||
- **A failing endpoint answers with an h3 error**, never a bare `throw
|
||||
new Error(...)`. h3 treats anything that is not an `H3Error` as
|
||||
unhandled and dumps it with its full stack to stderr — for an outcome
|
||||
the test is asserting on. `throw createError({ statusCode: 500 })`
|
||||
fails the request the same way and stays quiet.
|
||||
- **Viewport-dependent layouts.** happy-dom has no `matchMedia`, so
|
||||
`useMediaQuery` reports "not matching" for every query — pick a side
|
||||
deliberately with `stubViewportMatches()`.
|
||||
- **Pointer and geometry behaviour** (the sidebar drag) is driven the way
|
||||
[app/composables/useSidebarDraggable.nuxt.test.ts](app/composables/useSidebarDraggable.nuxt.test.ts)
|
||||
does it: `mockNuxtImport` the vueuse primitives (`useDraggable`,
|
||||
`useMouseInElement`, `useElementBounding`), then drive the captured
|
||||
callbacks and refs. Everything between them and the component stays
|
||||
real. Return **refs**, not plain objects — a plain `{ value }` leaves
|
||||
every watcher and computed behind it inert, and the assertions then
|
||||
pass or fail on flush timing rather than on behaviour.
|
||||
- **Settling.** A pinia-colada mutation that invalidates queries on
|
||||
success resolves several macrotasks deep; use `settleMutations()`, or
|
||||
`settleActionSubmit()` for the settings action modals (which also hold
|
||||
their submit for `delay(300)`, so they mount through
|
||||
`mountWithFrozenClock()`). better-auth backs off internally before
|
||||
surfacing a failed request, and vee-validate renders messages on its
|
||||
own scheduler — neither exposes a signal, so those assertions are the
|
||||
documented `vi.waitFor` exception.
|
||||
- **Shared app state forces `{ concurrent: false }`.** Beyond `vi.mock`
|
||||
singletons, the same applies to nuxt `useState`, pinia stores,
|
||||
cookie-backed `usePersistentState`, `vi.stubGlobal`, fake timers and
|
||||
the teleport target — all of them are shared by every mount in a file.
|
||||
Mark **each** describe that touches them, nested ones included: the
|
||||
option only covers the suite it is on.
|
||||
- **A component that keeps watching after its test** — one holding a
|
||||
colour-mode watcher, a branch sync, or a teleported overlay vue still
|
||||
patches — is taken down with `enableAutoUnmount(afterEach)` rather than
|
||||
left mounted. A leftover instance answers the next test too, and
|
||||
`clearTeleportedOverlays()` cannot be used on it: ripping out a node
|
||||
vue still has mounted breaks its next patch.
|
||||
|
||||
### Editor component tests
|
||||
|
||||
The editor suites reuse a few things the rest of the components do not:
|
||||
|
||||
- **Node views** ([app/components/editor/test-helpers/node-view.ts](app/components/editor/test-helpers/node-view.ts))
|
||||
mount on their own through `mountNodeView()`, which supplies every
|
||||
`nodeViewProps` entry plus the `onDragStart` / `decorationClasses`
|
||||
injections tiptap's renderer would provide. `makeNode()` and
|
||||
`makeEditor()` stand in for the node and the editor; the command chain
|
||||
a node view builds is recorded, so a suite asserts *which* commands ran
|
||||
and with what rather than reaching into prosemirror.
|
||||
- **Theme colours** are resolved by painting CSS variables onto a canvas,
|
||||
which happy-dom has no 2d context for — anything rendering a chart, a
|
||||
metric block or a caret needs `stubThemeColorContext()` from
|
||||
[app/components/editor/test-helpers/theme.ts](app/components/editor/test-helpers/theme.ts)
|
||||
first.
|
||||
- **Charts** are asserted through the option object handed to echarts
|
||||
(`chartOption()`), with `vue-echarts` replaced by a stub — echarts
|
||||
itself needs a canvas.
|
||||
- **Virtualized lists** (`vue-virtual-scroller`) measure row heights,
|
||||
which happy-dom reports as zero, so nothing is ever drawn: suites that
|
||||
need the rows mock the module with a pass-through component.
|
||||
- **`editor.commands` is rebuilt on every access**, so a spy on one of
|
||||
them is never the object the component calls. Shadow the getter with a
|
||||
recording proxy when a suite needs to see which commands ran.
|
||||
- **A live editor** is built with the real extensions when the behaviour
|
||||
under test reads the document (link marks, comment marks, the merged
|
||||
diff). A collaboration-backed component also needs a provider
|
||||
stand-in carrying `document`, `awareness` and `on`/`off`.
|
||||
|
||||
### Snapshots
|
||||
|
||||
Snapshots are golden files, not lazy assertion dumps: use them only for
|
||||
golden-style serialized outputs (ProseMirror JSON, diff structures) —
|
||||
`toMatchInlineSnapshot` for small values, file snapshots for large ones,
|
||||
explicit assertions everywhere else. Under concurrency they require the
|
||||
context-local `expect` (see below).
|
||||
|
||||
### Independence & concurrency
|
||||
|
||||
- **Every test is completely independent**: it creates its own fresh
|
||||
state (a new instance, store, or mount per test) and never depends on
|
||||
execution order or on state left behind by another test. Each
|
||||
`describe` block focuses on one method; each test verifies one specific
|
||||
behaviour — the suite reads like a specification of the unit, and a
|
||||
failure's name plus assertion tell you exactly what broke without
|
||||
opening the file.
|
||||
- Repeating the same setup in every test is a candidate for `beforeEach`
|
||||
or a `test.extend` fixture — never for module-level shared mutable
|
||||
state.
|
||||
- **All tests run concurrently**: the vitest config sets
|
||||
`sequence.concurrent: true`. Test independence is what makes this safe;
|
||||
a test that needs sequential execution is a smell, not a config
|
||||
exception. The one principled exception: suites asserting call
|
||||
accounting on shared module-level mocks (`vi.mock` singletons) run
|
||||
under `describe("…", { concurrent: false }, …)` with a comment —
|
||||
module mocks are per-file singletons and cannot be isolated across
|
||||
interleaving tests. Prefer per-test injected mocks wherever the design
|
||||
allows; they stay concurrency-safe.
|
||||
- Under concurrency, snapshots and assertions must use the `expect` from
|
||||
the local test context (`it("…", ({ expect }) => …)`) — the global
|
||||
`expect` cannot reliably attribute them to the right test when tests
|
||||
interleave. See
|
||||
[test.concurrent](https://vitest.dev/api/test#test-concurrent).
|
||||
- Mock and global state cannot leak between tests by construction: the
|
||||
vitest config sets `restoreMocks: true`, `unstubGlobals: true`, and
|
||||
`unstubEnvs: true`, so `vi.spyOn` spies, stubbed globals, and env stubs
|
||||
are restored after every test without per-file `afterEach` cleanup.
|
||||
Caveat: since vitest 4, `restoreMocks` does **not** touch hand-made
|
||||
`vi.fn()` singletons — suites holding them (module-mock trees) reset
|
||||
them explicitly in a `beforeEach`.
|
||||
|
||||
### Determinism
|
||||
|
||||
- Never sleepy: no `setTimeout`-based waiting in tests. Await concrete
|
||||
signals — `nextTick()`, `flushPromises()`, an emitted event, a promise
|
||||
returned by the unit itself.
|
||||
- Time is driven, not waited on: `vi.useFakeTimers()` +
|
||||
`advanceTimersByTime()` for timer-dependent code, `vi.setSystemTime()`
|
||||
for deterministic timestamps — assert exact dates, never wall-clock
|
||||
deltas.
|
||||
- `vi.waitFor` is the last resort for genuinely nondeterministic
|
||||
scheduling and always carries a comment justifying why no concrete
|
||||
signal exists.
|
||||
|
||||
### Prior art
|
||||
|
||||
Co-location is established practice across the ecosystem, including
|
||||
dependencies of this app:
|
||||
[Reka UI](https://github.com/unovue/reka-ui/tree/v2/packages/core/src/Dialog)
|
||||
(`Dialog.test.ts` among the Dialog `.vue` components),
|
||||
[Pinia Colada](https://github.com/posva/pinia-colada/tree/main/src)
|
||||
(co-located specs plus `.test-d.ts` type tests),
|
||||
[VueUse](https://github.com/vueuse/vueuse/tree/main/packages/core/useMouse),
|
||||
[PrimeVue](https://github.com/primefaces/primevue/tree/master/packages/primevue/src/button)
|
||||
(`Button.spec.js` beside `Button.vue`),
|
||||
[SvelteKit](https://github.com/sveltejs/kit/tree/main/packages/kit/src/utils),
|
||||
[immich](https://github.com/immich-app/immich) (co-located specs + a
|
||||
dedicated `e2e/` package), and — on nuxt 4's `app/` layout specifically —
|
||||
[kun-galgame-forum](https://github.com/KunMoe/kun-galgame-forum/tree/master/apps/web/app/components/editkit)
|
||||
(specs beside components inside `app/`).
|
||||
|
||||
## Code style
|
||||
|
||||
### Assignments
|
||||
|
||||
**One assignment per statement.** Never chain them, and never assign from inside
|
||||
an expression — enforced by `no-multi-assign`.
|
||||
|
||||
```ts
|
||||
// no: the line both mutates the map and binds a local
|
||||
const group = (groups[key] ??= { items: [] })
|
||||
|
||||
// yes
|
||||
let group = groups[key]
|
||||
if (!group) {
|
||||
group = { items: [] }
|
||||
groups[key] = group
|
||||
}
|
||||
```
|
||||
|
||||
`??=` itself is fine and used across the codebase; keep it as its own
|
||||
statement (`elem.children ??= []`).
|
||||
|
||||
### Vue `<script setup>` ordering
|
||||
|
||||
Within a Vue setup script, order the contents as follows:
|
||||
|
||||
1. Imports
|
||||
2. `defineProps`, `defineEmits`, `defineExpose`
|
||||
3. Store and composable initializations (`useFoo()`, `useStore()`, etc.)
|
||||
4. `ref` and `computed` declarations
|
||||
5. `onMounted` / `onUnmounted` (and other lifecycle hooks)
|
||||
6. Watchers (`watch`, `watchEffect`, `watchImmediate`)
|
||||
7. Functions
|
||||
|
||||
### Styling
|
||||
|
||||
Use **Tailwind utility classes** by default. When a utility expression can't express what you need, add the custom rule to [app/assets/css/main.css](app/assets/css/main.css) and apply it via a class name — do not use `<style>` blocks in components or inline `style=` attributes for non-dynamic values.
|
||||
|
||||
## The PromQL grammar
|
||||
|
||||
PromQL parsing goes through [@oxynote/lezer-promql](https://github.com/oxynote/lezer-promql), a fork of Prometheus's grammar that adds Grafana-style dynamic duration placeholders (`$__interval`, etc.). It is a published package, not a workspace one, and reaches the app through a pnpm override in [pnpm-workspace.yaml](pnpm-workspace.yaml) rather than a bundler alias — `@prometheus-io/codemirror-promql` imports the grammar itself, so the fork has to win that resolution too. Changing the grammar means releasing it from its own repository; a new version needs its version bumped in the override and added to `minimumReleaseAgeExclude`.
|
||||
|
||||
## Electron packaging notes
|
||||
|
||||
`forge.config.ts` allowlists only `/.vite` (compiled main+preload) and `/.output/public` (renderer SPA) into the package — anything else is excluded. The `oxynote://` protocol is registered at install time for OAuth deep-links. Production Fuses disable `RunAsNode`, `EnableNodeOptionsEnvironmentVariable`, and require ASAR integrity. Main and preload are forced to `.cjs` output (see [vite.electron.config.ts](vite.electron.config.ts)) because root `package.json` is `"type": "module"`.
|
||||
|
||||
`__API_BASE_URL__` and `__APP_BASE_URL__` are **baked in at build time** for the Electron bundle — runtime env cannot override them. The build fails if those env vars are missing.
|
||||
@AGENTS.md
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user