|
| 1 | +-- External-backed saves: back the Saved list with a Semble/Margin collection. |
| 2 | +-- |
| 3 | +-- See docs/plans/EXTERNAL_BACKED_SAVES_PLAN.md. The read path is TWO STORES, not |
| 4 | +-- one (this is the safety property): an ENRICHMENT store (saved_articles) holding |
| 5 | +-- reading work, which a poll NEVER deletes from, and a MEMBERSHIP SNAPSHOT |
| 6 | +-- (backed_collection_members) replaced WHOLESALE from each provably-complete poll. |
| 7 | +-- The Saved set is membership ⋈ enrichment, joined on url_normalized. A botched |
| 8 | +-- poll can only stale the displayed membership (recovered next good poll); it can |
| 9 | +-- never strip a body or a highlight. |
| 10 | +-- |
| 11 | +-- Provider-agnostic by design: external_* are foreign at-uri HANDLES, so they hold |
| 12 | +-- a Semble card + collectionLink, OR a Margin community.lexicon.bookmarks.bookmark |
| 13 | +-- + collectionItem (which Phase 0 proved can live in a DIFFERENT repo than the |
| 14 | +-- collection owner — a plain TEXT at-uri stores either). |
| 15 | + |
| 16 | +-- (1) Enrichment store gains the cross-app JOIN KEY. normalizeArticleUrl(url) lives |
| 17 | +-- in feed-proxy today; Phase 1 ports a backend copy for the backfill + write |
| 18 | +-- paths. Existing rows stay NULL until backfilled; SQLite treats NULLs as |
| 19 | +-- distinct in a UNIQUE index, so the index does not collide on un-backfilled rows. |
| 20 | +ALTER TABLE saved_articles ADD COLUMN url_normalized TEXT; |
| 21 | +CREATE UNIQUE INDEX idx_saved_articles_dedup ON saved_articles(user_did, url_normalized); |
| 22 | + |
| 23 | +-- (2) Membership snapshot: the latest PROVABLY-COMPLETE listRecords snapshot of a |
| 24 | +-- backed collection, replaced WHOLESALE per good poll (DELETE the collection's |
| 25 | +-- rows, bulk-INSERT the snapshot — one atomic D1 batch; never row-diffed). |
| 26 | +-- The external_* fields are foreign HANDLES (what to read/delete), not identity. |
| 27 | +-- Reading work never lives here. |
| 28 | +CREATE TABLE backed_collection_members ( |
| 29 | + user_did TEXT NOT NULL, |
| 30 | + external_collection TEXT NOT NULL, -- backing collection at-uri |
| 31 | + url_normalized TEXT NOT NULL, -- join key into saved_articles |
| 32 | + url TEXT NOT NULL, -- resolved web URL (raw) |
| 33 | + external_provider TEXT NOT NULL, -- 'semble' | 'margin' |
| 34 | + external_item_uri TEXT NOT NULL, -- card / bookmark / note at-uri (may be cross-repo) |
| 35 | + external_link_uri TEXT NOT NULL, -- collectionLink / collectionItem (deleted on unsave) |
| 36 | + metadata TEXT, -- JSON: title/author/etc + canonical at:// (Semble only) |
| 37 | + PRIMARY KEY (user_did, external_collection, url_normalized) |
| 38 | +); |
| 39 | +CREATE INDEX idx_backed_members_collection ON backed_collection_members(user_did, external_collection); |
| 40 | + |
| 41 | +-- (3) Short-lived unsave tombstones: suppress a just-unsaved URL until a snapshot |
| 42 | +-- confirms the foreign membership is actually gone, so a fire-and-forget |
| 43 | +-- membership delete that hasn't propagated can't be resurrected by the next |
| 44 | +-- wholesale-replace poll. Cleared when a complete poll no longer lists the URL; |
| 45 | +-- a TTL backstop surfaces a permanently-stuck delete. See Phase 4. |
| 46 | +CREATE TABLE backed_unsave_tombstones ( |
| 47 | + user_did TEXT NOT NULL, |
| 48 | + external_collection TEXT NOT NULL, |
| 49 | + url_normalized TEXT NOT NULL, |
| 50 | + created_at INTEGER NOT NULL, |
| 51 | + PRIMARY KEY (user_did, external_collection, url_normalized) |
| 52 | +); |
| 53 | + |
| 54 | +-- (4) Backing configuration, one per user. 'skyreader' (or NULL) = today's behavior |
| 55 | +-- (app.skyreader.feed.saved export). Otherwise 'semble:<collectionUri>' / |
| 56 | +-- 'margin:<collectionUri>'. One backing per ACCOUNT, applied to all saves. |
| 57 | +ALTER TABLE user_settings ADD COLUMN backing TEXT; |
| 58 | + |
| 59 | +-- (5) Last time we polled+replaced the membership snapshot for this user's backing. |
| 60 | +-- One backing per account => one timestamp. Lets the Saved-list-open poll |
| 61 | +-- minute-gate itself instead of hitting the PDS on every GET (epoch ms). |
| 62 | +ALTER TABLE user_settings ADD COLUMN last_backing_poll INTEGER; |
0 commit comments