ADR-0005: Where museum data lives
- Status: accepted 2026-08-02 — owner chose Render Postgres (he already runs a free instance and is happy to upgrade when it matters)
- Related: ADR-0004 (Lucas Account),
docs/SPACE-MUSEUM-DIRECTION.md§4, §10, execution plan §5
The question
The editor now produces real data — room layouts, which painting is on which face, owners, visibility, and later student artwork. Today it exports JSON and the owner commits it. That is fine for one person building the first rooms; it stops working the moment a class edits.
Multiplayer forces the issue too: a WebSocket server is a process, and a process wants a database near it.
What has to be stored
| Data | Shape | Size | Who writes |
|---|---|---|---|
| Room documents | one JSON doc per room (already roomDoc.js) | KBs | owners, via the editor |
| Ownership + visibility | emails, public/private, password hash | bytes | owners |
| Accounts / OTP | already lives in the Lucas Account Worker's own D1 | tiny | the auth service |
Student artwork sources (.lpp) and runtime packages (.lpr) | binary bundles | MBs each | students, later |
| Presence (who is in which room) | ephemeral | none | the WebSocket process |
Two very different things: small mutable documents and large immutable blobs. Do not put the blobs in the database.
Recommendation
One small Node service on Render — the same process that will hold the WebSocket rooms — serving a JSON API over Postgres, with object storage for blobs.
The browser build remains a separate Render static site. The owner reaffirmed this split on 2026-08-02 after comparing it with snake-lab's single-process deployment: this museum has substantially heavier images, Live Paintings, and 3D assets, so keeping them on the static CDN is worth the extra origin and CORS configuration. The split is a delivery optimization, not a WebSocket requirement. The Node service is named inception-space-api and will serve both /api/* and the future WebSocket endpoint. The site went live at https://is.lucasacademy.org on 2026-08-02 and remains cross-origin from https://inception-space-api.onrender.com; both the API and Lucas Account allowlists include the custom site origin. Cloudflare keeps the is CNAME DNS-only so Render remains the serving CDN and TLS endpoint.
- Render Postgres (owner's choice 2026-08-02): one bill, one dashboard, and it sits next to the process that will hold the WebSocket rooms. Free instances expire 30 days after creation and are deleted 14 days later (Render's documented behaviour), so the plan is to upgrade before that date — the owner is fine with the cost — and to keep
npm run rooms:exportcopies in git as insurance. Nothing in the schema is Render-specific: it is plain SQL with ajsonbcolumn, so moving to Neon or anywhere else later is a dump and a restore. - Cloudflare R2 for
.lpp/.lpr/ images: S3-compatible, no egress fees, and the museum can read blobs straight from a public bucket for published content while private drafts stay behind signed URLs.
This split is what keeps the database bill near zero. Render's flexible plans bill compute and storage separately: storage is $0.30 per GB per month, raisable at any time in multiples of 5 GB and never reducible (free databases get a fixed 1 GB). Both are "prorated to the second", which means the monthly rate is metered by how long the resource exists at that size — not by how much it is used. An idle database still bills every second; the only ways down are a smaller instance or no instance. Room documents are kilobytes, so a thousand rooms still fit in a fraction of that 1 GB. Put Live Paintings in Postgres instead — megabytes each — and a single class would push storage into gigabytes that can never be given back, while every backup got slower. Blobs belong in R2.
- Cloudflare D1 stays only inside the account service for OTP codes. Do not spread product data across two databases.
Sketch of the whole picture:
static museum (Render static site) ──fetch──► museum API + WS (Render, Node)
│ │
└── verifies nothing itself ├── Postgres (rooms, owners, visibility)
└── R2 (artwork blobs)
sign-in ──► Lucas Account (Cloudflare Worker, own D1, JWKS)
▲
the API verifies JWTs against that JWKS
Why this and not the alternatives:
- Everything on Cloudflare (D1 + Durable Objects) is cheaper and the auth service already lives there, but it puts the multiplayer server in a programming model the team has never used, while snake-lab already proves Node +
wson Render. The fixed cost is paid either way. - Git as the database (commit every edit) is tempting because it is free and reviewable, and it is exactly right while the owner is the only editor. It does not survive a class of students editing concurrently.
- Postgres for blobs would work and cost more; R2 exists precisely for this.
Consequences and the order to build it
- Keep the editor local-first (JSON export) until the API exists — today's state, nothing blocked.
- When the API lands: room documents move to Postgres, the editor saves over HTTP with its JWT, and the API re-checks ownership server-side.
- WebSocket presence joins the same process, reusing the same JWT check.
- Blobs go to R2 only when student artwork starts flowing (Phase 4+).
Private rooms need this API to be real
A password checked in the browser protects nothing: the room document, and therefore its contents, is already in the visitor's hands. AGENTS.md says this outright — a room password is not an adequate privacy boundary.
So: the editor may set a password now (stored as a salted hash, never in plain text), and the museum may use it as a courtesy gate, but a private room is only genuinely private once the API refuses to serve its document without proof — either a valid owner JWT or the room password exchanged for a short-lived token. Until then, private rooms must not hold anything that would embarrass anyone if it leaked, and the UI must say so.