Initial Commit
This commit is contained in:
2833
docs/superpowers/plans/2026-04-20-readline-cli.md
Normal file
2833
docs/superpowers/plans/2026-04-20-readline-cli.md
Normal file
File diff suppressed because it is too large
Load Diff
1561
docs/superpowers/plans/2026-04-20-utils-migration.md
Normal file
1561
docs/superpowers/plans/2026-04-20-utils-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
542
docs/superpowers/plans/2026-04-24-docker-infrastructure.md
Normal file
542
docs/superpowers/plans/2026-04-24-docker-infrastructure.md
Normal 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`.
|
||||
2988
docs/superpowers/plans/2026-04-24-testbed-foundation.md
Normal file
2988
docs/superpowers/plans/2026-04-24-testbed-foundation.md
Normal file
File diff suppressed because it is too large
Load Diff
2167
docs/superpowers/plans/2026-04-24-testbed-pages-and-e2e.md
Normal file
2167
docs/superpowers/plans/2026-04-24-testbed-pages-and-e2e.md
Normal file
File diff suppressed because it is too large
Load Diff
2254
docs/superpowers/plans/2026-04-24-testing-integration-core.md
Normal file
2254
docs/superpowers/plans/2026-04-24-testing-integration-core.md
Normal file
File diff suppressed because it is too large
Load Diff
1566
docs/superpowers/plans/2026-04-24-testing-integration-plugins.md
Normal file
1566
docs/superpowers/plans/2026-04-24-testing-integration-plugins.md
Normal file
File diff suppressed because it is too large
Load Diff
400
docs/superpowers/specs/2026-04-20-readline-cli-design.md
Normal file
400
docs/superpowers/specs/2026-04-20-readline-cli-design.md
Normal 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
|
||||
186
docs/superpowers/specs/2026-04-20-utils-migration-design.md
Normal file
186
docs/superpowers/specs/2026-04-20-utils-migration-design.md
Normal 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) |
|
||||
1276
docs/superpowers/specs/2026-04-24-enterprise-testbed-design.md
Normal file
1276
docs/superpowers/specs/2026-04-24-enterprise-testbed-design.md
Normal file
File diff suppressed because it is too large
Load Diff
135
docs/superpowers/specs/2026-04-25-test-suite-audit.md
Normal file
135
docs/superpowers/specs/2026-04-25-test-suite-audit.md
Normal 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`
|
||||
873
docs/superpowers/specs/2026-04-25-universe-family-design.md
Normal file
873
docs/superpowers/specs/2026-04-25-universe-family-design.md
Normal 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
|
||||
1493
docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md
Normal file
1493
docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user