Everything the desktop app knows lives in one SQLite file, surfsense.db, and in the files under data/; the job queues live apart in huey.db. The schema is a subset of the hosted SurfSense domain model, keeping the words users know (workspace, document, chunk, chat thread, artifact) and fixing the names that had drifted. The models are the source of truth, Alembic is the only thing that creates schema, and every revision is written by hand, because the database is one user's only copy.
Code: shared/db.py, shared/migrations.py, alembic/versions/, the models.py in each folder of modules/
Decisions: ADR 0005, ADR 0003, ADR 0006, ADR 0007, ADR 0036, ADR 0018
- Same domain language. Workspace, document, chunk, artifact, chat thread and chat message mean what they mean in hosted SurfSense.
- Subset. Tables and columns the desktop app does not ship are left out: users, memberships, connectors, Zero, the git knowledge base, LangGraph state.
- Fix on paste. Stale
new_*prefixes, redundant columns and JSONB status blobs become the local shapes below. - No auth. There is no users table, no memberships and no tokens.
- SQLite. One file, with JSON columns only where they earn their keep: document metadata, message content, artifact metadata.
- Queue mechanics stay in
huey.db. User-visible job state isdocuments.status, andplugin_runs.statusfor a plugin's run.
The conventions every table follows are in shared/db.py:
- Enums are text behind a CHECK constraint. SQLite has no enum type, so
text_enum()stores the values and names a CHECK that rejects anything else. - Constraints are named by a fixed naming convention, because SQLite lets them stay unnamed and Alembic's batch mode cannot drop what it cannot name.
- Timestamps are UTC wall time. SQLite keeps no offset; values are written in UTC and stamped UTC when read.
- Every connection turns foreign keys on, uses WAL and a 5-second busy timeout, loads sqlite-vec, and begins transactions with
BEGIN IMMEDIATE. - Every model is imported at startup (
import_models()), because relationships name their targets as strings.
| Concept | Hosted | Local | Notes |
|---|---|---|---|
| Workspace | workspaces |
workspaces |
keep |
| Document | documents |
documents |
keep |
| Chunk | chunks |
chunks |
keep |
| Chat thread | new_chat_threads |
chat_threads |
drop the stale new_ prefix |
| Chat message | new_chat_messages |
chat_messages |
drop the stale new_ prefix |
| Message to thread | thread_id |
chat_thread_id |
explicit on chat_messages and artifacts |
| Document status | JSONB {"state": ...} |
status text |
pending, processing, ready, failed, cancelled |
| Dedup key | unique_identifier_hash |
dedup_key |
a SHA-256 of the file's bytes, not of its name |
| Body text | content and source_markdown |
content |
one markdown body |
| Artifact sidecar | artifacts |
artifacts |
keep, in the ADR 0003 shape |
| Artifact metadata | a column named metadata |
artifact_metadata |
no alias |
| Column | Notes |
|---|---|
id, name, created_at, updated_at |
a name is 1 to 200 characters after trimming |
cloud_id |
nullable, unique: the hosted workspace an import came from, so re-importing the same bundle reuses the row (import.md) |
has_unkeyed_imported_threads |
true only when a message signature identifies chat history imported before hosted thread ids were retained; re-import claims a uniquely matching legacy thread and otherwise leaves that history untouched |
The API seeds one workspace, "My Workspace", at startup when none exists. Deleting a workspace first stops its plugin runs (stop_workspace_runs.py), then cascades its documents, threads, artifacts and runs.
| Column | Notes |
|---|---|
id, workspace_id |
foreign key to workspaces, cascading |
title |
at most 500 characters when set through the document routes |
document_type |
FILE (uploaded or imported), NOTE (written in the app), ARTIFACT (a Studio body) |
status |
pending by default; cancelled arrived in 0011 |
error_message |
why a job failed, at most 500 characters; cleared by retry and by a later success |
content |
the markdown body: extracted text, the note itself, or an artifact's body |
content_hash |
SHA-256 of a file's bytes, equal to dedup_key for an uploaded or imported file; 0025 copied it from dedup_key; null for notes and artifacts |
dedup_key |
SHA-256 of an uploaded or imported file's bytes; null for notes and artifacts |
document_metadata |
JSON: mime_type, size_bytes, suffix for files; import adds folder_path, source and the hosted ids; a recording's ingest adds transcription, {language, duration_s, model} once transcribed or {awaiting: true} while it waits for a speech model (documents.md) |
embedding_index_id |
foreign key to embedding_indexes: the index its chunks were embedded into; null until its first ingest |
folder_id |
foreign key to folders, ON DELETE SET NULL, from 0025: every FILE and NOTE is filed; null is an unfiled artifact, or a source the backstop unfiled, which scopes and the tree treat as the top of the Library |
created_at, updated_at |
documents_workspace indexes workspace_id and documents_folder indexes folder_id. documents_folder_dedup_key is unique on (workspace_id, folder_id, dedup_key) where dedup_key IS NOT NULL, so dedup is per folder: a copied folder keeps a file that already sits in another one, and rows without a key stay out of it. Routes and behaviour are in documents.md.
| Table | Columns | Notes |
|---|---|---|
source_roots |
id, workspace_id, kind, name, name_key, disk_path, state, created_at, updated_at |
kind is managed or linked; each workspace has one managed root, the Library (partial unique index), made by 0025 for existing workspaces and on first use otherwise. Linked roots are designed, not built (proposal); state already holds every value they will need, so adding one never rebuilds the table |
folders |
id, workspace_id, root_id, parent_id, name, name_key, disk_name, role, state, trash_kind, trashed_at, created_at, updated_at |
a root's own folder has no parent and is depth 0; Library folders go 1 to 8 deep. name_key is the name in NFC, case-folded in Python, unique among live siblings; role is one of evidence, target, playbook, library. state is ready, or deleting while a delete runs, which takes the subtree out of every scope at once; placeholder, trashed and trash_kind are for linked roots and the Trash, not built |
Both cascade with their workspace, and folders with its root and its parent.
| Column | Notes |
|---|---|
id, document_id |
foreign key to documents, cascading |
position |
order within the document; unique with document_id |
content |
the passage text |
embedding |
float32 BLOB, kept so the vector table can be rebuilt without re-embedding |
start_line, end_line |
the passage's line span in the markdown body, for citations |
| Table | Kind | Purpose |
|---|---|---|
chunks_fts |
FTS5, external content over chunks (content_rowid='id') |
the BM25 keyword leg; stores no text of its own |
chunk_vectors |
sqlite-vec vec0(embedding float[D]), rowid = chunks.id |
the nearest-neighbour leg; named by the active row of embedding_indexes |
Three triggers on chunks keep the keyword index in step. chunks_after_insert adds the text to chunks_fts; chunks_after_update deletes the old text and adds the new; chunks_after_delete deletes the text from chunks_fts and the row from chunk_vectors. An external-content table keeps no copy, so a delete must hand it the old text or it goes on matching. The triggers fire on cascade too, which is how every real delete arrives, since the user removes a document or a workspace, never a chunk. Nothing else could reach these tables: a virtual table takes no foreign key.
Ingest writes the chunk_vectors row itself, because only ingest holds the vector. 0001 creates the table at 384, bge-small-en-v1.5's width; on a fresh install, finishing onboarding drops the still empty table and creates it again at the chosen model's width (lock.py), the one place outside migrations that emits DDL. upgrade_to_head() reads the declared width back from sqlite_master and refuses to start when it differs from the active index's spec: vectors from another model are unrelated numbers, not merely the wrong shape. How the two legs are queried is in search.md.
| Column | Notes |
|---|---|
id |
|
spec |
JSON snapshot of the embedder's spec: repo, pinned revision and file hashes, width, pooling, normalisation, prefixes, max_tokens, semantic_weight. A later edit to the app's copy of a spec cannot change what an existing index means |
vector_table |
the vec0 table holding this index's vectors; checked against a fixed name pattern before it reaches SQL |
state |
active; building and retired are reserved for changing the model, which is not built |
created_at |
One active row, enforced in code rather than by a singleton constraint, because a second row is how changing the model would start (proposal). A fresh install has no row until onboarding finishes; 0022 writes a bge-small row for a library that already existed, pointing at its chunk_vectors, and stamps every document with chunks. Nothing is re-embedded.
| Table | Columns | Notes |
|---|---|---|
chat_threads |
id, workspace_id, cloud_id, title, opencode_session_id, source_scope, history_start_message_id, created_at, updated_at |
cloud_id is the nullable, unique hosted thread id. title is nullable; the API defaults it to "New chat". opencode_session_id, from 0021, is set on a thread the agent answers, whose turns live in that opencode session rather than in chat_messages (chat.md). source_scope, JSON from 0024, holds the sources ticked for the thread; null means every source (chat.md). history_start_message_id, from 0026, is the oldest message the model is still sent once history was trimmed, and NULL until it first was (chat.md) |
chat_messages |
id, chat_thread_id, role, content, created_at, completed_at |
role is user, assistant or system; completed_at arrived in 0002 |
content is JSON: {"text"} for a user turn, plus images: [{"key", "mime", "size_bytes", "sha256"}] when it carried any, and source_scope with resolved: {"count", "ids_sha256"} when it was grounded on a scope, and {"text", "citations"} for an assistant turn, whose text carries [citation:<chunk_id>] markers. The server reads text to build the model's history, and a user turn's images for the newest turn that carried some; the citations are for the UI. Imported user turns also carry an empty citations list. Messages are indexed on (chat_thread_id, created_at) and cascade with their thread. Visibility, authorship, cloning, turn ids, token usage and LangGraph checkpoints are left out. See chat.md.
An artifact's searchable body is a Document with document_type = ARTIFACT; artifacts is a sidecar that owns no title, path, body or indexing state (ADR 0003). The tables shipped in 0001.
| Column | Notes |
|---|---|
document_id |
foreign key, unique and cascading: one sidecar per document |
workspace_id |
foreign key, cascading |
chat_thread_id |
foreign key, set null: clearing a chat must not delete what it produced; set on each version the agent renders |
format |
text, not an enum |
generation |
integer, CHECK (generation > 0), bumped by each regenerate |
created_by_tool_call_id, updated_by_tool_call_id |
provenance; a REST job passes none |
artifact_metadata |
JSON: the source ids, prompt and options the job was created with, the source_scope it was resolved from and the grounded_document_ids that reached the model, quiz or flashcard progress, and for a Word document or PDF its spec, version and recipe (studio.md); for a revised copy of the user's file its revision, which also records the clean and external Word files kept beside the primary in the artifact's folder, since artifact_files.role allows only primary and preview (studio.md) |
artifacts has no status column; its status is its document's. artifact_files keeps one immutable blob per role: role (primary or preview), storage_key (the path relative to the data directory), original_filename, mime_type, size_bytes (CHECK > 0) and checksum_sha256, unique on (artifact_id, role) and on storage_key. There is no storage_backend column, since there is one backend. See studio.md.
| Table | Columns | Notes |
|---|---|---|
provider_connections |
id, label, provider, base_url, catalog_provider, api_key_ciphertext, timestamps, and since 0023 auth_kind, oauth_ciphertext and token_version |
label unique case-insensitively; provider must be openai_compatible; catalog_provider is a remote manifest provider id or custom, since 0014; the key is Fernet ciphertext since 0007; since 0023, auth_kind is api_key or chatgpt, oauth_ciphertext the Fernet-encrypted token set of a chatgpt row (NULL when signed out), and token_version the counter that keeps refreshes from racing (chatgpt-subscription.md) |
selected_models |
model_type, provider, connection_id, name, params_b, vendor, line, settings, updated_at |
one row per model type: text_gen, image_gen, image_edit, video_gen, audio_gen or speech_to_text; settings is JSON for what the user set that no endpoint states, keyed by the slice that owns each entry (a server voice model's voices, the speech model's transcription language), and cleared when the slot takes another model, all but chat_modes: the mode last picked for each model, keyed <provider>/<connection id>/<name>, which stays so a model's pick comes back with it (model capabilities) |
onboarding_completion |
id, completed_at |
a singleton (CHECK id = 1) whose presence means onboarding is done |
- A CHECK on
selected_modelsallowsllamacpp,sdcppandaudiocpponly without a connection andopenai_compatibleonly with one, and a second,local_runtime_type, letsllamacpphold onlytext_gen,sdcpponlyimage_gen,image_editandvideo_gen, andaudiocpponlyaudio_genandspeech_to_text. No CHECK keeps a connection out ofspeech_to_text; selection does (selection).connection_idcascades, so deleting a connection clears exactly the selections that used it. params_b,vendorandline(flagshiporsmall) are the model's fingerprint, recorded when it is chosen. They feed the prompt tier, which is computed on read, so retuning a threshold needs no migration.- Choosing a model never writes
onboarding_completion;POST /llm/onboardingdoes, once atext_genselection exists. - Remote
/modelsanswers, the local catalog, hardware profiles and fit estimates are not stored; they are recomputed or fetched live.
Connections are in connections.md; selection and onboarding in local-models/selection.md.
| Table | Columns | Notes |
|---|---|---|
license_state |
id, certificate, imported_at, clock_watermark |
a singleton; plan and expiry are re-derived from the certificate on every read, and clock_watermark is the highest instant ever seen |
egress_destinations |
destination, enabled, last_call_at |
one row per host; enabled defaults to false |
A destination is host:<hostname>: host:huggingface.co for model search and downloads, and one per remote host, shared by every connection to it; a loopback endpoint needs none. Revision 0027 removes rows under the earlier names model_download, model_search and image_model_pull, carrying a grant to host:huggingface.co only where search and a download were both allowed and the host had no answer of its own. See license/app.md and egress.md.
One row per run of a plugin's action. What the run produced is not here: the plugin wrote it through the API while it ran (the plugins proposal).
| Column | Notes |
|---|---|
id, workspace_id |
foreign key to workspaces, cascading: a run goes with the workspace it was started in |
plugin_id, version, action |
which installed version ran, and which of its actions |
inputs |
JSON: what the user gave the action |
status |
queued by default, then running, then succeeded or failed; cancelled from either of the first two, when the user cancels or deletes the workspace |
error |
why a run failed: exit <code> when the plugin's process ended with anything but 0, timeout when its action's timeout_seconds ran out, interrupted when the app quit during it |
log_tail |
the last 16 KiB the plugin printed, stdout and stderr together |
created_at, started_at, finished_at |
<data dir>/ ~/.surfsense by default
├── surfsense.db
├── backups/<from>-<to>.db the database as it was before a migration; the newest two are kept
├── huey.db three queues, ingest, studio and plugins, in one file
└── data/
└── workspaces/<workspace_id>/
├── documents/<document_id>/
│ ├── <file name> an uploaded or imported file, under its own name
│ ├── figures/ the pictures ingest kept, and figures.json
│ │ └── work/ the runs a figures-only pass that gave way resumes from
│ ├── docling/work/ a long Docling reading's batches, until it ends
│ └── transcript/ a recording's: segments.json once transcribed;
│ └── work/ the decoded audio, plan and windows a job resumes from
├── chats/<thread_id>/
│ └── <sha256>.<ext> an image a turn carried, normalised to PNG or JPEG
├── artifacts/<artifact_id>/
│ └── primary.<ext> the rendered file; a preview would sit beside it
└── agent/ the agent's working files (hosts with opencode staged)
├── text/<document_id>-<sha16>.md one per version of a source's text
└── threads/<thread_id>/ an agent thread's sources/ and outputs/
Directories are keyed by row id. An upload keeps its sanitized filename inside its document's directory, and no row stores that name: the directory holds the one file (documents.md). Directories written earlier hold original.<ext> and an unread extracted.md, and are left as they are. An artifact's files are named by role, with an extension when the MIME type is one the Studio worker knows. A chat image is named by its content hash, so one picture attached twice in a thread is one file (chat.md). Deleting a workspace removes its whole directory after the commit, and deleting a document, an artifact or a thread removes its own directory, an agent thread's folder under agent/threads/ included. agent/text/ holds no row's file: each thread's sources/ hard-links into it, and a file no thread links is swept (agent.md). The rest of the data directory is described in overview.md.
erDiagram
workspaces ||--o{ documents : contains
workspaces ||--o{ chat_threads : contains
workspaces ||--o{ artifacts : contains
workspaces ||--o{ plugin_runs : contains
documents ||--o{ chunks : "split into"
documents ||--o| artifacts : "body of"
chunks ||--|| chunks_fts : "rowid, by trigger"
chunks ||--|| chunk_vectors : "rowid, by ingest"
embedding_indexes |o--o{ documents : "embedded"
chat_threads ||--o{ chat_messages : contains
chat_threads |o--o{ artifacts : "produced, set null"
artifacts ||--o{ artifact_files : stores
provider_connections |o--o{ selected_models : "selected through"
workspaces {
int id PK
text name
int cloud_id UK
datetime created_at
datetime updated_at
}
documents {
int id PK
int workspace_id FK
text title
text document_type "FILE, NOTE, ARTIFACT"
text status "pending, processing, ready, failed, cancelled"
text error_message
text content
text content_hash
text dedup_key
json document_metadata
int embedding_index_id FK
datetime created_at
datetime updated_at
}
embedding_indexes {
int id PK
json spec
text vector_table
text state "active, building, retired"
datetime created_at
}
chunks {
int id PK
int document_id FK
int position
text content
blob embedding
int start_line
int end_line
}
chunks_fts {
text content "FTS5, external content"
}
chunk_vectors {
blob embedding "vec0, the active spec's width"
}
chat_threads {
int id PK
int workspace_id FK
text title
int history_start_message_id
datetime created_at
datetime updated_at
}
chat_messages {
int id PK
int chat_thread_id FK
text role
json content
datetime created_at
datetime completed_at
}
artifacts {
int id PK
int document_id FK, UK
int workspace_id FK
int chat_thread_id FK
text format
int generation
text created_by_tool_call_id
text updated_by_tool_call_id
json artifact_metadata
datetime created_at
datetime updated_at
}
artifact_files {
int id PK
int artifact_id FK
text role "primary, preview"
text storage_key UK
text original_filename
text mime_type
int size_bytes
text checksum_sha256
datetime created_at
datetime updated_at
}
provider_connections {
int id PK
text label UK
text provider
text base_url
text catalog_provider
blob api_key_ciphertext
datetime created_at
datetime updated_at
}
selected_models {
text model_type PK
text provider
int connection_id FK
text name
real params_b
text vendor
text line
json settings
datetime updated_at
}
onboarding_completion {
int id PK
datetime completed_at
}
license_state {
int id PK
text certificate
datetime imported_at
datetime clock_watermark
}
egress_destinations {
text destination PK
bool enabled
datetime last_call_at
}
plugin_runs {
int id PK
int workspace_id FK
text plugin_id
text version
text action
json inputs
text status "queued, running, succeeded, failed, cancelled"
text error
text log_tail
datetime created_at
datetime started_at
datetime finished_at
}
| Revision | File | Change |
|---|---|---|
0001 |
0001_initial_schema.py |
workspaces, documents, chunks with chunks_fts, chunk_vectors and their triggers, chat threads and messages, artifacts and artifact files, a generation-only selected_models, provider_credentials |
0002 |
0002_add_chat_message_completed_at.py |
chat_messages.completed_at |
0003 |
0003_add_onboarding_completion.py |
onboarding_completion |
0004 |
0004_openai_compatible_connections.py |
provider_connections; selected_models rebuilt with connection_id, the image_generation role and the cascading foreign key, keeping only Ollama generation selections; provider_credentials dropped |
0005 |
0005_workspace_cloud_id.py |
workspaces.cloud_id, unique |
0006 |
0006_license_state.py |
license_state |
0007 |
0007_encrypt_provider_api_keys.py |
plaintext api_key dropped, api_key_ciphertext added |
0008 |
0008_egress_destinations.py |
egress_destinations |
0009 |
0009_local_image_provider.py |
selected_models rebuilt so sdcpp may hold a role without a connection |
0010 |
0010_selected_model_fingerprint.py |
params_b, vendor and line on selected_models |
0011 |
0011_document_cancelled_status.py |
cancelled added to documents.status |
0012 |
0012_llamacpp_provider.py |
selected_models rebuilt with llamacpp in place of ollama, clearing Ollama selections rather than remapping them; an ollama_pull egress grant becomes model_download |
0013 |
0013_selection_by_model_type.py |
selected_models rebuilt keyed by model_type: generation becomes text_gen and image_generation becomes image_gen, and the local_runtime_type CHECK is added; downgrading drops a selection in the three types the old key cannot hold |
0014 |
0014_connection_catalog_provider.py |
provider_connections.catalog_provider, custom for every existing connection; downgrading drops the column in place, because a table rebuild would cascade into selected_models |
0015 |
0015_image_selection_by_build.py |
a local image_gen selection is renamed from the old list's name to its curated build's id (stable-diffusion-1.5 to v1-5-pruned_Q4_0, and the two SDXL models); the map is frozen in the migration, and downgrading reverses it |
0016 |
0016_local_audio_provider.py |
selected_models rebuilt so audiocpp may hold audio_gen, and only that, without a connection; downgrading drops an audiocpp selection |
0017 |
0017_keyword_index_keeps_combining_marks.py |
the keyword index rebuilt from chunks with combining marks kept inside a word |
0018 |
0018_sdcpp_edit_and_video.py |
selected_models rebuilt so sdcpp may hold image_edit and video_gen; downgrading drops such a selection |
0019 |
0019_selection_settings.py |
selected_models.settings, a nullable JSON column added in place; its first entry is a server audio model's voices |
0020 |
0020_plugin_runs.py |
plugin_runs |
0022 |
0022_embedding_indexes.py |
embedding_indexes and documents.embedding_index_id; an existing library gets a bge-small row and its documents are stamped. The column is added by a plain ALTER TABLE, since a batch rebuild of documents would cascade to every chunk |
0024 |
0024_thread_source_scope.py |
chat_threads.source_scope, by a plain ALTER TABLE; no row is backfilled, so every older thread uses every source |
0025 |
0025_source_roots_and_folders.py |
source_roots and folders; a Library root and root folder per workspace; documents.folder_id by a plain ALTER TABLE; every FILE and NOTE filed, an imported folder_path becoming a chain of folders (case and normalization variants merged, levels past 8 joined with " / " into the eighth); content_hash filled from dedup_key; the dedup index swapped to per folder by DROP and CREATE INDEX |
0026 |
0026_chat_history_start.py |
chat_threads.history_start_message_id, by a plain ALTER TABLE; no row is backfilled, so a thread sends its whole history until it first outgrows its budget |
0027 |
0027_retire_pre_host_egress_grants.py |
egress rows under model_download, model_search and image_model_pull removed; host:huggingface.co allowed only where search and a download were both allowed and the host had no row; downgrading changes nothing |
0029 |
0029_chat_thread_cloud_id.py |
chat_threads.cloud_id, nullable and unique; workspaces.has_unkeyed_imported_threads, marking workspaces with older unkeyed imported threads |
0030 |
0030_speech_to_text_slot.py |
selected_models rebuilt so speech_to_text is a type and audiocpp may hold it beside audio_gen, carrying every column, 0019's settings included, which 0018's rebuild predates; downgrading drops a speech_to_text selection |
- Before the API applies a pending revision to an existing database,
upgrade_to_headwrites aVACUUM INTOcopy to<data>/backups/<from>-<to>.db(via.partial, renamed once complete) and keeps the newest two (migration_snapshot.py). A new database or one at head writes none. If the copy fails, nothing migrates and the API start fails with the reason. To restore: quit SurfSense, replacesurfsense.dbwith the snapshot, and start the app. - Migrations run on every API start and are idempotent. Autogenerate is off: it renders a rename as a drop plus an add, which deletes a column's data silently, and
env.pycarries notarget_metadata, so it cannot be used by accident. - SQLite cannot alter a CHECK constraint in place, so
0004,0009,0012,0013,0016,0018and0030copyselected_modelsinto a new table. A copy names every column the table has by then: one written from an older rebuild drops what later revisions added. - A revision that touches a table already holding rows should read the live schema first (
op.get_bind(),sa.inspect) rather than assume its shape. tests/integration/test_migrations.pyfails when the models and the migration history disagree, when a second upgrade is not a no-op, and when a failed migration leaves anything behind.test_migration_0012.py,test_migration_0013.py,test_migration_0014.py,test_migration_0027.py,test_migration_0029.pyandtest_migration_0030.pybeside it test what those revisions change.
- Deleting a folder is permanent: the Trash is designed, not built (proposal).
- The snapshot needs free disk about the size of
surfsense.db, and nothing checks for it first: when the copy fails, the API does not start and the app closes with no message (overview). - A release cannot open a database a later release migrated, since its migrations do not know the newer revision, so its API does not start. Going back a version means restoring the snapshot by hand, as going from 2.2.0 to 2.1.0 takes
backups/0023-0027.db.