Why Postgres, what the schema looks like, where bytes live.
Every piece of vectorless state lives in exactly one of three places:
- Postgres — structured state. Documents, sections, lifecycle, metadata, tree summaries. The database of record.
- Object storage (S3-compatible) — raw bytes. Original uploaded documents, section content that is too big for a DB row.
- Queue — transient work items. Ingest jobs. Never the source of truth for anything.
If it doesn't fit one of these three, we don't store it. This keeps the deployment model simple: one database, one bucket, one queue.
The tree looks like a document-store use case — it's hierarchical, you'd think MongoDB or Firestore is the natural fit. Resist this instinct.
80% of what we actually query is not the tree body, it's the metadata around it:
- "list documents with status=ready, newest first, paginated" — relational query.
- "count documents per tenant for billing" — aggregate.
- "find sections whose summary contains these keywords" — full-text search.
- "cascade-delete everything when a document is deleted" — referential integrity.
Every one of those is free in Postgres and annoying in NoSQL stores. And we do not have the one problem that forces NoSQL: we are not doing petabyte-scale random-key lookups against a schema-less store.
Postgres + JSONB gives us the "document store feel" where we want it (per-doc snapshots, flexible metadata) without leaving the relational world. One database, one set of operational patterns.
id text PRIMARY KEY -- "doc_<uuid>"
title text NOT NULL
content_type text NOT NULL -- "application/pdf", "text/markdown", ...
source_ref text NOT NULL -- key into object storage
status text NOT NULL -- pending | parsing | summarizing | ready | failed
error_message text NOT NULL DEFAULT ''
byte_size bigint NOT NULL DEFAULT 0
metadata jsonb NOT NULL DEFAULT '{}' -- free-form per-doc metadata
tree_snapshot jsonb -- (planned) denormalised tree.View
created_at timestamptz NOT NULL DEFAULT now()
updated_at timestamptz NOT NULL DEFAULT now()
Indexes:
- PK on
id. (status, created_at DESC)for the list endpoint.- GIN on
metadatafor future JSONB queries.
Lifecycle: pending -> parsing -> summarizing -> ready on the
happy path, -> failed on any stage failure (with error_message
populated).
id text PRIMARY KEY -- "sec_<uuid>"
document_id text NOT NULL REFERENCES documents(id) ON DELETE CASCADE
parent_id text REFERENCES sections(id) ON DELETE CASCADE -- nullable for root
ordinal int NOT NULL -- position among siblings
depth int NOT NULL -- 0 for root
title text NOT NULL
summary text NOT NULL DEFAULT '' -- LLM-generated or excerpt fallback
content_ref text NOT NULL DEFAULT '' -- key into object storage for full text
token_count int NOT NULL DEFAULT 0
metadata jsonb NOT NULL DEFAULT '{}'
created_at timestamptz NOT NULL DEFAULT now()
updated_at timestamptz NOT NULL DEFAULT now()
Indexes:
- PK on
id. (document_id, depth, ordinal)for tree load ordering.(parent_id)for children lookup.
Self-referential via parent_id. CASCADE DELETE means removing a
document cleans up all its sections automatically.
Standard migrations tracking table. Engine applies embedded SQL files
from internal/db/migrations/*.sql at boot in ID order. Idempotent.
- Raw document bytes. Goes into object storage at
documents/<doc_id>/source/<filename>. - Full section content. Also object storage, at
documents/<doc_id>/sections/<section_id>.txt. Postgres gets only thecontent_refkey. - Vector embeddings. None exist. By design.
- User accounts, billing, org data. Control plane's database, not the engine's.
Planned, not shipped yet. Idea:
After ingestion completes, the engine computes the full tree.View
(the compact representation used for LLM reasoning) and writes it to
documents.tree_snapshot as JSONB.
At query time, LoadTree becomes a single-row read instead of a
recursive sections walk. Saves one Postgres round-trip and all the
ORM-style reconstruction that goes with it.
Trade-off: the snapshot goes stale if someone edits sections directly. We solve that by only writing the snapshot at the end of ingest (when everything is consistent) and invalidating it on any re-ingest. Since we don't support "edit a section" as a public API, staleness is a non-issue.
When this lands, it's a migration + a write at the end of the ingest pipeline. Non-breaking.
<bucket>/
documents/
<doc_id>/
source/
<original_filename> <-- raw uploaded bytes
sections/
<sec_id_1>.txt
<sec_id_2>.txt
...
- One prefix per document. Deleting a document is one
DELETEin Postgres (cascades to sections) plus one prefix delete in storage. - Text-only section files. If future ingest pipelines output other
shapes (parsed AST, syntax-highlighted HTML), put them under
additional subdirectories (
sections/ast/<id>.json, etc.). - Content-type is stored per object for HTTP serving.
type Storage interface {
Put(ctx, key, io.Reader, Metadata) error
Get(ctx, key) (io.ReadCloser, Metadata, error)
Delete(ctx, key) error
DeletePrefix(ctx, prefix) error
SignedURL(ctx, key, ttl) (string, error) // optional, nil-error if unsupported
}Drivers:
- Local — filesystem, for dev.
- S3-compatible — AWS S3, Cloudflare R2, MinIO, Backblaze B2, Google Cloud Storage (via S3 interop), DigitalOcean Spaces. One driver, many endpoints.
- GCS / Azure — optional, add only when a user asks.
Queue is transient. Failing jobs retry; succeeding jobs are discarded.
type Queue interface {
Enqueue(ctx, Job) error
Register(kind Kind, handler Handler)
Start(ctx) error
Close() error
}ingest_document— run the parse -> persist -> summarise pipeline for a newly-uploaded document.- (future)
reingest_document,compact_tree,warm_cache.
- River (default) — Postgres-backed. Same DB as the data plane, one fewer service to run, ACID semantics for enqueue.
- Asynq — Redis-backed. Higher throughput, needs Redis.
- QStash — HTTP-based. Good for serverless deploys on Cloudflare Workers or Vercel where Postgres and Redis aren't always available.
- SQL, not an ORM migration DSL. Raw SQL is the forever-language.
- Embedded into the binary via
//go:embed migrations/*.sql. No separate migration tool to install. - Applied automatically at boot, tracked in
schema_migrations. Idempotent. - One-way. Down migrations are a 2005 practice; in 2026, forward-only plus a restore-from-backup plan is how grown-up services work.
0001_init.up.sql
0002_add_tree_snapshot.up.sql
0003_add_metadata_gin.up.sql
Numeric prefix for ordering, descriptive slug. No timestamps — branches should merge cleanly; renumber if two migrations land with the same prefix.
All migrations must be backwards-compatible with the previous engine version for the duration of a rolling deploy:
- Adding a column with a default: safe.
- Adding an index: usually safe;
CREATE INDEX CONCURRENTLYfor large tables. - Adding a NOT-NULL column: two migrations — (1) add nullable with default, backfill, (2) next release adds NOT NULL constraint.
- Dropping a column: two releases — (1) stop writing to it, (2) remove it in the next release.
This discipline means zero-downtime deploys, always.
When retrieval needs keyword search as a hint layer alongside LLM reasoning:
- Add a
summary_tsv tsvectorgenerated column onsections. - GIN index on
summary_tsv. - Use
plainto_tsqueryfor user queries.
This stays in Postgres. No Elasticsearch, no Meilisearch, no specialised engine. Postgres full-text gets us 80% of the way for 5% of the operational cost.
- Every engine write is wrapped in a transaction where multiple rows change atomically. Ingest: all sections written in one tx per document.
- Storage writes happen before DB writes. If the DB insert fails, the object is orphaned — cleaned up by a background reaper that looks for storage objects with no matching DB row. Eventual consistency, not lost data.
- Queue enqueue happens after the DB insert, in the same tx when using River (which makes this trivial — it's a SQL insert into a queue table). This gives us exactly-once enqueue semantics.
The engine has zero tenant concept. Every document belongs to whatever logical tenant the calling layer tracks.
In SaaS deploys, the control plane prefixes every document_id with
the org ID or stores the org mapping in its own database. The engine
neither knows nor cares — it's just a key to it.
This means:
- No
org_idcolumns in the engine schema. - No row-level security policies.
- No "did this user ingest this document" checks — the control plane authorised the call before the engine saw it.
If this becomes painful (e.g. the control plane wants to enforce
org-scoped queries at the DB level for defence-in-depth), we add an
optional tenant_id column and a tenant-scoped connection pool.
Not before.
- Hot / cold storage split. Old documents could move to cheaper storage (S3 Glacier, R2's infrequent-access tier). Worth it only at scale.
- Per-section versioning. Re-ingesting a document today blows away old sections. Stable IDs for unchanged sections + version rows for changed ones would let us cite "section X as of date Y." Deferred to Phase 4.
- Cross-region replication. If SaaS goes multi-region, the engine
DB and storage need a replication strategy. Logical replication
- S3 cross-region replication is the baseline.
- ENGINE.md — what produces and consumes this data.
- ARCHITECTURE.md — where the data layer sits.
- DEPLOYMENT.md — which managed services host the DB, bucket, queue.