Capture, files and backup#
Capture never waits: a phone saves to its own queue first, the server saves a file and a few rows, and everything slow (Claude, previews) happens later. Files are kept by content hash, shown by being embedded in page text, and deleted only when nothing uses them.
Colours mean the same on every architecture page: blue is stored data, orange is Claude work, green is the user. The rest of the system is on the architecture page.
The capture path#
The PWA queues every capture in IndexedDB and sends it when it can. The server answers without calling Claude, so a note is searchable the moment it is stored.
sequenceDiagram
actor U as User
participant Q as PWA queue (IndexedDB)
participant SW as Service worker
participant API as scrap API
participant S as Store (SQLite, files/)
participant J as Job queue
participant W as Worker
U->>Q: save (ULID minted)
Q-->>U: shown as queued
alt Android share sheet
U->>SW: POST /share
SW->>Q: queue like any capture
SW-->>U: 303 redirect to /
Note over API: POST /share saves it directly<br/>only if the service worker did not
end
loop online, with backoff on network or 5xx errors
Q->>API: POST /api/captures
API->>S: write files, then one transaction
API->>J: enqueue ingest / enrich jobs
API-->>Q: 201 (200 if the id was seen)
Q->>Q: delete local copy
end
J-->>W: claim job (only if SCRAP_INGEST=on)
W->>S: derived text, title, tags
Queue (
web/src/queue.ts,db.ts). The item keeps its ULID, so a retry is harmless. Network errors,408,429and5xxretry with backoff (2 s doubling, capped at 5 min). Other4xxanswers mark the item failed until the user taps Retry. AWeb Lockslock stops two tabs sending the same item.Share target (
web/src/sw.ts). The worker queues the share POST in IndexedDB. If storage refuses it, the request goes on to the server’sPOST /share, which lifts a bare URL out oftextand saves the capture. Android only; iOS would need a Shortcut posting to the API.Save is
Store.capture: files are written tofiles/first, then oneBEGIN IMMEDIATEtransaction.
Step in the transaction |
Notes |
|---|---|
look up capture id |
found: a retry, return |
|
|
|
page first (foreign key); a chosen kind sets |
|
the capture is the original, kept until its page is purged from the trash |
search chunk |
via |
|
photo captures |
|
notes and links, when enrich is on |
Nothing is lost. The original and the page exist before any job runs. A failed job shows on the page with its reason and Retry; a paused queue shows Resume.
Limits.
SCRAP_MAX_UPLOAD_MB(default 50) is checked fromContent-Lengthbefore reading the body. Over it is413and the item is marked failed.
One capture is one page#
Capture |
Body |
Title |
|---|---|---|
note |
the text |
first line (no leading |
link |
the URL, then any text |
the URL, or the first line of the text |
photo (any image; its images stored first, so ingest reads the page from one) |
text if any, then one image embed per image; other files listed below it |
text’s first line, else device time, which ingest replaces |
file (no image) |
text if any; the files listed below it |
text’s first line, else device time |
Claude then adds derived text, a better title, tags and a summary (Job queue and Claude features). Merging and linking pages is by hand or over MCP.
Images in the text#
A page shows an image only by embedding it in its Markdown. The blob store is addressed by SHA-256; the embed is the only link between a page and a photo’s display.
flowchart LR
up["Upload"]:::user --> blob[("Blob files/ab/sha256")]:::store
blob --> emb["Embed in page body"]:::store
emb --> prev["Preview JPEG"]:::store
cap["Capture row"]:::store --- blob
classDef store fill:#e8f1ff,stroke:#4a7bd0
classDef user fill:#e9f7ef,stroke:#2e9d5b
Embed form.
. A photo capture writes one per image, after its text, in order. Its text may instead place an upload with a token,⟦image N⟧or⟦file N⟧, when the request says so (place=1, the composer’s, #403): that upload’s embed, or a PDF’s or text file’s[name](/api/blobs/<sha>)link, goes there (placedinstore/helpers.py). From then on the text is the user’s: ingest never edits it, and reprocessing only adds derived versions.Previews (
preview.py) are JPEGs made on first request intocache/previews, decoded shrunk to bound memory. The cache is never backed up and can be deleted at any time; originals are never changed.In the PWA. An embed on a line of its own becomes a figure (tap for the viewer, caption with Download and Ask Claude); an embed inside a link, heading, list, quote or beside text stays a plain image. A file the text links on a line of its own is its row there; every other file (a PDF the text does not link, or links mid-sentence) is listed below the text. A PDF’s or text file’s text is read by scrap itself, with no model, so search finds it (Files: read locally, for search).
Deleted embeds. Deleting a file’s last embed from a page’s text does not detach it: the capture keeps it, so
scrap gcstill finds and gathers it as an orphan. The page stops showing it: its thumbnail,read, the searchimage_countandhas:image, andis:duplicateleave out a file whose address an earlier revision of the text had and the current one has not (GONEinstore/constants.py, asStore.removed). A file the text never mentioned stays listed. Restoring the text (a revert) shows the file again (#303).Add photo to an existing page is
POST /api/pages/{key}/captures(photos_api.py).Download and the export rewrite embeds and links to the files beside the Markdown, never inside code (
backup.localise).
Unused files#
scrap gc is a dry run unless given --delete. A file is in use while anything refers to it, and
what gc removes is mostly files nobody recorded.
flowchart LR
scan["Read all refs, one transaction"]:::store --> old{"Older than grace?"}
old -- no --> keep["Keep"]
old -- yes --> unref{"Referenced?"}
unref -- yes --> keep
unref -- no --> count["Count as unused"]:::store
count -- "--delete" --> rm["Rename aside, recheck, delete"]:::user
count -- "orphans" --> rev["Review page"]:::user
classDef store fill:#e8f1ff,stroke:#4a7bd0
classDef user fill:#e9f7ef,stroke:#2e9d5b
Reference that keeps a file |
Source |
|---|---|
the address |
|
a capture |
|
a derived row or an ingest job |
|
a model run |
its photo or |
Grace period
--grace-hours, default 24, protects uploads in flight: a capture writes files before it records them.put_blobtouches a file it finds already stored.Safe while serving. References are reread inside a write transaction, each file is renamed aside and its age checked again, then deleted with its previews. A run that stops mid-way leaves the file aside; the next run restores it first. If references cannot be read, nothing is deleted.
Old revisions are not references. Only each page’s current text counts.
Model runs (
model_run,enrich_run) are pruned too, so their stored prompts do not grow without bound; a run’s sent JPEG is then unused and goes in the same run.topic_runis not pruned.Retention first. With
--delete, the trash purge (pages in it more than--trash-days, topics aside), finished job pruning and run pruning commit in a transaction of their own before the references are read, so a file is never unlinked while they could roll back. A dry run does the same and rolls it back. See Data model and storage and ADR 30.Orphans. A capture’s file that no page shows any more is not deleted then.
--deletegathers it onto the month’s open todo Orphaned assets YYYY-MM (orphans.py, due in 7 days, a user page, capture sourcegc) with the embed, where it last was and Claude’s description. Deleting that page is the review; its files stay in the trash, where gc counts them as used, until the page is purged: then the files it showed that no page shows are taken off their captures and deleted. Nothing is stored for this: it is derived from page text and revisions. The monthly page rolls over once done, cancelled, trashed or 1 MB full.Scheduling. On a single server a nightly timer runs gc after the backup and deletes only if a completed backup (integrity checked,
COMPLETEmarker written) is under 26 hours old. See Deployment and operations.
Backup, restore and export#
flowchart LR
s[("Store")]:::store -- "scrap backup" --> b[("Backup dir + COMPLETE")]:::store
b -- "scrap restore" --> s
s -- "scrap export, Export everything" --> x["Markdown + files"]:::user
classDef store fill:#e8f1ff,stroke:#4a7bd0
classDef user fill:#e9f7ef,stroke:#2e9d5b
Backup, restore, export (backup.py, export.py; Deployment and operations):
backuptakes an online SQLite snapshot, then copiesfiles/, safe while serving. Blobs are written before the rows that name them, so the copy always holds every blob in the snapshot. The snapshot is integrity-checked before theCOMPLETEmarker is written.restorechecks integrity and that the schema is not newer than the code, works from read-only media, and refuses a non-empty data folder without--force. The preview cache is never backed up.exportwrites one Markdown file per page with its files beside it, so the data outlives scrap: a folder (scrap export) or a zip streamed fromGET /api/store/download, the same files either way, laid out as an Obsidian vault (Export format). A page’s file holds only the owner’s text and data; what a model wrote about it goes in_claude/<slug>.md, so the model’s words are never mistaken for the owner’s (20. Text enrichment: Claude tags, summarises and relates text pages, apart from the text, 27. Claude’s write-ups of boards, apart from the text). The pages are read in one transaction, then the files are copied or streamed a chunk at a time.
Invariants#
A capture is saved with no LLM call and no network beyond the upload itself.
A capture id is a client-minted ULID: sending twice stores once.
Originals (blobs and captures) are never changed and are purged only with their page after N days in the trash (or, for an orphan, with the review page that showed it); everything else is derived.
A blob is deleted only when no page text, capture, derived row, job or model run refers to it, and only once what freed it is committed.
Decisions and reference#
4. Originals are kept; everything else is derived, versioned or disposable
5. Capture never waits: client-minted ids, an offline queue, no LLM on the save path
6. A vanilla TypeScript PWA with a size budget and lazy chunks
14. Page text is Markdown, parsed by a library on both sides
15. Images are embedded in the page text; scrap gc is conservative
17. Import from a Thoth vault goes through Store and is idempotent
HTTP API and CLI for routes and commands; Capture from your phone (the PWA) for the capture screen.