Skip to content

Commit 79ad319

Browse files
feat(versioning): Figma-shaped diagram version history on Redis Stack with live collaboration (#657)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9c1bdaf commit 79ad319

103 files changed

Lines changed: 12554 additions & 1325 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docker/compose.app.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ services:
6666
- PORT=8000
6767
- WS_PORT=4444
6868
- REDIS_URL=redis://db:6379
69+
- OWNER_SECRET=${OWNER_SECRET:?OWNER_SECRET must be set}
6970
networks:
7071
- apollon-network
7172
read_only: true

docker/compose.db.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
services:
22
db:
3-
image: redis:7-alpine
3+
# Stack image is required: the server's RedisJSON gate refuses to boot
4+
# without the ReJSON module. `redis:7-alpine` ships without it and would
5+
# silently break versioning on a fresh deploy.
6+
image: redis/redis-stack-server:7.4.0-v8
47
restart: unless-stopped
58
expose:
69
- "6379"
710
volumes:
811
- dbdata:/data
912
networks:
1013
- apollon-network
11-
command: redis-server --appendonly yes --appendfsync everysec
14+
command:
15+
- redis-stack-server
16+
- --appendonly
17+
- "yes"
18+
- --appendfsync
19+
- everysec
1220
healthcheck:
1321
test: ["CMD", "redis-cli", "ping"]
1422
interval: 10s

docker/compose.local.db.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
services:
22
db:
3-
image: redis:7-alpine
3+
image: redis/redis-stack-server:7.4.0-v8
44
ports:
55
- "${REDIS_PORT:-6379}:6379"
66
volumes:
77
- dbdata:/data
8+
command:
9+
- redis-stack-server
10+
- --appendonly
11+
- "yes"
12+
- --appendfsync
13+
- everysec
814

915
volumes:
1016
dbdata:

docker/compose.local.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,25 @@ services:
2222
- PORT=8000
2323
- WS_PORT=4444
2424
- REDIS_URL=redis://db:6379
25+
- OWNER_SECRET=${OWNER_SECRET:-change-me-in-production}
2526
depends_on:
2627
db:
2728
condition: service_healthy
2829
networks:
2930
- apollon-local
3031

3132
db:
32-
image: redis:7-alpine
33+
image: redis/redis-stack-server:7.4.0-v8
3334
ports:
3435
- "6379:6379"
3536
volumes:
3637
- dbdata:/data
38+
command:
39+
- redis-stack-server
40+
- --appendonly
41+
- "yes"
42+
- --appendfsync
43+
- everysec
3744
networks:
3845
- apollon-local
3946
healthcheck:

docs/admin/legal-pages.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Apollon's privacy notice must cover, at minimum:
6767

6868
1. **Controller** (Art. 4(7) GDPR) — your legal identity and contact.
6969
2. **DPO** — name or office, per Art. 37 GDPR.
70-
3. **Processings** — list each, with purpose, legal basis (cite GDPR article + any national law), data categories, retention. For an out-of-the-box Apollon deployment this minimally includes: Redis-stored diagram content (120-day TTL) and WebSocket relay metadata (ephemeral). Operational events (startups, cert renewals, crit-level errors) contain no personal data by design — nginx runs with `access_log off;` and `error_log … crit;`, Traefik with `--accesslog=false`, and Express silent in production. If your fork re-enables per-request access logging at any layer, add that as an additional processing and document its retention.
70+
3. **Processings** — list each, with purpose, legal basis (cite GDPR article + any national law), data categories, retention. For an out-of-the-box Apollon deployment this minimally includes: Redis-stored diagram content (120-day TTL), Redis-stored version-history snapshots and metadata (same 120-day TTL, scoped per diagram, automatic FIFO eviction at 50 versions), and WebSocket relay metadata (ephemeral). Operational events (startups, cert renewals, crit-level errors) contain no personal data by design — nginx runs with `access_log off;` and `error_log … crit;`, Traefik with `--accesslog=false`, and Express silent in production. If your fork re-enables per-request access logging at any layer, add that as an additional processing and document its retention.
7171
4. **Recipients** — who else sees the data; "none" is a valid answer.
7272
5. **Third-country transfers** — state "none" if you host in the EU; otherwise cite adequacy (Art. 45 GDPR) or safeguards (Art. 46 GDPR).
7373
6. **Retention** — concrete time frames per category.

docs/admin/operations.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Operations
2+
3+
## Required infrastructure
4+
5+
- **Redis Stack** (`redis/redis-stack-server:7.4.0-v0` is the pinned
6+
test image; production `compose.db.yml` uses the same tag) — provides
7+
RedisJSON ≥ 2.0, required for HEAD storage. Plain `redis:7-alpine` /
8+
`redis:8-alpine` do not bundle the JSON module.
9+
- **Node.js ≥ 22.14**, **npm ≥ 11.1**.
10+
11+
The server checks `MODULE LIST` at startup and asserts ReJSON is loaded;
12+
on missing module the boot fails closed (process exits non-zero) rather
13+
than serving traffic with broken versioning.
14+
15+
## Environment variables
16+
17+
| Variable | Default | Purpose |
18+
|---|---|---|
19+
| `HOST` | `localhost` | Express bind host |
20+
| `PORT` | `8000` | Express HTTP port |
21+
| `WS_PORT` | `4444` | WebSocket relay port |
22+
| `CORS_ORIGIN` | unset | Allowed origin (omit to allow all) |
23+
| `REDIS_URL` | `redis://localhost:6379` | Redis connection |
24+
| `OWNER_SECRET` | dev placeholder | HMAC secret for soft owner cookie |
25+
| `MAX_VERSIONS_PER_DIAGRAM` | `50` | FIFO cap on version history |
26+
| `MAX_SNAPSHOT_BYTES` | `5242880` | Body parser hard limit (5 MiB) |
27+
| `MAX_DESCRIPTION_LENGTH` | `240` | Version description char limit |
28+
| `MAX_NAME_LENGTH` | `80` | Version name char limit |
29+
| `DIAGRAM_TTL_SECONDS` | `10368000` | HEAD TTL (120 d) |
30+
| `VERSION_TTL_SECONDS` | `10454400` | Version body+meta TTL (121 d) |
31+
| `AUTO_VERSION_INTERVAL_SECONDS` | `1800` | Marker TTL = auto-version cadence (30 min) |
32+
33+
## Durability
34+
35+
Redis is configured with:
36+
- `appendonly yes` — AOF on
37+
- `appendfsync everysec` — at most ~1s of writes can be lost on a hard
38+
crash. Applies uniformly to autosave PUTs and snapshot/restore commits;
39+
there is no per-route fsync gate.
40+
41+
For a stricter durability story, deploy a Redis replica and set
42+
`min-replicas-to-write 1` plus `min-replicas-max-lag 10`. The application
43+
does not call `WAIT` — that command requires configured replicas to
44+
provide any guarantee, and we deliberately don't claim a guarantee that
45+
isn't real.
46+
47+
Recommended additional ops:
48+
- RDB BGSAVE every 6 hours
49+
- Off-site backup of the `dbdata` volume nightly
50+
51+
## Single-replica WebSocket
52+
53+
The relay server is in-memory (`Map<diagramId, Set<WebSocket>>`). It is
54+
**not multi-replica safe** — clients on different replicas cannot see each
55+
other. Production deploys must:
56+
57+
1. Run a single relay replica behind a sticky load balancer, or
58+
2. Move to a Redis Pub/Sub adapter (deferred; tracked separately).
59+
60+
## Redis Functions lifecycle
61+
62+
The server boot-loads one Lua library named `apollon` containing three
63+
functions: `commit_snapshot`, `restore_version`, and
64+
`list_versions_before`. Verify with `redis-cli FUNCTION LIST` (the library
65+
name `apollon` should appear). Idempotent across restarts; the library
66+
survives Redis restart because Redis Functions are persisted to AOF/RDB.
67+
68+
## Migration: legacy STRING → RedisJSON
69+
70+
Pre-existing `diagram:{<id>}` keys stored as plain JSON STRINGs (legacy
71+
format) are upgraded by:
72+
73+
```bash
74+
npm run migrate:string-to-json --workspace=@tumaet/server
75+
```
76+
77+
The script:
78+
- Scans `diagram:*`
79+
- Skips any key that's already RedisJSON-typed
80+
- Reads STRING bodies, JSON.SETs them back, preserves TTL
81+
- Refuses to leave any STRING-typed `diagram:*` HEAD keys behind
82+
(boot-gate equivalent)
83+
84+
Idempotent. Safe to re-run.
85+
86+
## Observability
87+
88+
The server emits JSON logs via Pino. Every version event is structured:
89+
90+
```json
91+
{
92+
"level": 30,
93+
"event": "version.created",
94+
"diagramId": "...",
95+
"versionId": "01JKQ...",
96+
"kind": "user",
97+
"evictedVersionIds": [],
98+
"librarySchemaVersion": "4.4.0",
99+
"requestId": "...",
100+
"msg": "version.created"
101+
}
102+
```
103+
104+
`x-request-id` is propagated to all error response bodies — quote it when
105+
filing support tickets.
106+
107+
A Prometheus `/metrics` endpoint is **not** included in this release.
108+
Add `prom-client` and a `/metrics` route if a downstream SRE consumer
109+
requires it.
110+
111+
## See also
112+
113+
- [Version history (developer doc)](../development/versioning.md)
114+
- [Runbook](runbook.md)

docs/admin/runbook.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Apollon Standalone runbook
2+
3+
On-call reference for the most likely production issues. Pair with
4+
`docs/development/versioning.md` for architecture context.
5+
6+
## Health & quick triage
7+
8+
```bash
9+
# Liveness — Express up + Redis ping reachable.
10+
curl -fsS https://<host>/health
11+
# Returns 200 {"status":"ok"} or 503 on Redis trouble.
12+
```
13+
14+
The server logs are JSON via Pino; correlate by `requestId`:
15+
16+
```bash
17+
# Find every event for a given request:
18+
journalctl -u apollon-standalone -o cat | grep '"requestId":"<id>"'
19+
```
20+
21+
## Stuck or missing snapshot
22+
23+
Symptom: user clicked "Save version," POST returned 5xx, the version
24+
doesn't appear after refresh.
25+
26+
```bash
27+
# Inspect the diagram family.
28+
redis-cli SCAN 0 MATCH 'diagram:{<id>}*' COUNT 200
29+
redis-cli ZRANGE 'diagram:{<id>}:versions' 0 -1 WITHSCORES
30+
redis-cli HGETALL 'diagram:{<id>}:meta'
31+
```
32+
33+
If a version body exists at `diagram:{<id>}:version:<vid>` but is not in
34+
the ZSET index, **delete the orphan**:
35+
36+
```bash
37+
redis-cli DEL 'diagram:{<id>}:version:<vid>' 'diagram:{<id>}:version:<vid>:meta'
38+
```
39+
40+
If a ZSET entry has no body, drop it from the index:
41+
42+
```bash
43+
redis-cli ZREM 'diagram:{<id>}:versions' '<vid>'
44+
```
45+
46+
## Corrupt RedisJSON HEAD
47+
48+
Symptom: GET `/api/diagrams/:id` returns 500 INTERNAL with a JSON parse error
49+
in the logs.
50+
51+
```bash
52+
redis-cli JSON.GET 'diagram:{<id>}' '$'
53+
```
54+
55+
If the JSON is truly corrupt and there is no recoverable HEAD:
56+
57+
1. List versions: `redis-cli ZRANGE 'diagram:{<id>}:versions' 0 -1`
58+
2. Pick the most recent version with `ZREVRANGE`.
59+
3. Restore it via the API: `POST /api/diagrams/:id/versions/<vid>/restore`
60+
61+
## 503 spike
62+
63+
Symptom: `/health` flips to 503; client requests fail with
64+
`REDIS_UNAVAILABLE`.
65+
66+
1. `systemctl status apollon-standalone` — is Express up?
67+
2. `systemctl status redis-stack-server` — is Redis up?
68+
3. `redis-cli INFO replication` — is the primary writable?
69+
4. `redis-cli INFO persistence` — AOF rewrite stuck?
70+
5. `redis-cli FUNCTION LIST` — is the `apollon` library loaded? If not,
71+
restart the server (boot re-loads idempotently).
72+
73+
## OWNER_SECRET rotation
74+
75+
Rotating `OWNER_SECRET` invalidates **all** soft-ownership claims for
76+
existing diagrams. Users will see the confirm-twice friction prompt on
77+
destructive operations until they re-create the diagram.
78+
79+
Steps:
80+
1. Generate a new secret: `openssl rand -hex 32`
81+
2. Update the deployment env (`OWNER_SECRET=<new>`) and restart the server.
82+
3. Communicate the rotation to active users — destructive ops will now
83+
show "Heads up — this diagram was originally created in a different
84+
browser. Continue?"
85+
86+
## Backup posture
87+
88+
Apollon Standalone holds only ephemeral diagram data with a 120-day TTL.
89+
Users export-as-JSON for permanent local copies. AOF (`appendfsync everysec`)
90+
is enabled, so up to ~1s of writes can be lost on a hard crash. Snapshot
91+
and restore endpoints do not call `WAIT` — see `operations.md → Durability`
92+
for the rationale.
93+
94+
## Function reload
95+
96+
The server boot-loads the apollon Lua library via `FUNCTION LOAD REPLACE`.
97+
Verify with:
98+
99+
```bash
100+
redis-cli FUNCTION LIST
101+
# Expected library name: apollon
102+
# Expected functions: commit_snapshot, restore_version, list_versions_before
103+
```
104+
105+
If the library is missing, restart the server (boot-load is idempotent).
106+
107+
## Clearing all version data
108+
109+
For GDPR right-to-erasure on a single diagram:
110+
111+
```bash
112+
# Delete the entire family (HEAD + meta + versions + bodies).
113+
redis-cli --scan --pattern 'diagram:{<id>}*' | xargs -r redis-cli DEL
114+
```
115+
116+
The webapp's `DELETE /api/diagrams/:id` does this automatically.

0 commit comments

Comments
 (0)