Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,542 @@
# Implementation Plan: Docker Compose Infrastructure
**Goal:** Create the `docker/` workspace at the repo root with three Docker Compose files, a Caddyfile, `.env.example`, a `package.json`, and a `README.md`. This is pure Docker/YAML infrastructure — no TypeScript.
**Architecture:** Three compose stacks share the same six services (SurrealDB, Redis, PostgreSQL, Mailpit, Typesense, Garage S3) but serve different purposes: `dev` runs persistent volumes for local development; `test` runs ephemeral containers for CI integration tests; `testbed` extends the services with the showcase application and a Caddy reverse proxy for staging.
**Tech Stack:** Docker Compose v2, Caddy 2
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan.
---
## Chunk 1: Package setup + dev stack
- [ ] **Create the `docker/` directory at repo root**
Path: `/mnt/s/web/genesis/docker/`
- [ ] **Create `docker/package.json`**
Exact content:
```json
{
"name": "@suaveplan/docker",
"private": true,
"scripts": {
"dev:up": "docker compose -f docker-compose.dev.yml up -d",
"dev:down": "docker compose -f docker-compose.dev.yml down",
"test:up": "docker compose -f docker-compose.test.yml up -d",
"test:down": "docker compose -f docker-compose.test.yml down",
"testbed:up": "docker compose -f docker-compose.testbed.yml up --build -d",
"testbed:down": "docker compose -f docker-compose.testbed.yml down"
}
}
```
- [ ] **Create `docker/docker-compose.dev.yml`**
Full content — all six services with named volumes and healthchecks on SurrealDB and PostgreSQL:
```yaml
services:
surrealdb:
image: surrealdb/surrealdb:v3.0.4
command: start --auth --user root --pass root
ports:
- "8001:8000"
volumes:
- surrealdb-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
ports:
- "8002:6379"
volumes:
- redis-data:/data
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- "8003:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 5s
timeout: 3s
retries: 5
mailpit:
image: axllent/mailpit:latest
ports:
- "8004:1025"
- "8005:8025"
typesense:
image: typesense/typesense:27.1
environment:
TYPESENSE_DATA_DIR: /data
TYPESENSE_API_KEY: test
ports:
- "8006:8108"
volumes:
- typesense-data:/data
garage-s3:
image: ghcr.io/deuxfleurs/garage:v2.0.0
ports:
- "8900:3900"
- "8902:3902"
volumes:
- garage-data:/data
environment:
GARAGE_ALLOW_WORLD_READABLE_CONFIG: "true"
volumes:
surrealdb-data:
redis-data:
postgres-data:
typesense-data:
garage-data:
```
- [ ] **Create `docker/.env.example`**
Exact content:
```bash
# Set these to use your already-running local services instead of spinning containers.
# A fixture only enters passthrough mode when ALL its required vars are set and non-empty.
SURREALDB_URL=ws://localhost:8001
SURREALDB_USERNAME=root
SURREALDB_PASSWORD=root
REDIS_URL=redis://localhost:8002
POSTGRES_URL=postgresql://postgres:postgres@localhost:8003/postgres
MAILPIT_SMTP_URL=smtp://localhost:8004
MAILPIT_API_URL=http://localhost:8005
TYPESENSE_URL=http://localhost:8006
TYPESENSE_API_KEY=test
GARAGE_S3_ENDPOINT=http://localhost:8900
GARAGE_S3_ADMIN_ENDPOINT=http://localhost:8902
GARAGE_S3_ACCESS_KEY_ID=
GARAGE_S3_SECRET_ACCESS_KEY=
```
- [ ] **Verify dev compose YAML is valid**
```bash
docker compose -f /mnt/s/web/genesis/docker/docker-compose.dev.yml config
```
Expected: compose prints the resolved YAML with no errors.
- [ ] **Smoke-test the dev stack starts cleanly**
```bash
cd /mnt/s/web/genesis/docker && \
docker compose -f docker-compose.dev.yml up -d && \
docker compose -f docker-compose.dev.yml ps
```
Expected: all six services listed with `Status` = `running` (or `healthy` once healthchecks pass). Tear down immediately after verification:
```bash
docker compose -f /mnt/s/web/genesis/docker/docker-compose.dev.yml down
```
---
## Chunk 2: Test stack + testbed stack + docs
- [ ] **Create `docker/docker-compose.test.yml`**
Identical structure to `dev.yml` with two mechanical differences:
1. Port range substituted to `7004–7011`.
2. All `volumes:` keys removed from every service definition.
3. The top-level `volumes:` section removed entirely.
Full content:
```yaml
services:
surrealdb:
image: surrealdb/surrealdb:v3.0.4
command: start --auth --user root --pass root
ports:
- "7004:8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
ports:
- "7005:6379"
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- "7006:5432"
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 5s
timeout: 3s
retries: 5
mailpit:
image: axllent/mailpit:latest
ports:
- "7007:1025"
- "7008:8025"
typesense:
image: typesense/typesense:27.1
environment:
TYPESENSE_DATA_DIR: /data
TYPESENSE_API_KEY: test
ports:
- "7009:8108"
garage-s3:
image: ghcr.io/deuxfleurs/garage:v2.0.0
ports:
- "7010:3900"
- "7011:3902"
environment:
GARAGE_ALLOW_WORLD_READABLE_CONFIG: "true"
```
- [ ] **Create `docker/docker-compose.testbed.yml`**
Full content — adds `testbed-net` internal network, the testbed app container (built from `../apps/testbed/Dockerfile`), and Caddy on ports 3200/3201:
```yaml
networks:
testbed-net:
services:
surrealdb:
image: surrealdb/surrealdb:v3.0.4
command: start --auth --user root --pass root
networks:
- testbed-net
redis:
image: redis:7-alpine
networks:
- testbed-net
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
networks:
- testbed-net
mailpit:
image: axllent/mailpit:latest
networks:
- testbed-net
typesense:
image: typesense/typesense:27.1
environment:
TYPESENSE_DATA_DIR: /data
TYPESENSE_API_KEY: test
networks:
- testbed-net
garage-s3:
image: ghcr.io/deuxfleurs/garage:v2.0.0
environment:
GARAGE_ALLOW_WORLD_READABLE_CONFIG: "true"
networks:
- testbed-net
testbed:
build:
context: ../apps/testbed
dockerfile: Dockerfile
environment:
SURREALDB_URL: ws://surrealdb:8000
GARAGE_S3_ENDPOINT: http://garage-s3:3900
GARAGE_S3_ADMIN_ENDPOINT: http://garage-s3:3902
REDIS_URL: redis://redis:6379
POSTGRES_URL: postgresql://postgres:postgres@postgres:5432/postgres
TYPESENSE_URL: http://typesense:8108
TYPESENSE_API_KEY: test
MAILPIT_SMTP_URL: smtp://mailpit:1025
MAILPIT_API_URL: http://mailpit:8025
networks:
- testbed-net
depends_on:
- surrealdb
- redis
- postgres
- garage-s3
- typesense
- mailpit
caddy:
image: caddy:2-alpine
ports:
- "3200:80"
- "3201:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
networks:
- testbed-net
```
- [ ] **Create `docker/Caddyfile`**
Exact content:
```caddyfile
:80 {
reverse_proxy testbed:3000
}
```
- [ ] **Create `docker/README.md`**
Full content:
````markdown
# @suaveplan/docker
Docker Compose infrastructure for the genesis monorepo. Three stacks cover local development, CI integration testing, and full staging with the testbed showcase app.
## Stacks
| File | Purpose | Ports |
|---|---|---|
| `docker-compose.dev.yml` | Persistent dev services | 8001–8006, 8900, 8902 |
| `docker-compose.test.yml` | Ephemeral CI services (no volumes) | 7004–7011 |
| `docker-compose.testbed.yml` | Full staging with testbed app + Caddy | 3200–3201 (+ internal) |
## Starting the dev stack
```bash
bun run --filter=@suaveplan/docker dev:up
```
Stops it:
```bash
bun run --filter=@suaveplan/docker dev:down
```
## Port table
### Dev stack (`docker-compose.dev.yml`) — persistent volumes
| Host port | Container port | Service |
|---|---|---|
| 8001 | 8000 | SurrealDB WebSocket + HTTP |
| 8002 | 6379 | Redis |
| 8003 | 5432 | PostgreSQL |
| 8004 | 1025 | Mailpit SMTP |
| 8005 | 8025 | Mailpit Web UI |
| 8006 | 8108 | Typesense |
| 8900 | 3900 | Garage S3 API |
| 8902 | 3902 | Garage S3 Admin |
### Test stack (`docker-compose.test.yml`) — ephemeral, no volumes
| Host port | Container port | Service |
|---|---|---|
| 7004 | 8000 | SurrealDB |
| 7005 | 6379 | Redis |
| 7006 | 5432 | PostgreSQL |
| 7007 | 1025 | Mailpit SMTP |
| 7008 | 8025 | Mailpit Web UI |
| 7009 | 8108 | Typesense |
| 7010 | 3900 | Garage S3 API |
| 7011 | 3902 | Garage S3 Admin |
### Testbed staging (`docker-compose.testbed.yml`)
| Host port | Service |
|---|---|
| 3200 | apps/testbed HTTP (via Caddy) |
| 3201 | apps/testbed HTTPS (via Caddy) |
All other services in the testbed stack communicate on the internal `testbed-net` Docker network by service name — no host ports are exposed for those services.
## Verifying services are healthy
After `dev:up`, check status:
```bash
docker compose -f docker/docker-compose.dev.yml ps
```
SurrealDB and PostgreSQL have healthchecks. Wait for their `Status` column to show `healthy`:
```bash
# SurrealDB
curl -f http://localhost:8001/health
# PostgreSQL
docker compose -f docker/docker-compose.dev.yml exec postgres pg_isready -U postgres
# Mailpit Web UI
curl -f http://localhost:8005/api/v1/messages
# Typesense
curl -f http://localhost:8006/health
# Garage S3 Admin
curl -f http://localhost:8902/health
```
## Env-bridge pattern (testcontainers integration)
When `@suaveplan/testing-integration` fixture packages start a container, they first check for environment variable overrides. If **all** required vars for a fixture are set and non-empty, the fixture enters passthrough mode: it constructs config directly from the env vars, skips container startup, and skips the healthcheck (the service is assumed healthy by virtue of the env vars being explicitly set).
Copy `.env.example` to `.env` at the repo root and fill in the values pointing at the dev stack:
```bash
cp docker/.env.example .env
```
The default values in `.env.example` are pre-wired to the dev stack ports. Once the dev stack is running and `.env` is populated, integration test runs will reuse your live containers rather than spinning new ones — saving 30–60 seconds per test suite.
A partial set of vars for a fixture (e.g. `GARAGE_S3_ENDPOINT` set but `GARAGE_S3_ACCESS_KEY_ID` missing) causes the fixture to fall through to container startup. All required vars must be set for passthrough mode to activate.
### Env-bridge variable reference
| Fixture | Required vars |
|---|---|
| `surrealdb` | `SURREALDB_URL`, `SURREALDB_USERNAME`, `SURREALDB_PASSWORD` |
| `redis` | `REDIS_URL` |
| `postgres` | `POSTGRES_URL` |
| `mailpit` | `MAILPIT_SMTP_URL`, `MAILPIT_API_URL` |
| `typesense` | `TYPESENSE_URL`, `TYPESENSE_API_KEY` |
| `garage-s3` | `GARAGE_S3_ENDPOINT`, `GARAGE_S3_ADMIN_ENDPOINT`, `GARAGE_S3_ACCESS_KEY_ID`, `GARAGE_S3_SECRET_ACCESS_KEY` |
Note: Garage S3 requires `ACCESS_KEY_ID` and `SECRET_ACCESS_KEY` which are generated when the container first initialises. The dev stack container creates these on first run; retrieve them by running `garage key info test-key` inside the container, then persist them in `.env`.
## Running the testbed staging environment
The testbed stack builds `apps/testbed/` from its `Dockerfile` and serves it behind Caddy on port 3200.
Prerequisites: `apps/testbed/Dockerfile` must exist (created as part of sub-project 3).
Start:
```bash
bun run --filter=@suaveplan/docker testbed:up
```
Open `http://localhost:3200` in a browser. All six backing services are available to the testbed app by service name on the internal `testbed-net` Docker network.
Stop:
```bash
bun run --filter=@suaveplan/docker testbed:down
```
## Scripts
| Script | Command |
|---|---|
| `dev:up` | `docker compose -f docker-compose.dev.yml up -d` |
| `dev:down` | `docker compose -f docker-compose.dev.yml down` |
| `test:up` | `docker compose -f docker-compose.test.yml up -d` |
| `test:down` | `docker compose -f docker-compose.test.yml down` |
| `testbed:up` | `docker compose -f docker-compose.testbed.yml up --build -d` |
| `testbed:down` | `docker compose -f docker-compose.testbed.yml down` |
````
- [ ] **Verify test compose YAML is valid**
```bash
docker compose -f /mnt/s/web/genesis/docker/docker-compose.test.yml config
```
Expected: compose prints resolved YAML with no errors.
- [ ] **Verify testbed compose YAML is valid**
```bash
docker compose -f /mnt/s/web/genesis/docker/docker-compose.testbed.yml config
```
Expected: compose prints resolved YAML with no errors. (The `testbed` service build context will warn that `../apps/testbed` does not exist yet — this is expected at this stage and is not a failure. The YAML itself must be structurally valid.)
- [ ] **Add `@suaveplan/docker` to Turborepo workspace**
Open `/mnt/s/web/genesis/turbo.json` (or `turbo.json` at repo root). Confirm `docker/` is picked up by the workspace glob. Bun workspace glob is set in `/mnt/s/web/genesis/package.json` under `"workspaces"`. Add `"docker"` to the array if not already covered by a wildcard.
Check the root `package.json` workspaces field:
```bash
grep -A 10 '"workspaces"' /mnt/s/web/genesis/package.json
```
If the wildcard already covers `docker/` (e.g. `"*"` or `["apps/*", "universal/**", "docker"]`), no change needed. If it does not, add `"docker"` to the array.
- [ ] **Final verification: bun install resolves the workspace**
```bash
cd /mnt/s/web/genesis && bun install
```
Then confirm the package is linked:
```bash
ls /mnt/s/web/genesis/node_modules/@suaveplan/docker 2>/dev/null || echo "symlink not found"
```
Expected: symlink present pointing to `../../docker`.
---
## Summary of files created
| File | Description |
|---|---|
| `docker/package.json` | Workspace package descriptor with six compose scripts |
| `docker/docker-compose.dev.yml` | Dev stack — persistent volumes, ports 8001–8006, 8900, 8902 |
| `docker/docker-compose.test.yml` | Test stack — ephemeral, ports 7004–7011 |
| `docker/docker-compose.testbed.yml` | Staging stack — testbed app + Caddy, ports 3200–3201 |
| `docker/Caddyfile` | Caddy reverse proxy config for testbed staging |
| `docker/.env.example` | Env-bridge variable reference with dev stack defaults |
| `docker/README.md` | Full documentation: ports, commands, env-bridge, testbed |
## Notes for implementers
- The Typesense image tag is `27.1` (not `27.1.0` — this matches the exact Docker Hub tag).
- Garage S3 container port for S3 API is `3900`; admin API is `3902`. The dev stack maps these to host ports `8900` and `8902` respectively.
- The testbed compose file does not expose service ports to the host — all inter-service communication goes through `testbed-net`. Only Caddy (3200/3201) is host-accessible.
- The `GARAGE_ALLOW_WORLD_READABLE_CONFIG: "true"` environment variable is required for Garage to start without a mounted config file.
- SurrealDB's `healthcheck` targets `http://localhost:8000/health` (HTTP, same port as WebSocket). This is correct — SurrealDB v3 exposes an HTTP health endpoint on the same port.
- Mailpit does not have a healthcheck defined in the compose files — it starts reliably without one. Integration fixture healthchecks poll `<apiUrl>/api/v1/messages` directly.
- The `TYPESENSE_DATA_DIR` env var is required for Typesense to know where to store data. In the test stack (no volumes), this dir lives in the ephemeral container filesystem and is discarded on `down`.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,400 @@
# Design: `@suaveplan/readline` + `@suaveplan/cli`
**Date:** 2026-04-20
**Branch:** genesis
**Status:** Approved — ready for implementation planning
---
## 1. Problem
The `@suaveplan/cli` package needs to land on genesis at enterprise quality. Its `prompt` module currently depends on `node:readline`, which ties the package to the Node runtime and blocks the "universal" guarantee. No standalone readline primitive exists in the monorepo.
---
## 2. Solution
Introduce `@suaveplan/readline` as a new `universal/platform` package, then port `@suaveplan/cli` as a full enterprise rewrite that depends on it. Both land in dependency order within the same session.
---
## 3. Package Locations
| Package | Path | FOLDERS.yml domain |
|---|---|---|
| `@suaveplan/readline` | `universal/platform/readline` | `platform` |
| `@suaveplan/cli` | `universal/platform/cli` | `platform` |
`FOLDERS.yml` gains `readline` in the `universal/platform` list. `cli` already appears there — only `readline` is added, bringing the total to 7 entries:
```yaml
platform: [ cli, electrobun, macos-contacts, opentelemetry,
profiler, readline, sitemap ] # 7
```
---
## 4. Dependency Chain
```text
@opentelemetry/api zod tslib
↓
@suaveplan/error @suaveplan/types
↓
@suaveplan/readline
↓
@suaveplan/cli
```
---
## 5. `@suaveplan/readline`
### 5.1 Architecture
Three feature modules share one internal byte scanner:
```text
src/
scanner/ ← internal byte engine (not exported)
reader/ ← public: readLines() AsyncGenerator API
interface/ ← public: createInterface() interactive prompt API
errors/ ← public: ReadlineError + stable code registry
```
`"sideEffects": false` must be set in `package.json`.
No root `"."` barrel export. Consumers import directly from subpaths to enable tree-shaking.
### 5.2 `scanner` (internal)
Ports the [maleck13/readline](https://github.com/maleck13/readline) byte scanner from JavaScript to TypeScript. Replaces `EventEmitter` with an internal bounded async push queue (backpressure-safe). Handles LF, CR, and CRLF line endings. Not exported via `package.json` subpaths.
```ts
interface ScannerOptions {
maxLineLength?: number; // default 4096
encoding?: BufferEncoding;
}
function createScanner(input: Readable, opts?: ScannerOptions): AsyncGenerator<string>
```
### 5.3 `reader` — `@suaveplan/readline/reader`
```ts
interface ReadLinesOptions {
maxLineLength?: number;
encoding?: BufferEncoding;
skipEmpty?: boolean;
signal?: AbortSignal;
}
async function* readLines(
input: Readable | ReadableStream | string,
options?: ReadLinesOptions
): AsyncGenerator<string, void, unknown>
```
Accepts a Node `Readable`, a WHATWG `ReadableStream`, or a file path string. Honours `AbortSignal` at each yield point.
### 5.4 `interface` — `@suaveplan/readline/interface`
```ts
interface ReadlineOptions {
input: Readable;
output?: Writable;
signal?: AbortSignal;
}
interface ReadlineInterface {
question(prompt: string, signal?: AbortSignal): Promise<string>;
close(): void;
readonly closed: boolean;
[Symbol.asyncIterator](): AsyncIterator<string>;
}
function createInterface(options: ReadlineOptions): ReadlineInterface
```
The async iterator inherits the `AbortSignal` from `ReadlineOptions`. `ReadlineInterface` satisfies `@suaveplan/cli`'s existing `InputReader` type (`{ question, close }`) by structural typing — no adapter required.
`createInterface()` emits an OTel span covering the interface lifetime (start on creation, end on `close()`, error status on abort).
### 5.5 Error codes
```text
READLINE.SCANNER.LINE_TOO_LONG
READLINE.SCANNER.STREAM_ERROR
READLINE.INTERFACE.CLOSED
READLINE.INTERFACE.ABORTED
READLINE.READER.STREAM_ERROR
```
### 5.6 Subpath exports
```json
{
"./reader/reader": { "import": { "types": "...", "default": "..." } },
"./interface/interface": { "import": { "types": "...", "default": "..." } },
"./errors/errors": { "import": { "types": "...", "default": "..." } }
}
```
`scanner` is internal — no subpath export.
### 5.7 Key `package.json` fields
```json
{
"sideEffects": false,
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"zod": "catalog:forms",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"vitest": "catalog:testing"
}
}
```
---
## 6. `@suaveplan/cli`
### 6.1 Architecture
Full enterprise rewrite of `suaveplan/packages/universal/cli`. Preserves the complete public API surface. Adds structured errors, Zod schemas, OTel instrumentation, AbortSignal support, and replaces `node:readline` with `@suaveplan/readline/interface`.
```text
src/
app/ command/ parser/ style/
errors/ output/ spinner/ progress/ prompt/
```
`"sideEffects": false` must be set in `package.json`.
### 6.2 `errors`
```ts
class CliError extends SuaveplanError { ... }
```
Stable codes:
```text
CLI.PARSE.UNKNOWN_OPTION
CLI.PARSE.MISSING_VALUE
CLI.PARSE.INVALID_VALUE
CLI.PARSE.MISSING_ARGUMENT
CLI.PARSE.MISSING_REQUIRED_OPTION
CLI.COMMAND.NO_ACTION
CLI.COMMAND.UNKNOWN_SUBCOMMAND
CLI.APP.RUN_FAILED
CLI.APP.ABORTED
```
### 6.3 `parser`
Preserved API. Throws `CliError` (CLI.PARSE.*) instead of bare `Error`. Adds `ParserConfigSchema` and `ParsedArgsSchema` Zod exports.
```ts
function parseArgs(argv: string[], config?: ParserConfig): ParsedArgs
function parseOptionFlags(flags, description?, defaultValue?): { name, config }
function parseArgumentSpec(spec, description?): ArgumentConfig
```
### 6.4 `command`
Preserved fluent API. `execute()` gains an OTel span. Throws `CliError` on missing action and unknown subcommand.
```ts
class Command {
description / option / argument / action / command / alias / hidden / example
execute(argv: string[], parentName?: string): Promise<void>
helpText(parentName?: string): string
getParserConfig(): ParserConfig
}
```
### 6.5 `app`
Preserved API. `run()` gains an OTel span and an optional `AbortSignal`. Throws `CliError(CLI.APP.ABORTED)` when the signal fires.
```ts
class App {
run(argv?: string[], options?: { signal?: AbortSignal }): Promise<void>
helpText(): string
description / option / argument / command / action / example
}
function cli(name: string, version?: string): App
```
Zod export: `AppConfigSchema`.
### 6.6 `style`, `output/format`, `output/table`
API unchanged. Zod schemas added for option types. Bare `Error` throws replaced with `CliError`.
`output/format` and `output/table` follow the standard five-file co-located layout:
```text
src/output/
format/
format.ts format.types.ts format.test.ts format.md index.ts
table/
table.ts table.types.ts table.test.ts table.md index.ts
```
### 6.7 `spinner` + `progress`
`AbortSignal` added to options. Stream remains injectable (`WritableStreamLike`).
```ts
interface SpinnerOptions { text?, frames?, interval?, stream?, signal?: AbortSignal }
interface ProgressOptions { total, width?, stream?, signal?: AbortSignal }
```
### 6.8 `prompt`
`createDefaultReader()` uses `createInterface()` from `@suaveplan/readline/interface`. All five prompt functions (`text`, `password`, `confirm`, `select`, `multiselect`) gain an optional `signal?: AbortSignal`. `node:readline` is absent from the dependency graph.
### 6.9 Subpath exports
```json
{
".": "barrel",
"./app/app": "...",
"./command/command": "...",
"./parser/parser": "...",
"./style/style": "...",
"./output/format/format": "...",
"./output/table/table": "...",
"./spinner/spinner": "...",
"./progress/progress": "...",
"./prompt/prompt": "...",
"./errors/errors": "..."
}
```
### 6.10 Key `package.json` fields
```json
{
"sideEffects": false,
"dependencies": {
"@suaveplan/readline": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"zod": "catalog:forms",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"vitest": "catalog:testing"
}
}
```
---
## 7. Testing Strategy
### Unit tests (co-located `.test.ts`)
| Module | Key scenarios |
|---|---|
| `scanner` | LF / CR / CRLF, max-line overflow, empty input, mid-stream abort |
| `reader` | file path, WHATWG `ReadableStream`, `AbortSignal` mid-iteration |
| `interface` | real `Readable`/`Writable` pair, question/close lifecycle, `closed` guard, async iterator abort via inherited signal, OTel span created / ended on close / error status on abort |
| `parser` | all option types, `--no-*`, `--`, variadic, unknown options |
| `command` | subcommand routing, aliases, help text, error paths |
| `spinner` / `progress` | injectable stream (no TTY), AbortSignal cancellation |
| `prompt` | injected mock `InputReader`, validation retries, all five prompt types |
### Property-based tests (fast-check)
- **scanner/parser round-trip** — split any string into lines, scan bytes, reassemble → original string
- **parser safety** — `parseArgs(argv, { allowUnknown: true })` never throws for any `argv`
- **style round-trip** — `stripAnsi(style.bold.red(s)) === s` for all strings `s`
- **table invariant** — column widths always ≥ max cell content width
### Coverage gate
100% statement + branch + function + line (istanbul) for both packages.
---
## 8. Benchmarks
### `@suaveplan/readline` (`src/__benchmarks__/`)
| Benchmark | Description |
|---|---|
| `scanner-throughput` | 1 MB and 10 MB streams, lines/sec |
| `reader-file` | `readLines()` over a temp file vs `node:readline` baseline |
### `@suaveplan/cli` (`src/__benchmarks__/`)
| Benchmark | Description |
|---|---|
| `parser-throughput` | 10 000 `parseArgs()` calls with a 20-flag config |
| `app-run` | Full `App.run()` dispatch cycle |
Baselines published in each package's `README.md`. A >10% regression fails CI. Each package's `package.json` must include a `"bench"` script and the turbo pipeline must include a `bench` task.
---
## 9. OpenSpec Changes
```text
openspec/changes/
enterprise-readline/
proposal.md
tasks.md
specs/readline/spec.md
enterprise-cli/
proposal.md
tasks.md
specs/cli/spec.md
```
Both archive on ship to `openspec/archive/2026-04-20-enterprise-readline/` and `openspec/archive/2026-04-20-enterprise-cli/`.
---
## 10. CLAUDE.md Additions
Add two entries to the genesis package listing under `platform`:
- **readline** — terminal I/O primitive: AsyncGenerator line reader (`readLines`), interactive prompt interface (`createInterface`), byte scanner engine. Depends on `@suaveplan/error`, `@suaveplan/types`, `@opentelemetry/api`, `zod`.
- **cli** — CLI toolkit: argument parser, command router, ANSI styling, output formatting, table renderer, spinner, progress bar, interactive prompts. Depends on `@suaveplan/readline`, `@suaveplan/error`, `@suaveplan/types`, `@opentelemetry/api`, `zod`.
---
## 11. Acceptance Criteria
Both packages are enterprise-done when:
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/readline` green
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/cli` green
- [ ] 100% istanbul coverage on both packages
- [ ] Zero `any`, `TODO`, `FIXME`, `ts-ignore`, `.skip` in `src/`
- [ ] Every `.ts` implementation file has a sibling `.md` (≥200 words)
- [ ] Zod schemas exported for all public types
- [ ] OTel spans on `App.run()`, `Command.execute()`, `createInterface()`
- [ ] AbortSignal honoured in `readLines()`, `question()`, async iterator, `spinner`, `progress`, all prompt functions
- [ ] `node:readline` absent from `@suaveplan/cli` dependency graph
- [ ] `"sideEffects": false` in both `package.json` files
- [ ] mitata benchmarks in `src/__benchmarks__/` with `"bench"` script wired in `package.json` and turbo pipeline
- [ ] Baselines published in both `README.md` files
- [ ] OpenSpec changes archived; `FOLDERS.yml` and `CLAUDE.md` updated

View File

@@ -0,0 +1,186 @@
# Design: `@suaveplan/utils` Migration — genesis
**Date:** 2026-04-20
**Branch:** genesis
**Source:** `@suaveplan/utils` from main (`/mnt/s/web/suaveplan/packages/universal/utils`)
**Reference:** `/mnt/s/web/suaveplan/enterprise/utils.md`
---
## Context
The old `@suaveplan/utils` package is a 14-module grab-bag imported by 109 reverse-consumer workspaces. On genesis, we rewrite it fresh at enterprise quality and split it into four packages, each with a clear, single responsibility. Nothing is ported mechanically — every module is rewritten to meet the genesis enterprise checklist.
---
## Decision: Split into 4 packages
| Package | Domain path | Purpose |
|---------|-------------|---------|
| `@suaveplan/utils` | `universal/data/utils` | Zero-external-dep odds-and-sods utilities |
| `@suaveplan/json` | `universal/data/json` | Prototype-pollution-safe JSON parse/stringify |
| `@suaveplan/bytes` | `universal/primitives/bytes` | Byte encoding / format conversion |
| `@suaveplan/locale-data` | `universal/i18n/locale-data` | ISO country + language static data |
**Scrapped:** `string/parseTemplate` only — duplicate of `@suaveplan/templates` which already handles `{{var}}` interpolation. `string/parseTextWithLinks` is distinct (link annotation, not template interpolation) and is retained in `@suaveplan/utils`.
---
## Package 1: `@suaveplan/utils`
**Principle:** Zero external deps beyond the genesis standard (`zod`, `@opentelemetry/api`, `@suaveplan/error`, `@suaveplan/types`). Odds-and-sods utilities that are not worth a dedicated package.
### Modules
| Module | Key exports |
|--------|-------------|
| `debug` | `debug.log/warn/error` — dedup-buffered console logger (suppresses repeated messages within a rolling 10-item buffer) |
| `object` | `deepMerge`, `deepMergeAll`, `deepClone`, `pick`, `omit`, `flattenObject`, `getNestedValue`, `setNestedValue`, `hasPath`, `isEmpty`, `isObject`, `isPlainObject`, `isObjectOrArray`, `keepKeys`, `removeKeys` |
| `platform` | `getPlatform`, `getMemoryUsage`, `getCpuUsage`, `isNode`, `isBrowser`, `isBun` — injectable platform adapter |
| `number` | `packFloat16/32/64`, `unpackFloat16/32/64`, `isInFloat16/32/64Range`, `min16/32/64`, `max16/32/64` |
| `random` | `pseudoRandom<T>(arr, index, seed?)` — seeded deterministic array element selection |
| `email` | `normalizeEmail` — Gmail dots/plus, Yahoo dashes, Microsoft plus, generic normalization |
| `sound` | `parseSoundVariations`, `getSoundKey`, `convertSprites` — audio sprite key parsing + variation selection |
| `string` | `generateId`, `parseTextWithLinks` |
| `validate` | `isHex` |
| `validation` | `validateSize`, `formatSize`, `parseSize`, `truncateToSize`, `isWithinSizeLimit`, `getDataSize`, `MessageSizeError`, `SIZE_LIMITS`, `DEFAULT_MAX_MESSAGE_SIZE` |
### Dependencies
```text
@suaveplan/error workspace:^
@suaveplan/types workspace:^
zod catalog:zod
@opentelemetry/api catalog:otel-api
```
---
## Package 2: `@suaveplan/json`
**Principle:** Safe, hardened JSON operations. No external deps.
### Modules
| Module | Key exports |
|--------|-------------|
| `safe-json` | `safeParse`, `safeStringify`, `tryParse`, `isValidJSON`, `getStringifySize`, `SafeJSONError` |
### Features
- Prototype pollution guard: rejects `__proto__`, `constructor`, `prototype` keys by default (configurable)
- Circular reference detection with structured `SafeJSONError`
- `tryParse` returns `undefined` instead of throwing
- `getStringifySize` measures serialised byte length without full allocation
- All errors extend `@suaveplan/error` with stable codes (`JSON.PARSE_FAILED`, `JSON.CIRCULAR`, `JSON.PROTOTYPE_POLLUTION`)
### Dependencies
```text
@suaveplan/error workspace:^
@suaveplan/types workspace:^
zod catalog:zod
@opentelemetry/api catalog:otel-api
```
---
## Package 3: `@suaveplan/bytes`
**Principle:** Single canonical home for byte encoding and format conversion in the monorepo. `@suaveplan/crypto-utils` currently duplicates some of this; a follow-up task updates `crypto-utils` to consume `@suaveplan/bytes` instead.
### Modules
| Module | Key exports |
|--------|-------------|
| `encoding` | `bytesToHex`, `hexToBytes`, `bytesToBase64`, `base64ToBytes`, `bytesToUtf8`, `utf8ToBytes` |
| `uint8` | `concatUint8Arrays`, `sliceUint8Array`, `uint8ArraysEqual`, `inputToUint8Array`, `detectInputFormat`, `validateInputTypeFormat`, `formatOutput`, `convertDataFormat` |
| `uuid` | `uint8ArrayToUuid`, `uuidToUint8Array`, `generateUuid` — uses `crypto.randomUUID()` natively; no `uuid` npm package needed |
### Features
- Format auto-detection (`detectInputFormat`: hex, base64, utf8, Uint8Array)
- Round-trip guarantees tested via fast-check property tests
- All errors extend `@suaveplan/error` with stable codes (`BYTES.INVALID_FORMAT`, `BYTES.ENCODE_FAILED`, etc.)
### Dependencies
```text
@suaveplan/error workspace:^
@suaveplan/types workspace:^
zod catalog:zod
@opentelemetry/api catalog:otel-api
```
### Follow-up
After `@suaveplan/bytes` is published, open an OpenSpec change to update `@suaveplan/crypto-utils` to depend on `@suaveplan/bytes` and remove the duplicate hex/base64/utf8/bytes/concat encoding utilities. **`base58` stays in `crypto-utils`** — it is blockchain/crypto-specific (Bitcoin address encoding) and not a general-purpose byte encoding concern; moving it to `bytes` would introduce a crypto-domain concept into a general primitive.
---
## Package 4: `@suaveplan/locale-data`
**Principle:** Static ISO reference data. Too large and domain-specific to be an odds-and-sods utility. Feeds `@suaveplan/i18n`.
### Modules
| Module | Key exports |
|--------|-------------|
| `countries` | `CountryItem` type, per-country named exports (`AD`, `AE`, ...), `getCountry(code)`, `getAllCountries()`, `getCountriesByContinent(continent)`, `getCountriesByCurrency(currency)` |
| `languages` | `LanguageItem` type, `getLanguages()`, `getLanguage(code)`, `getRtlLanguages()`, `isRtlLanguage(code)` |
### Features
- All data typed with Zod schemas
- Lookup helpers with structured errors for unknown codes
- Zero runtime deps — data is bundled at build time, not fetched
### Dependencies
```text
@suaveplan/error workspace:^
@suaveplan/types workspace:^
zod catalog:zod
@opentelemetry/api catalog:otel-api
```
---
## Build sequence
All four packages share the same dependency set (genesis standard), so they can land in parallel or any order. Recommended sequence:
1. `@suaveplan/bytes` — unblocks `crypto-utils` refactor
2. `@suaveplan/json` — self-contained
3. `@suaveplan/locale-data` — self-contained; enables `i18n` to consume it
4. `@suaveplan/utils` — self-contained; the catch-all
---
## Enterprise checklist (applies to all four)
- [ ] 100% statement + branch + function + line coverage (Istanbul)
- [ ] 100% docs coverage (`bun run docs:check`)
- [ ] Every `.ts` implementation file has a sibling `.md` (≥200 words, simple + advanced examples, API reference)
- [ ] All public types have Zod schemas in sibling `*.schemas.ts`
- [ ] All thrown errors extend `@suaveplan/error` with stable `PACKAGE.DOMAIN.REASON` codes
- [ ] Injectable `clock`, `rng`, `signal` where applicable
- [ ] `AbortSignal` on every iterative helper
- [ ] OTel counters + histograms + spans on public entrypoints
- [ ] fast-check property tests for pure functions
- [ ] mitata benchmarks with CI regression gate
- [ ] `sideEffects: false`; one subpath export per feature module
- [ ] `@vitest/coverage-istanbul` in `devDependencies`
- [ ] OpenSpec change created, verified, archived
---
## OpenSpec changes to create
| Change ID | Scope |
|-----------|-------|
| `enterprise-bytes` | New `@suaveplan/bytes` package |
| `enterprise-json` | New `@suaveplan/json` package |
| `enterprise-locale-data` | New `@suaveplan/locale-data` package |
| `enterprise-utils` | New `@suaveplan/utils` package (genesis rewrite) |
| `crypto-utils-bytes-refactor` | Update `@suaveplan/crypto-utils` to consume `@suaveplan/bytes` (follow-up) |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
# Test-Suite Documentation Audit — Genesis Monorepo (2026-04-24 wave)
**Date:** 2026-04-25
**Scope:** review of `openspec/specs/testing*/spec.md` (canonical, 2026-04-19) plus the 2026-04-24 testbed design + plans in `docs/superpowers/`.
**Goal:** identify gaps and improvements to elevate the test-suite documentation to enterprise-class quality.
## Executive summary — top critical gaps
1. **Spec drift is severe.** The canonical `openspec/specs/testing*/spec.md` files (2026-04-19) say nothing about `testing-integration*`, `console-monitor`, `network-monitor`, `multi-session`, or `extension-fixture`. All recent 2026-04-24 work lives only in `docs/superpowers/plans/` and has not been folded into the contract — there is no normative spec for the integration substrate that the entire testbed and CI nightly job depends on.
2. **Test-pyramid is missing several mandatory enterprise tiers.** Zero accessibility (axe-core), zero visual-regression, zero contract testing, zero mutation testing, zero load/perf, zero fuzzing for non-pure code, zero chaos/fault injection beyond the single "disable signalling route" test.
3. **Service-fixture coverage is too narrow for the package list.** `oauth-client`, `mcp`, `server-emailer`, `search`, future Kafka/NATS-style event pipes, and any object-store/secret/identity work have no fixtures. The minimum missing set: MongoDB, MySQL/MariaDB, OIDC issuer (Keycloak/dex/Hydra), MinIO (broader-S3 parity vs Garage), an OTel collector, and a NATS/Kafka broker.
4. **`apps/testbed` only partially enforces CLAUDE.md's six "Web App & Page Verification" bullets.** Console + network monitors and Docker staging are wired; rendered-DOM content checks per page, mixed-content, asset-404 detection, and the "zero-defect-to-live" gate are not formalised in the plan.
5. **Test-suite observability is absent.** No JUnit/Allure output, no OTel export of test execution despite OTel being a first-class concern in `testing/spec.md`, no flake-rate metric, no coverage trend, no trace-on-failure shipped to a sink. CI matrix is also single-arch (no arm64, no Bun version matrix, no Node fallback parity check).
---
## 1. Coverage gaps in service fixtures
| Severity | Gap | Where it belongs | Concrete fix |
|---|---|---|---|
| Critical | No OIDC issuer fixture, but `@suaveplan/oauth-client` already exists | new plugin `testing-integration-oidc` (Keycloak or `oauth2-mock-server`) under `packages/testing/` | Add to `enterprise-testbed-design.md §4.1` and create plan; required to integration-test `oauth-client` against a real Authorization Code + PKCE flow. |
| Critical | No Mongo / MySQL fixture | `enterprise-testbed-design.md §4.1` | Add `testing-integration-mongodb` (mongo:7) and `testing-integration-mysql` (mysql:8) — many real apps use these and there is no parity coverage for `db-*` siblings of `db-sqlite`. |
| High | No object-store parity beyond Garage | new `testing-integration-minio` | Garage has quirks (admin-port health, layout assignment) that mask AWS-S3 differences; MinIO is the closest free AWS-equivalent and exposes a different surface. |
| High | No Kafka/NATS/RabbitMQ broker fixture | `§4.1` | Add at least one (NATS is lightest) — events, schema-registry, idempotency, and any future stream-processing package needs it. |
| High | No OpenTelemetry collector fixture | `§4.1` | Required to actually verify the OTel instrumentation that every package ships; today you can only assert "no SDK registered = noop", not end-to-end export. |
| Medium | No Elasticsearch/OpenSearch fixture | `§4.1` | Typesense covers search but Elastic-API consumers are common — flag for later wave. |
| Medium | No Vault / secrets fixture, no LocalStack | `§4.1` | Mark as deferred; document the gap so consumers aren't surprised. |
| Low | No Caddy/nginx reverse-proxy test fixture | `§4.1` | Caddy already runs in `testbed` stack; expose a minimal fixture for routing tests. |
## 2. Test-pyramid completeness
| Severity | Gap | Where it belongs | Concrete fix |
|---|---|---|---|
| Critical | No accessibility testing | `testing-e2e/spec.md` + `testbed-pages-and-e2e.md §7.4` | Add `attachAxe(page)` helper (using `@axe-core/playwright`) and require every spec's invariant list to include `await axe.assertNoViolations()` alongside console/network monitors. |
| Critical | No visual regression | `testing-e2e/spec.md` + `testbed-foundation.md` Storybook section | Add `expect(page).toHaveScreenshot()` baseline strategy plus a Storybook test-runner job; or pin a `pixelmatch`-based helper into `testing-e2e`. Decide and write the spec — "stories exist" is not visual coverage. |
| High | No mutation testing | `testing/spec.md` quality gate | Add a Stryker job (Bun-compatible via `@stryker-mutator/core` + node) for `crypto*`, `error`, `validation`, `serialize`, and other foundation packages; gate at e.g. ≥ 70% mutation score. |
| High | No load/perf gate beyond `mitata` micro-bench | `testing/spec.md` benchmark requirement | Extend with a k6/autocannon "API smoke" gate against `apps/testbed`'s Hono routes, run nightly; current 10% mitata gate doesn't cover end-to-end latency or concurrency. |
| High | No contract tests (consumer/provider) | new `testing-contract` package or `testing-integration` extension | `oauth-client`, `webhook-client`, `schema-registry`, `api-schemas` are textbook Pact candidates; spec a `recordPactInteraction()` helper. |
| Medium | Property tests (fast-check) only mandated for `@suaveplan/testing` itself | `testing/spec.md` "Property-based coverage" requirement | Generalise into a project-wide rule: every pure-function package SHALL include fast-check round-trip tests; cite `crypto-utils`, `diff`, `pagination`, `validation`. |
| Medium | No fuzzing for parsers | per-package specs (`mime`, `dmarc`, `password-formats`, `metadata`, `exif`, `serialize`) | Require a `*.fuzz.test.ts` using `fast-check` arbitraries seeded from real-world corpora; these are all attack-surface parsers. |
| Medium | No chaos/fault injection beyond single "disable route" | `testing-e2e/spec.md` + multi-session specs | Add network-throttle, latency, and abort fixtures (`page.route` with random failure injection); already half-built in `network-monitor`. |
| Low | No snapshot strategy doc | `testing-dom/spec.md` | One-paragraph stance on inline snapshots vs file snapshots; right now nothing forbids snapshot drift. |
## 3. Cross-cutting concerns
| Severity | Gap | Where it belongs | Concrete fix |
|---|---|---|---|
| Critical | Multi-fixture lifecycle order is parallel-start / reverse-stop only — no declared dependencies between fixtures | `testing-integration-core.md` `TestEnvironment` section + `testing` spec delta | Add `dependsOn?: string[]` to `ServiceFixture`; topo-sort start order; document for cases like "OIDC issuer must be ready before app under test seeds clients". |
| Critical | No deterministic time/RNG injection contract for integration tests | new requirement in `testing-integration` spec | The `testing` spec mandates `Clock`/`Rng` injection but `testing-integration` fixtures don't propagate; add `clock`/`rng` to `TestEnvironmentOptions` and require fixture configs to pass them through where applicable. |
| High | No flake quarantine / retry policy for integration tests | `testing-integration` spec | Add `quarantine.json` + a `markFlaky()` helper that retries N times and reports — without it, transient Docker failures will block PRs. |
| High | No leak/handle-tracking on teardown | `TestEnvironment` requirement | After `teardown()`, assert via `process._getActiveHandles()` (or Bun equivalent) that no handles attributable to fixtures remain; fail the run if so. |
| High | No CI matrix on Bun versions, OS, or Node-fallback parity | `testbed-pages-and-e2e.md §7.6` | Add matrix: `bun-1.x` + `bun-canary`, `ubuntu-latest` + `macos-14` + `windows-latest` (Playwright at minimum); add a Node-runner parity job for browser-runnable packages. |
| High | No secret management policy for CI | `testing-integration` spec + `docker/README.md` | Document where `GARAGE_S3_SECRET_ACCESS_KEY` and any future OIDC client secrets live (GitHub OIDC + Vault/SOPS); current `.env.example` leaves them blank with no guidance. |
| Medium | Port collision policy is static range only | `enterprise-testbed-design.md §5.2` | testcontainers already uses random ports for the integration path; spec the dev/test-stack collision behaviour (fail-loud vs auto-shift) and document in `docker/README.md`. |
| Medium | No OOM/timeout budget per spec | `testing/spec.md` and Playwright config | Per-spec memory cap and global-suite wall-clock budget; current 30 s test timeout is per-test, no suite ceiling. |
| Medium | No network sandboxing rule | `testing-e2e/spec.md` | Default `page.route('**/*')` to block external origins unless explicitly allow-listed; today specs could hit live internet. |
## 4. Observability of the tests themselves
| Severity | Gap | Where it belongs | Concrete fix |
|---|---|---|---|
| Critical | No JUnit / Allure / structured test-result output | `testing/spec.md` + Playwright config | Mandate `junit` + `playwright html` reporters and an Allure aggregation step in CI; currently only `["html","github"]` is set. |
| High | OTel test-execution export missing despite first-class OTel in spec | new requirement in `testing/spec.md` | Wire test runner to the `testing-integration-otel-collector` fixture (see §1) and export every test's span; failed-test traces become triage gold. |
| High | No flake-rate / coverage trend tracking | `testing/spec.md` quality gate | Persist Istanbul + JUnit artefacts to a static bucket (Garage S3 itself!) and compute per-package flake rate over 30 days; gate "new flaky test" on PR. |
| Medium | Trace-on-failure exists locally (`trace: on-first-retry`) but is not exported anywhere | Playwright config | Upload `trace.zip` artefacts to the same bucket on red runs. |
## 5. Operational rigor
| Severity | Gap | Where it belongs | Concrete fix |
|---|---|---|---|
| Critical | Images pinned by tag, not digest | `docker-infrastructure.md` + plugin specs | Replace every `image: foo:tag` with `image: foo:tag@sha256:…`; tag-based pinning is non-reproducible (especially `axllent/mailpit:latest`, which is wrong for any "enterprise" claim). |
| Critical | `axllent/mailpit:latest` in particular | `docker-infrastructure.md` Chunk 1 + `testing-integration-mailpit` plugin | Pin to a specific minor (`v1.x`) and digest. |
| High | No arm64 ↔ amd64 matrix for testcontainers | `testbed-pages-and-e2e.md §7.6` | Garage and Typesense images don't always ship arm64; CI must run both arches or the spec must document amd64-only. |
| High | No airgap / offline-cache test | new requirement | Verify the test suite runs after `bun install --offline` with all images pre-pulled; many enterprises require this. |
| High | No Verdaccio integration in test path | `testing-integration` spec | The repo uses Verdaccio at `npmjs.fq.io` — there should be a `testing-integration-verdaccio` fixture (or compose service) so publish flows are testable, not just lint+typecheck+test. |
| Medium | No container resource limits | `docker-compose.*.yml` | Add `deploy.resources.limits` (cpu, memory) — without them, a runaway test can starve CI. |
| Medium | Healthcheck timeouts not coherent with WSL2 fork-pool timing | `testing-integration-core.md` HealthPoller defaults + CLAUDE.md "Vitest / WSL2" | 120 s default is fine, but document interaction with `fileParallelism: false` for browser tests; a parallel sweep can serialise health polls and blow the budget. |
## 6. Documentation / spec coherence
| Severity | Gap | Where it belongs | Concrete fix |
|---|---|---|---|
| Critical | None of the 2026-04-24 work is in `openspec/specs/` | `openspec/specs/testing-integration/spec.md` (new), and ADDED requirements in `openspec/specs/testing-e2e/spec.md` | Create an OpenSpec change folder, write deltas covering: `ServiceFixture<T>`, `TestEnvironment`, `createIntegrationSetup`, `skipIfUnavailable`, `HealthPoller`, env-bridge, plus the new e2e modules (`console-monitor`, `network-monitor`, `multi-session`, `extension-fixture`); fold on ship. |
| High | Six service-fixture plugins have no spec at all | new `openspec/specs/testing-integration-<svc>/spec.md` per plugin (or one umbrella) | Each needs at minimum: image policy, env-bridge contract, healthcheck contract, config shape, error code prefix. |
| Critical | `testing` spec mandates ≥ 95 % coverage; project rule is **100 % for every package, no exceptions** (statements / branches / functions / lines, Istanbul provider) | `openspec/specs/testing/spec.md` | Replace the 95 % threshold with a flat 100 % gate. No "foundation-only" carve-out. Pre-publish gate (`bunx turbo test:coverage --filter=@suaveplan/<pkg>`) must hard-fail under 100 %. Remove every reference to 95 % in plans and specs. |
| Medium | The `testing` spec's `provide("__integration_failed__", …)` interface is invented in plans without being mirrored in any spec | `testing-integration` spec | Document it as a stable inject-key contract or rename to `__suaveplan_integration_failed__` to avoid name collisions. |
## 7. apps/testbed verification rigor
CLAUDE.md mandates six bullets; the plan delivers ~3.5 of them.
| Severity | Bullet | Status | Concrete fix |
|---|---|---|---|
| Met | Docker staging environment | `docker-compose.testbed.yml` is fine | — |
| Partial | Full Playwright sweep of every page + every component | Pages enumerated via `routes.ts`; components only via Storybook stories — there is no spec that says "every component on every page is interacted with" | `testbed-pages-and-e2e.md §7.4` + spec | Add a per-page `interaction.spec.ts` template that asserts every `data-testid="demo-card"` triggers `data-testid="output"` to populate; baseline only navigates. |
| Critical | Never trust HTTP 200 alone — verify rendered DOM | Plans require `data-testid="page-title"` non-empty but not other rendered content | `enterprise-testbed-design.md §7.4` | Strengthen invariant 5 to "`output` AND at least one demo-specific assertion per page", and forbid baseline-only specs as the sole coverage for a route. |
| Met | Inspect browser console | `attachConsoleMonitor` covers it | — |
| Partial | Inspect network activity | `attachNetworkMonitor` exists; no rules for missing-asset / mixed-content / CORS | `testing-e2e/spec.md` | Extend `NetworkMonitor` contract: `assertNoMissingAssets()`, `assertNoMixedContent()`, `assertNoCorsErrors()` as separate methods, all called by invariant. |
| Critical | Zero-defect push to live | No gate exists | CI workflow + `enterprise-testbed-design.md` | Add a "release readiness" job that fails on any of: console error, network failure, axe violation, visual diff, broken-link crawler hit. Without this, the bullet is aspirational. |
## 8. Test-suite onboarding
| Severity | Gap | Where it belongs | Concrete fix |
|---|---|---|---|
| High | No single "how do I write a test for X" entry point | new `docs/testing/README.md` (or `openspec/specs/testing/AGENTS.md`) | Decision tree: pure fn → vitest+fast-check; React → `testing-dom`; needs Docker → `testing-integration` + plugin; cross-browser → `testing-e2e`; performance → mitata + k6. Link from root README and CLAUDE.md. |
| Medium | No "first test in 5 minutes" tutorial against `apps/testbed` | `apps/testbed/README.md` | The plan's README requirements list operational topics but not a tutorial; add one. |
| Medium | No discoverability index of fixtures | `docker/README.md` or new `docs/testing/fixtures.md` | Table mapping "I need to test X" → "use fixture Y" for every plugin. |
---
## Universal coverage rule
**100 % Istanbul coverage (statements / branches / functions / lines) is mandatory for every `@suaveplan/*` package — no exceptions, no "foundation only", no "95 % is fine for X".** The pre-publish gate (`bunx turbo test:coverage --filter=@suaveplan/<pkg>`) MUST hard-fail below 100 %. Every spec, plan, and tooling default in this repo SHALL state 100 % and only 100 %. Any 95 % reference is a defect to fix on sight.
Mutation testing, contract testing, and property/fuzz coverage are layered ON TOP of 100 % line coverage — they do not substitute for it, and 100 % line coverage does not substitute for them.
## Recommended sequencing
1. **Lift 2026-04-24 plans into normative specs.** Open an OpenSpec change adding `openspec/specs/testing-integration/spec.md` + per-plugin specs + ADDED requirements in `testing-e2e/spec.md` for the new modules. Without this, every future plugin compounds drift.
2. **Replace every 95 % coverage reference with 100 %** in the same change — `testing/spec.md`, the archived `2026-04-19-enterprise-testing` design notes if still authoritative, and any plan defaults. Add an explicit "100 %, no exceptions" line to `openspec/specs/testing/spec.md` quality requirements.
3. **Add the missing pyramid tiers as separate OpenSpec changes**, in this order: a11y (cheap, high-signal) → visual regression → OTel-collector fixture + test-execution export → contract testing → mutation testing → load/perf gate.
4. **Tighten testbed verification** to fully meet CLAUDE.md's six bullets — strengthen invariants, add missing-asset/mixed-content/CORS assertions, add the release-readiness gate.
5. **Fix operational hygiene last** — digest-pin all images, add resource limits, add CI matrix, add airgap rebuild test.
## Files referenced
- `/mnt/s/web/genesis/openspec/specs/testing/spec.md`
- `/mnt/s/web/genesis/openspec/specs/testing-dom/spec.md`
- `/mnt/s/web/genesis/openspec/specs/testing-e2e/spec.md`
- `/mnt/s/web/genesis/docs/superpowers/specs/2026-04-24-enterprise-testbed-design.md`
- `/mnt/s/web/genesis/docs/superpowers/plans/2026-04-24-docker-infrastructure.md`
- `/mnt/s/web/genesis/docs/superpowers/plans/2026-04-24-testing-integration-core.md`
- `/mnt/s/web/genesis/docs/superpowers/plans/2026-04-24-testing-integration-plugins.md`
- `/mnt/s/web/genesis/docs/superpowers/plans/2026-04-24-testbed-foundation.md`
- `/mnt/s/web/genesis/docs/superpowers/plans/2026-04-24-testbed-pages-and-e2e.md`

View File

@@ -0,0 +1,873 @@
# Universe Family — Design Spec
**Date:** 2026-04-25
**Status:** Draft
**Packages:** 9 new packages across `geo`, `astronomy`, and `gaming` domains
**Replaces:** `@suaveplan/day-night` (retired), `@suaveplan/geo` (restructured into 3 packages)
---
## 1. Motivation
The existing `@suaveplan/day-night` package mixes gaming concerns (ECS components, gameplay modifiers, in-game clock) with simplified pseudo-astronomy (hardcoded sinusoidal sun arc, 30-day moon cycle, no lat/lon). The existing `@suaveplan/geo` covers Earth-surface geometry only. Neither is enterprise-grade, and neither is extensible.
This spec defines a nine-package family that:
- Provides real, observatory-accurate astronomical calculations via a pluggable provider architecture
- Generalises Earth-surface geometry to any planet via configurable ellipsoid + physical parameters
- Supports an observer-centric model: stars, planets, tides, and lighting are computed from the point of view of a single observer position — on any planet surface, in atmosphere, or anywhere in space
- Runs all of the above at configurable N× speed for game engines, with physically-derived lighting and gameplay modifiers replacing hardcoded presets
- Locks in a binary wire protocol (BitSchemas via `@suaveplan/bitpacker`) for efficient server→client delivery of star query results, deferring the SurrealDB implementation to a later phase
---
## 2. Package Family & Dependency Graph
### Locations in genesis monorepo
```
Layer 0 — Abstract geometry
universal/geo/geo @suaveplan/geo
Layer 1 — Concrete surfaces
universal/geo/geo-earth @suaveplan/geo-earth
universal/geo/geo-planet @suaveplan/geo-planet
Layer 2 — Astronomical observer + plugin hub
universal/astronomy/universe @suaveplan/universe
universal/astronomy/solar-system @suaveplan/solar-system
universal/astronomy/stars @suaveplan/stars
universal/primitives/tides @suaveplan/tides (enterprise rewrite)
Layer 3 — Game layer
universal/gaming/gaming-universe @suaveplan/gaming-universe
universal/gaming/gaming-environment-lighting @suaveplan/gaming-environment-lighting
```
### Dependency graph
```
geo (zero workspace deps)
geo-planet → geo, error, types
geo-earth → geo, geo-planet, error, types ← geo-earth IS geo-planet configured for Earth
universe → geo, geo-planet, bitpacker, error, types
solar-system → universe, error, types
stars → universe, bitpacker, error, types
tides → universe, geo-planet, error, types
gaming-universe → universe, timing, events, error, types
gaming-environment-lighting → gaming-universe, events, error, types
```
### What happens to existing packages
- **`@suaveplan/day-night`** — retired. `gaming-environment-lighting` is its enterprise replacement.
- **`@suaveplan/geo`** — restructured. The enterprise migration of the existing package becomes `geo-earth`. Abstract math is extracted into `geo`. `geo-planet` is new.
- **`@suaveplan/tides`** — enterprise rewrite in place; gains `TidalProvider` adapter for `universe` integration.
---
## 3. `@suaveplan/geo` — Abstract Geometry
### Purpose
Pure spherical and ellipsoidal geometry math. No planet-specific constants, no astronomical meaning. Every operation that needs a body's shape takes an explicit `Ellipsoid` parameter.
### Dependencies
`@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`. Zero other workspace deps.
### Core types
```ts
interface Ellipsoid {
semiMajorAxis: number; // equatorial radius, metres
semiMinorAxis: number; // polar radius, metres
flattening: number; // derived: (a − b) / a
eccentricity: number; // derived
}
interface SphericalCoord {
latitude: number; // degrees, −90 to 90
longitude: number; // degrees, −180 to 180
altitude?: number; // metres above surface
}
interface Vec3 { x: number; y: number; z: number }
interface Mat3x3 { /* 9-element row-major matrix */ }
```
### Subpath exports
| Subpath | Contents |
|---|---|
| `geo/ellipsoid` | `Ellipsoid`, `defineEllipsoid(a, b)` |
| `geo/transforms` | `sphericalToCartesian`, `cartesianToSpherical`, `toRadians`, `toDegrees` |
| `geo/distance` | `greatCircleDistance(a, b, ellipsoid, unit?)`, `vincentyDistance(a, b, ellipsoid, unit?)` |
| `geo/bearing` | `bearing(from, to)`, `destination(start, bearingDeg, distM, ellipsoid)`, `midpoint(a, b)` |
| `geo/angular` | `angularSeparation(a, b)` — angle between two unit-sphere points |
| `geo/bounds` | `BoundingBox`, `createBoundingBox`, `containsPoint`, `expandBox`, `mergeBoxes`, `intersects` |
| `geo/polygon` | `pointInPolygon`, `polygonArea`, `polygonPerimeter`, `simplifyPolygon` |
| `geo/units` | `DistanceUnit`, `convertDistance` |
### Explicit non-scope
- No numeric constant for any specific body (Earth, Mars, etc.)
- No geocoding providers
- No geohash
- No atmospheric models
- No astronomical meaning — nothing about observers, stars, or coordinate frames
---
## 4. `@suaveplan/geo-planet` — Configurable Planet Surface
### Purpose
Defines `PlanetConfig` — the single source of truth for any planet's physical, rotational, atmospheric, and satellite properties. Implements surface geometry on any configured ellipsoid.
### Dependencies
`@suaveplan/geo`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`.
### Core types
```ts
interface PlanetConfig {
name: string;
mass: number; // kg
radius: number; // metres, equatorial
polarRadius: number; // metres
surfaceGravity: number; // m/s²
rotationPeriod: number; // seconds (sidereal day)
axialTilt: number; // degrees
atmosphere: AtmosphereConfig | null;
satellites: SatelliteConfig[];
orbital?: OrbitalConfig; // Keplerian elements — for game-defined planets
textures?: PlanetTextureManifest; // identifier refs only, no bundled assets
}
interface AtmosphereConfig {
surfacePressure: number; // Pa
scaleHeight: number; // metres (pressure halving altitude)
rayleighCoefficient: number; // for sky colour computation
mieCoefficient: number; // for Mie scattering (dust, haze)
extinctionPerAirMass: number; // magnitudes of star dimming per air mass
}
interface SatelliteConfig {
name: string;
mass: number; // kg
radius: number; // metres
orbitalRadius: number; // metres, semi-major axis
orbitalPeriod: number; // seconds
orbitalInclination: number; // degrees
eccentricity: number;
albedo: number; // 0–1, reflectivity
}
interface OrbitalConfig {
semiMajorAxisAU: number;
eccentricity: number;
inclinationDeg: number;
argumentOfPerihelionDeg: number;
ascendingNodeDeg: number;
meanAnomalyAtEpochDeg: number;
epochJd: number; // Julian date of reference epoch
}
interface PlanetTextureManifest {
diffuse?: string; // URL or asset identifier
normal?: string;
specular?: string;
clouds?: string;
night?: string; // city lights, etc.
}
```
### PlanetSurface
```ts
class PlanetSurface {
constructor(planet: PlanetConfig)
distance(a: SphericalCoord, b: SphericalCoord, unit?: DistanceUnit): number
bearing(from: SphericalCoord, to: SphericalCoord): number
destination(start: SphericalCoord, bearingDeg: number, distanceM: number): SphericalCoord
pointInPolygon(point: SphericalCoord, polygon: SphericalCoord[]): boolean
atmosphericDensity(altitudeMetres: number): number // kg/m³, derived from AtmosphereConfig
airMass(altitudeDeg: number): number // Kasten–Young formula
}
```
---
## 5. `@suaveplan/geo-earth` — Earth Configuration
### Purpose
`geo-earth` is `geo-planet` pre-configured for Earth. It is not a sibling of `geo-planet` — it depends on `geo-planet` and exposes Earth's `PlanetConfig` as a constant, plus Earth-specific additions that do not generalise to other planets.
### Dependencies
`@suaveplan/geo`, `@suaveplan/geo-planet`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`.
### What it provides
```ts
// Earth's PlanetConfig constant — WGS84 ellipsoid, Moon as satellite, ISA atmosphere
export const EARTH: PlanetConfig
// Pre-built PlanetSurface for Earth — no config required
export const earthSurface: PlanetSurface
// Earth-specific additions
export { GeocodingProvider, GeocodingResult, createInMemoryProvider, batchGeocode }
export { encodeGeohash, decodeGeohash }
export { parseCoordinate, formatCoordinate } // DMS parsing / formatting
```
The enterprise migration of the existing `@suaveplan/geo` lands here. The existing public API is preserved — only the internal implementation changes (math now delegates to `geo-planet`).
---
## 6. `@suaveplan/universe` — Observer Model, Frames, Plugin Registry, Wire Protocol
### Purpose
The semantic core of the family. Where geometry becomes astronomy. Owns: the unified observer position type, coordinate frame definitions and converters, provider interfaces, the plugin registry, and wire BitSchemas.
### Dependencies
`@suaveplan/geo`, `@suaveplan/geo-planet`, `@suaveplan/bitpacker`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`.
### 6a — ObserverPosition
```ts
type ObserverPosition =
| { type: 'surface'; planet: PlanetConfig; coord: SphericalCoord }
| { type: 'atmospheric'; planet: PlanetConfig; coord: SphericalCoord; altitudeMetres: number }
| { type: 'space'; position: Vec3; frame: CoordinateFrame }
```
The `PlanetConfig` is carried inline on surface/atmospheric positions — no lookup required at query time.
### 6b — CoordinateFrame
```ts
type CoordinateFrame =
| { type: 'heliocentric' } // Sun at origin, AU scale, J2000 equatorial
| { type: 'galactic' } // Galactic centre, light-year scale
| { type: 'custom'; origin: Vec3; basisMatrix: Mat3x3 }
// Converters — pure functions, fully testable
function convertFrame(position: Vec3, from: CoordinateFrame, to: CoordinateFrame): Vec3
function toHeliocentric(position: Vec3, from: CoordinateFrame): Vec3
function toGalactic(position: Vec3, from: CoordinateFrame): Vec3
```
Heliocentric is used within the solar system. Galactic for interstellar travel. Custom allows game-defined local frames.
### 6c — Provider interfaces
```ts
interface CelestialBody {
name: string
type: 'star' | 'planet' | 'moon' | 'asteroid' | 'comet'
altitude: number // degrees above observer horizon
azimuth: number // degrees clockwise from north
apparentMagnitude: number
angularDiameterArcmin: number // apparent size in arcminutes
distanceAU: number
heliocentricPosition: Vec3
}
// Raw catalog record — no observer-relative fields
interface CatalogStar {
catalogId: string // e.g. 'HR 7001' (BSC5), 'HIP 91262' (Hipparcos)
ra: number // Right Ascension, degrees J2000
dec: number // Declination, degrees J2000
distance?: number // parsecs (undefined if no parallax data)
apparentMagnitude: number
spectralType: string
}
// Observer-relative star — returned by getVisibleStars; altitude/azimuth always present
interface ObservedStar extends CatalogStar {
altitude: number // degrees above observer horizon
azimuth: number // degrees clockwise from north
extinctedMagnitude: number // apparentMagnitude dimmed by atmosphere (= apparentMagnitude on vacuum/space)
}
// Union used in StarProvider — queryCone returns CatalogStar, getVisibleStars returns ObservedStar
type Star = CatalogStar | ObservedStar
interface StarQueryOptions {
magnitudeLimit?: number
atmosphericExtinction?: boolean
minAltitudeDeg?: number
maxResults?: number
}
interface TideExtreme {
type: 'high' | 'low'
height: number // metres above chart datum
time: Date
satellite?: string // which moon caused this extreme (multi-moon planets)
}
interface SolarProvider {
getBodies(observer: ObserverPosition, time: Date, signal?: AbortSignal): Promise<CelestialBody[]>
getBody(name: string, observer: ObserverPosition, time: Date, signal?: AbortSignal): Promise<CelestialBody>
// ObserverPosition carries PlanetConfig for surface/atmospheric; space observer uses heliocentric frame
getSunrise(observer: ObserverPosition, date: Date, signal?: AbortSignal): Promise<Date>
getSunset(observer: ObserverPosition, date: Date, signal?: AbortSignal): Promise<Date>
}
interface StarProvider {
// Returns ObservedStar[] — altitude/azimuth always populated
getVisibleStars(observer: ObserverPosition, time: Date, options: StarQueryOptions, signal?: AbortSignal): Promise<ObservedStar[]>
// Returns CatalogStar[] — catalog-space query, no altitude/azimuth
queryCone(ra: number, dec: number, radiusDeg: number, options?: StarQueryOptions, signal?: AbortSignal): Promise<CatalogStar[]>
getStar(catalogId: string, signal?: AbortSignal): Promise<CatalogStar>
}
interface TidalProvider {
predictHeight(planet: PlanetConfig, coord: SphericalCoord, time: Date, signal?: AbortSignal): Promise<number>
getExtremes(planet: PlanetConfig, coord: SphericalCoord, date: Date, signal?: AbortSignal): Promise<TideExtreme[]>
}
// WeatherProvider — locked now, implementation deferred (@suaveplan/weather)
// Modulates star visibility (cloud cover occludes), sky colour (Mie scatter from fog/dust),
// and gameplay modifier visibilityRangeMetres. UniverseClient passes weather state to
// gaming-environment-lighting without breaking changes when @suaveplan/weather lands.
interface WeatherState {
cloudCoverage: number // 0–1 (0 = clear, 1 = overcast)
cloudOpticalDepth: number // effective magnitude extinction from clouds (0 = none)
fogDensity: number // 0–1, drives Mie coefficient boost in sky colour
precipitationRate: number // mm/hr, for surface wetness / visibility degradation
}
interface WeatherProvider {
getWeather(observer: ObserverPosition, time: Date, signal?: AbortSignal): Promise<WeatherState>
}
```
### 6d — UniverseClient
```ts
interface SceneOptions {
includeSolar?: boolean // default true — compute sun/moon/planet positions
includeStars?: boolean // default true — query visible stars
includeTides?: boolean // default true — compute tidal extremes (surface only)
starOptions?: StarQueryOptions // passed through to StarProvider.getVisibleStars
}
class UniverseClient {
register(domain: 'solar', provider: SolarProvider): this
register(domain: 'stars', provider: StarProvider): this
register(domain: 'tides', provider: TidalProvider): this
// weather slot: locked now, implementation deferred — gaming-environment-lighting
// reads WeatherState from UniverseScene without a breaking API change when @suaveplan/weather lands
register(domain: 'weather', provider: WeatherProvider): this
// signal cancels all in-flight provider calls
getScene(observer: ObserverPosition, time: Date, options?: SceneOptions, signal?: AbortSignal): Promise<UniverseScene>
}
interface UniverseScene {
bodies: CelestialBody[]
stars: ObservedStar[]
tides?: TideExtreme[]
weather?: WeatherState // undefined until WeatherProvider registered
}
```
### 6e — Wire BitSchemas
Star data is quantised before packing — rendering does not require float64 precision.
```ts
// ~11 bytes per star vs ~200 bytes JSON — ~18× compression
const StarPacket = defineBitSchema([
{ name: 'ra', type: 'uint', bits: 19 }, // 0–360,000 (0.001° steps)
{ name: 'dec', type: 'int', bits: 18 }, // ±90,000
{ name: 'magnitude', type: 'uint', bits: 11 }, // 0–1,200 (×10 + 20 offset: −2.0 to 10.0)
{ name: 'altitude', type: 'int', bits: 15 }, // ±9,000 (0.01° steps)
{ name: 'azimuth', type: 'uint', bits: 16 }, // 0–36,000
{ name: 'spectralClass', type: 'uint', bits: 4 }, // OBAFGKM + 8 reserved
]);
// CelestialBodyPacket — apparentMagnitude: uint, ×10 + 300 offset, 0.1 magnitude resolution
// covers −30.0 to +174.7 (Sun = −26.7 → stored as 33; Jupiter = −2.9 → stored as 271; full 11-bit range used)
const CelestialBodyPacket = defineBitSchema([
{ name: 'altitude', type: 'int', bits: 15 }, // ±9,000 (0.01° steps)
{ name: 'azimuth', type: 'uint', bits: 16 }, // 0–36,000 (0.01° steps)
{ name: 'angularDiameterArcmin', type: 'uint', bits: 14 }, // 0–10,000 (0.01′ steps, covers up to 166°)
{ name: 'apparentMagnitude', type: 'uint', bits: 11 }, // 0–2,047 (×10 + 300 offset: −30.0 to +74.7)
{ name: 'bodyType', type: 'uint', bits: 3 }, // 0=star 1=planet 2=moon 3=asteroid 4=comet
]);
// PackedObservedStar — only the fields recoverable from StarPacket bits
// catalogId and distance are NOT in the wire format; use the catalog source to re-fetch if needed
interface PackedObservedStar {
ra: number; dec: number; apparentMagnitude: number
altitude: number; azimuth: number
spectralClass: number // 0-6 = OBAFGKM, 7 = unknown
}
// Pack/unpack helpers — consumers never touch raw bit widths
// packStars requires ObservedStar (altitude/azimuth always present)
function packStars(stars: ObservedStar[]): Uint8Array
function unpackStars(buf: Uint8Array, count: number): PackedObservedStar[]
// PackedCelestialBody — only the fields recoverable from CelestialBodyPacket bits
// name, distanceAU, heliocentricPosition are NOT in the wire format
interface PackedCelestialBody {
altitude: number; azimuth: number
angularDiameterArcmin: number; apparentMagnitude: number
bodyType: 0 | 1 | 2 | 3 | 4 // 0=star 1=planet 2=moon 3=asteroid 4=comet
}
function packBodies(bodies: CelestialBody[]): Uint8Array
function unpackBodies(buf: Uint8Array, count: number): PackedCelestialBody[]
```
---
## 7. `@suaveplan/solar-system`
### Purpose
Implements `SolarProvider` using [astronomy.js](https://github.com/cosinekitty/astronomy) for real solar system bodies, and a Keplerian propagator for game-defined planets carrying `OrbitalConfig`.
### Dependencies
`@suaveplan/universe`, `@suaveplan/error`, `@suaveplan/types`, `astronomy-engine`, `zod`, `@opentelemetry/api`.
### Two-engine model
**Real bodies** (Sun, Moon, Mercury–Neptune, Pluto, Ceres): astronomy.js computes positions, rise/set/transit, phase, illumination, angular diameter — correct to arcsecond precision.
**Configured bodies** (game planets with `OrbitalConfig`): Keplerian solver propagates position from epoch. Physically plausible, not observatory-grade.
Per `CelestialBody` computed from `ObserverPosition`:
| Field | Source |
|---|---|
| `altitude` / `azimuth` | Observer lat/lon + time → horizontal coordinates |
| `angularDiameterArcmin` | `2 × arctan(radius / distance) × (180/π × 60)` |
| `apparentMagnitude` | Phase angle + distance (astronomy.js for real bodies) |
| `heliocentricPosition` | Vec3 in heliocentric frame |
For `SurfacePosition` / `AtmosphericPosition` observers, `PlanetConfig.atmosphere` applies extinction to magnitudes near the horizon.
### Additional exports (standalone, outside `SolarProvider`)
```ts
// planet defaults to EARTH — pass any PlanetConfig with axialTilt and OrbitalConfig for other planets
getSeason(date: Date, planet?: PlanetConfig): Season
getNextEquinox(after: Date, planet?: PlanetConfig): Date
getNextSolstice(after: Date, planet?: PlanetConfig): Date
getLunarPhase(time: Date): LunarPhase // phase 0–1, illumination %, name (Earth/Moon only)
predictEclipse(after: Date, type: 'solar' | 'lunar'): EclipseEvent // Earth only
getConstellation(ra: number, dec: number): string
```
All functions return `Date` in UTC. Consumers needing timezone-localised sunrise/sunset/season dates should convert via `@suaveplan/datetime`, which handles tz-aware date math and is the monorepo-standard for timezone operations.
### Injectable engine
```ts
interface AstronomyEngine {
bodyPosition(body: string, observer: AstroObserver, time: AstroTime): EquatorialCoords
riseSetTransit(body: string, observer: AstroObserver, date: AstroTime): RiseSetTransit
}
function createSolarSystemProvider(options?: { engine?: AstronomyEngine }): SolarProvider
```
Tests inject a deterministic engine. Production uses astronomy.js.
---
## 8. `@suaveplan/stars`
### Purpose
Implements `StarProvider` with a two-level plugin system: swappable catalog sources within `stars`, and `stars` itself registered as a plugin to `universe`.
### Dependencies
`@suaveplan/universe`, `@suaveplan/bitpacker`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`.
### Core types
```ts
// Native catalog record — the shape every StarCatalogSource works with internally
interface RawStar {
catalogId: string
ra: number // degrees J2000
dec: number // degrees J2000
apparentMagnitude: number
spectralType: string
distance?: number // parsecs
properMotionRa?: number // mas/yr — present in Hipparcos and above
properMotionDec?: number // mas/yr
parallax?: number // mas — present in Hipparcos and above
}
```
### Catalog source interface
```ts
interface StarCatalogSource {
readonly id: string
readonly magnitudeLimit: number
readonly starCount: number
queryCone(ra: number, dec: number, radiusDeg: number, options?: StarQueryOptions, signal?: AbortSignal): Promise<RawStar[]>
getById(catalogId: string, signal?: AbortSignal): Promise<RawStar | null>
}
```
### Bundled catalog — BSC5
Yale Bright Star Catalog (~9,096 stars, magnitude ≤ 6.5). Ships as compact binary (bitpacked at build time). ~100 KB vs ~2 MB JSON.
```ts
import { createBsc5Source } from '@suaveplan/stars/catalogs/bsc5'
const source = createBsc5Source() // no config, no network, offline
```
### External catalog interfaces (contracts locked in, implementations deferred)
```ts
createHipparcosSource(options: HipparcosSourceOptions): StarCatalogSource // 118k stars
createTycho2Source(options: Tycho2SourceOptions): StarCatalogSource // 2.5M stars
createGaiaSource(options: GaiaSourceOptions): StarCatalogSource // 1.5B sources
```
Each takes injectable `fetchFn` (wrap with `@suaveplan/retry`'s circuit breaker + jittered backoff — Hipparcos/Tycho/Gaia are flaky external APIs) and optional `cacheFn` (wire `@suaveplan/cache`'s `get`/`set` — catalog queries are read-mostly and TTL-friendly).
### SurrealDB provider interface (locked in, implementation deferred)
`SurrealAdapter` is defined structurally within `stars` — `@suaveplan/surrealdb` satisfies it at runtime but is not a hard dependency, keeping `stars` free of the database package when the SurrealDB source is not used.
```ts
// Structural interface — satisfied by SurrealAdapter from @suaveplan/surrealdb
interface SurrealAdapterLike {
query<T>(sql: string, vars?: Record<string, unknown>): Promise<T[]>
}
interface SurrealStarProviderOptions {
adapter: SurrealAdapterLike
namespace: string
database: string
coneFn: string // SurrealDB JS function name, default: 'stars::cone_search'
extinctionFn: string // default: 'stars::apply_extinction'
atmosphereDensity?: number // g/cm², derived from observer's PlanetConfig.atmosphere
}
function createSurrealStarSource(options: SurrealStarProviderOptions): StarCatalogSource
```
Results arrive bitpacked (`StarPacket`), unpacked client-side using `unpackStars()`.
### Atmospheric extinction
```ts
// Pogson's formula — applied when ObserverPosition has AtmosphereConfig
extinctedMagnitude = rawMagnitude + extinctionCoeff × airMass(altitudeDeg)
// Kasten–Young formula — accurate to ~5° altitude
function airMass(altitudeDeg: number): number
```
`SpacePosition` and vacuum-planet observers receive raw magnitudes with no extinction.
### StarProvider implementation
```ts
class StarProviderImpl implements StarProvider {
constructor(sources: StarCatalogSource[]) // priority-ordered
getVisibleStars(observer, time, options, signal?): Promise<ObservedStar[]>
// 1. Compute horizon frame from ObserverPosition
// 2. queryCone full visible hemisphere
// 3. Filter by minAltitudeDeg, magnitudeLimit
// 4. Apply atmospheric extinction from PlanetConfig
// 5. Sort by brightness
queryCone(ra, dec, radiusDeg, options?, signal?): Promise<CatalogStar[]>
getStar(catalogId, signal?): Promise<CatalogStar>
}
```
---
## 9. `@suaveplan/tides` — Enterprise Rewrite
### Purpose
Enterprise-grade tidal prediction for Earth (via real harmonic data) and any planet (via physics-based model). Implements `TidalProvider` for `universe` integration.
### Dependencies
`@suaveplan/universe`, `@suaveplan/geo-planet`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`.
### What's preserved
TICON and ADMIRALTY providers are kept intact — same harmonic constituent engine, same station data, same multi-provider fallback chain. Earth API is unchanged.
### Enterprise additions (cross-cutting)
- Zod schemas for all public option types and wire formats
- `AbortSignal` on every async method
- `TidesError` extending `@suaveplan/error` with stable `TIDES.*` codes
- OTel spans, counters, histograms on every provider call
- Property-based tests (fast-check) on harmonic prediction engine
- Injectable `fetchFn` (ADMIRALTY is a flaky external API — wrap with `@suaveplan/retry` circuit breaker + jitter), `clockFn`, optional `cacheFn` (wire `@suaveplan/cache` — tide tables are read-mostly and TTL-friendly)
### Multi-satellite physics model
```ts
// Tidal height from all satellites in PlanetConfig, superposed
function tidalHeight(planet: PlanetConfig, coord: SphericalCoord, time: Date): number
// Individual satellite contribution — useful for visualisation
function satelliteTidalContribution(
planet: PlanetConfig,
satellite: SatelliteConfig,
coord: SphericalCoord,
time: Date
): number
```
Physics: `tidalRange ∝ (satelliteMass / orbitalRadius³) × (planetRadius⁴ / planetMass)`. Multiple satellites superpose — orbital resonances produce complex spring/neap interference patterns automatically.
```ts
createPhysicsBasedProvider(): TidalProvider // any PlanetConfig with satellites
createTiconProvider(): TidalProvider // Earth, offline harmonic data
createAdmiraltyProvider(options): TidalProvider // Earth, UKHO API 600+ stations
```
### TidalProvider routing
```ts
class TidalProviderImpl implements TidalProvider {
constructor(options: {
earthProviders: TidalProvider[] // tried in order for Earth
physicsProvider: TidalProvider // fallback for non-Earth planets
})
predictHeight(planet, coord, time, signal?): Promise<number>
getExtremes(planet, coord, date, signal?): Promise<TideExtreme[]>
}
```
Earth identification uses `planet.name.toLowerCase() === 'earth'` — a named string match, not a floating-point mass/radius comparison, which would be fragile. The `EARTH` constant exported from `@suaveplan/geo-earth` has `name: 'Earth'`. Game-defined planets must use a different name. Non-Earth → physics model. Planets with no satellites → zero height, empty extremes.
---
## 10. `@suaveplan/gaming-universe`
### Purpose
Wraps `UniverseClient` with a configurable N× game clock. All queries use game time. The `startTime` being a real calendar date gives the real historical or projected sky.
### Dependencies
`@suaveplan/universe`, `@suaveplan/timing`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`.
### Clock design
`GamingUniverse` does **not** invent its own scheduler. It wraps `@suaveplan/timing`'s `AsyncScheduler` (which already provides AbortSignal drain, interval tick, and injectable clock) to drive game time. `setTimeScale`/`pause`/`resume` control the multiplier applied to `timing`'s real-time ticks before accumulating into game time. This makes cancellation and clock injection uniform with the rest of the monorepo, and makes `@suaveplan/game-loop` (when it exists) the natural per-tick integration point for calling `getScene()`.
### API
```ts
interface GamingUniverseOptions {
startTime: Date // real calendar date — determines actual star/planet positions
timeScale: number // initial multiplier: 1 = real, 3600 = 1s = 1hr game time
universe: UniverseClient
clock?: Clock // injectable — from @suaveplan/types; defaults to Date.now
}
class GamingUniverse {
constructor(options: GamingUniverseOptions)
setTimeScale(n: number): void
setGameTime(date: Date): void
pause(): void
resume(): void
isPaused(): boolean
getGameTime(): Date
// Full scene at current game time — signal cancels in-flight provider calls
getScene(observer: ObserverPosition, options?: SceneOptions, signal?: AbortSignal): Promise<UniverseScene>
readonly universe: UniverseClient
// Stops the clock and cancels all in-flight queries; idempotent
dispose(): void
}
```
### Events
```ts
// All emitted via @suaveplan/events
hourChanged { gameTime: Date }
dayChanged { gameTime: Date; dayNumber: number }
seasonChanged { season: 'spring' | 'summer' | 'autumn' | 'winter' }
sunriseStarted { planet: PlanetConfig; coord: SphericalCoord }
sunsetStarted { planet: PlanetConfig; coord: SphericalCoord }
moonriseStarted { satellite: SatelliteConfig; altitude: number } // altitude at horizon crossing
moonsetStarted { satellite: SatelliteConfig; altitude: number } // altitude at horizon crossing (typically ≈0°)
eclipseStarted { type: 'solar' | 'lunar'; totality: number }
```
---
## 11. `@suaveplan/gaming-environment-lighting`
### Purpose
Replaces `@suaveplan/day-night`. Light schedule, sky colour, star visibility thresholds, and gameplay modifiers are all physically derived from astronomical state — no hardcoded presets.
### Dependencies
`@suaveplan/gaming-universe`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`.
### Sky colour — Rayleigh/Mie scattering
```
sunAltitude → airMass → scatteringPath → RGB sky colour
```
Driven by `AtmosphereConfig.rayleighCoefficient` and `mieCoefficient`:
- Earth noon: deep blue
- Earth sunset: orange/red (long scatter path)
- Mars noon: butterscotch (CO₂ + dust Mie scatter)
- Vacuum world: black always, stars always visible
### Twilight star visibility thresholds
```
sun altitude > 0° no stars visible
sun altitude ≤ 0° civil twilight magnitude ≤ 1
sun altitude ≤ −6° nautical twilight magnitude ≤ 3
sun altitude ≤ −12° astronomical twilight magnitude ≤ 6
sun altitude ≤ −18° full dark all naked-eye stars
```
On vacuum planets (no `AtmosphereConfig`): threshold collapses, stars always visible.
### Satellite lighting
```ts
// Per satellite contribution, summed across all PlanetConfig.satellites
moonLight ∝ phaseIllumination × angularDiameter² × albedo
```
Multiple moons sum. Large close moons can brightly illuminate the surface at "night".
### Gameplay modifiers — continuous functions of ambient light
```ts
interface EnvironmentModifiers {
totalAmbientLight: number // 0–1, sun + all moons
stealthBonus: number // 1 − totalAmbientLight
visibilityRangeMetres: number // base × totalAmbientLight × atmosphereClarity
npcSleepProbability: number // sigmoid of localHour relative to sunrise/sunset
}
```
### Environment component shapes
These are **plain TypeScript interfaces** — framework-agnostic data shapes. They are not registered as ECS archetypes and carry no dependency on `@suaveplan/ecs`. Consumers wiring them into an ECS (e.g. bitecs, miniplex) do so by reading these shapes and mapping fields to their own component storage.
```ts
interface DayNightClock { gameTime: Date; timeScale: number; paused: boolean }
interface LightState { skyColour: RGB; sunAltitude: number; totalAmbientLight: number;
moonContributions: MoonLight[]; weather?: WeatherState }
interface DayNightAffected { modifiers: EnvironmentModifiers }
```
### Render payload
`gaming-environment-lighting` publishes a `RenderPayload` each tick — the canonical shape for downstream render integrations (`@suaveplan/r3f-environment`, Three.js custom shaders, etc.). Locking this now prevents churn when R3F integration lands.
```ts
interface RenderPayload {
// Sky
skyColour: RGB
sunAltitude: number // degrees
sunAzimuth: number // degrees
horizonColour: RGB // for gradient blending at low sun angles
// Lighting
totalAmbientLight: number // 0–1
moonContributions: MoonLight[]
// Atmosphere
fogDensity: number // from AtmosphereConfig + WeatherState
rayleighTint: RGB // for shader uniforms
// Textures (from PlanetTextureManifest — identifiers only, no assets)
textures: PlanetTextureManifest
// Weather (undefined if no WeatherProvider registered)
weather?: WeatherState
}
```
`gaming-environment-lighting` never loads or stores texture assets — `PlanetTextureManifest` identifiers are passed through verbatim.
---
## 12. Implementation Order
Packages must land in dependency order:
```
1. geo
2. geo-planet
3. geo-earth (depends on geo-planet)
4. universe (depends on geo, geo-planet)
5. solar-system (depends on universe)
6. stars (depends on universe)
7. tides (depends on universe, geo-planet)
8. gaming-universe (depends on universe)
9. gaming-environment-lighting (depends on gaming-universe)
```
Each package ships at `0.1.0` per genesis convention, with full 100% test coverage gate before publish.
---
## 13. Deferred (Out of Scope for This Spec)
- SurrealDB star storage schema and SurrealQL JS function bodies (`stars::cone_search`, `stars::apply_extinction`)
- Hipparcos, Tycho-2, Gaia catalog source implementations
- R3F / Three.js rendering integration for sky, terrain, planet textures
- Multiplayer synchronisation of game time across clients
- `@suaveplan/weather` integration with atmospheric lighting
The SurrealDB provider interface (`SurrealStarProviderOptions`, `createSurrealStarSource`) is locked in this spec — any future implementation must satisfy it.
---
## 14. Cross-Cutting Conventions
All packages in this family follow the monorepo standard:
- **Logging:** `@suaveplan/logger` + `@opentelemetry/api` together — OTel spans carry trace IDs; logger emits structured entries correlated to those spans. Neither is used without the other at public entrypoints.
- **Retries:** `@suaveplan/retry` (circuit breaker + jittered backoff) wraps all `fetchFn` parameters that hit external APIs (ADMIRALTY, Hipparcos, Tycho, Gaia).
- **Caching:** `@suaveplan/cache` is the canonical `cacheFn` adapter for read-mostly data (star catalog queries, tide tables). Consumers wire it in; packages don't import it directly.
- **Dates:** All public APIs return `Date` (UTC). Consumers needing timezone-aware values use `@suaveplan/datetime`.
- **ECS:** Component shapes in `gaming-environment-lighting` are plain TypeScript interfaces — no `@suaveplan/ecs` dependency. ECS consumers map fields to their own component storage.
## 15. Acceptance Criteria
Each package is complete when:
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/<pkg>` green
- [ ] `bunx turbo test:coverage --filter=@suaveplan/<pkg>` at 100%
- [ ] Every exported symbol has a co-located `.md`
- [ ] No `TODO`, `FIXME`, `any`, `ts-ignore`, or `.skip` in `src/`
- [ ] Zod schemas for all public option types
- [ ] `@suaveplan/logger` + OTel spans + counters on all public entrypoints
- [ ] `AbortSignal` on all async methods
- [ ] OpenSpec change proposal created and archived on ship

File diff suppressed because it is too large Load Diff