What actually grows
A survey of where the database's bytes are, and which tables will decide when
the volume fills. Measured rather than guessed — node scripts/measure-database-storage.mjs
reproduces all of it (through the SSH tunnel; see
oracle-shift-guide.md).
The headline
The whole database is 3.5 MB across 42 tables. There is no storage problem
today, and per-table "bytes per row" figures at this size are mostly page slack:
papers reports ~4 kB/row for 78 rows because a table with three indexes cannot
occupy less than a handful of 8 kB pages, whatever is in it.
So the useful question is not what is big — nothing is — but what grows
without bound, because that is what decides when the 50 GB pgdata volume
runs out. OCI Always Free caps every volume at 200 GB combined, so there is
nowhere to grow into.
The three shapes of table
| Shape | Grows with | Examples |
|---|---|---|
| Fixed | the size of the workspace | papers, vault_pages, profiles, projects |
| User effort | how much work someone does | reader_annotations, report_sections, milestones |
| Usage | how often the product is used | experiment_metrics, crdt_updates, ai_mcp_relay_requests, ai_audit_records |
Only the third shape matters. A workspace has as many papers as the researcher has papers; it has as many metric points as their training loop chose to log, which is unbounded and unrelated to anything a person typed.
Usage-shaped tables, and what bounds each
| Table | Bounded by | Status |
|---|---|---|
experiment_metrics |
downsampling on write, then chunked storage | Done — see the plan. 448.7 → 22.5 B/point, and a run's footprint is now logarithmic in its length |
ai_mcp_relay_requests |
purge_ai_mcp_relay_requests(), swept from the claim RPC |
Done — migration 0116 |
share_link_rate_limits |
rows older than 2 hours deleted inside check_share_link_rate |
Bounded since 0049 |
crdt_updates |
compaction to a snapshot once a document's tail passes COMPACT_THRESHOLD |
Bounded — tracks document size, not edit count |
ai_audit_records |
nothing | Unbounded. One row per accepted/rejected AI proposal. Deliberate: it is an audit trail, and deleting it defeats the point. Small per row and gated on a human decision, so it grows at human speed |
blob_objects |
the blobs it describes | Bounded by storage, which has its own tiering |
ai_mcp_relay_requests — the one that was missed
Worth recording because of how it hid. Its own schema comment called it
"Short-lived opaque MCP relay envelopes", and expires_at was honoured — but
only as a read-time verdict: the API reported status: 'expired' for a stale
row and left it in place. Nothing ever deleted anything.
Measured before the fix: 71 rows, 232 kB, of which 136 kB TOAST — about 3.3 kB
per row, one row per MCP tool call, retained since the feature shipped three
weeks earlier. After 0116 swept it: 248 kB → 32 kB.
The lesson generalises: a table can be documented as ephemeral, honour its own expiry, and still never delete a row. An expiry column is not retention.
reader_annotations — effort-shaped, until ink arrived
Annotations are effort-shaped: one row per thing a person deliberately marked. Ink breaks that assumption, because the row's size is set by the input device rather than by the person. A stylus samples at 120–240 Hz, so a two-second stroke captured raw is ~360 points — 13.3 kB of JSON for one pen stroke — and a page of handwriting is megabytes. Nothing deletes it, and nothing should: these are the user's notes.
So the bound is applied at the point of capture instead, in
packages/core/src/reader/ink-stroke.ts:
| Budget | Mechanism | Effect |
|---|---|---|
| Sampling | points closer than 0.75 pt to the previous one are never recorded | drops the samples that land inside the nib |
| Shape | Ramer–Douglas–Peucker at 0.35 pt before the write | a typical stroke loses 80–90% of its points and no visible detail |
| Precision | coordinates rounded to 2 dp | a PDF point is ~0.35 mm; the digits below this were float noise |
| Per stroke | hard cap of 400 points, reached by simplifying harder | no single stroke is unbounded, whatever the device does |
| Per mark | strokes drawn together join one annotation, up to 64 | a handwritten sentence is one row, not twenty — and one row's per-row overhead, not twenty |
Measured on that same two-second stroke: 360 captured samples → 188 after the sampling filter → 33 stored points, 446 B — a 30× reduction, with the same mark on screen. Pressure is the one thing deliberately not stored per point — it would add a third number to every sample, and Zotero's ink annotation has nowhere to put it; one width per stroke gets the same visual result for a constant 8 bytes.
Indexes
Indexes are 59% of the database, and 57 of them have never been scanned
(840 kB). That number is a hint, not a verdict: on a young deployment "never
scanned" usually means "no query has taken that path yet", and several of them
exist for RLS policies or foreign keys where the cost of not having one is a
sequential scan on every delete. They are left alone deliberately. Revisit when
the database is large enough for idx_scan = 0 to mean something.
When to look again
The measurement script prints bytes per row and flags never-scanned indexes. Worth re-running when any usage-shaped table passes ~100 MB, or when adding a table that grows per event rather than per object — at which point the question to ask is the one this page exists to answer: what deletes these?