Skip to content

Commit 5b0632d

Browse files
committed
add external backed saves
1 parent 977dfa4 commit 5b0632d

20 files changed

Lines changed: 5212 additions & 23 deletions

CLAUDE.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Each package has its own CLAUDE.md with detailed guidance.
1818

1919
A reading app that helps you make sense of what you read. It brings everything you follow — RSS, newsletters, social posts, YouTube, the Atmosphere — into one calm place, gives you a focused surface to read and annotate it, and helps it become understanding through highlights, margin notes, a linkblog, and integrations with sensemaking tools (Semble, Margin).
2020

21-
It's built on the AT Protocol (Bluesky): subscriptions, saved articles, and shares live in the user's Personal Data Server (PDS), so a reader's reading life is portable and outlives any one app. That ownership is the **foundation, not the headline** — lead with reading, not protocol jargon. Read state lives server-side in D1, not the PDS. Features offline support (PWA + IndexedDB), real-time updates, and calm social sharing via linkblogs.
21+
It's built on the AT Protocol (Bluesky): subscriptions, saved articles, and shares can sync to the user's Personal Data Server (PDS), so a reader's reading life is portable and outlives any one app. That ownership is the **foundation, not the headline** — lead with reading, not protocol jargon. Read state lives server-side in D1, not the PDS — and so, canonically, do saves (the PDS `app.skyreader.feed.saved` record is an opt-in export, see the Copy & Voice accuracy note below). Features offline support (PWA + IndexedDB), real-time updates, and calm social sharing via linkblogs.
2222

2323
## Design Context
2424

@@ -53,8 +53,14 @@ When writing user-facing copy about data ownership, portability, or AT Protocol,
5353
user's **atproto PDS**, making it backed up, portable to any Atmospheric app, and
5454
**publicly visible**. Always surface the public-visibility tradeoff.
5555
- `PDS` is fine as a concrete noun once the framing is established (e.g. "stored on your PDS"), but
56-
don't _lead_ with it — lead with the Atmosphere/portability idea. Saves and shares always live on
57-
the PDS; only the subscription/feed list is the opt-in choice.
56+
don't _lead_ with it — lead with the Atmosphere/portability idea. **Accuracy note:** the canonical
57+
store for saves is **D1**, not the PDS. Writing the `app.skyreader.feed.saved` record to the PDS is
58+
an opt-in _export_ gated on the same `pds_sync_enabled` flag as subscriptions, and today it only
59+
fires for `feed`/`url` saves — `document` and `share` saves never write a PDS save record at all
60+
(see `backend/src/routes/saved.ts`). So don't claim in copy that "your saves always live on your
61+
PDS"; the honest line is that saves live in Skyreader and become PDS-portable when Atmospheric sync
62+
is on. (See [External-backed saves](docs/plans/EXTERNAL_BACKED_SAVES_PLAN.md), which would make a
63+
Saved list _be_ a foreign collection.)
5864
- Keep the voice **calm, terse, reading-first** (per PRODUCT.md). Prefer one short clause over a
5965
paragraph; let the user act, don't oversell.
6066

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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;

backend/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
handleUpdateSaved,
5858
handleDeleteSaved,
5959
handleDeleteSavedByGuid,
60+
handleSetBacking,
6061
} from './routes/saved';
6162
import { handleExtract } from './routes/extract';
6263
import { handleGetSettings, handleUpdateSettings } from './routes/settings';
@@ -380,7 +381,7 @@ export default {
380381
case url.pathname === '/api/saved':
381382
if (!session) return unauthorizedResponse(headers);
382383
if (request.method === 'GET') {
383-
response = await handleGetSaved(request, env);
384+
response = await handleGetSaved(request, env, ctx);
384385
} else if (request.method === 'POST') {
385386
response = await handleCreateSaved(request, env, ctx);
386387
} else {
@@ -394,6 +395,10 @@ export default {
394395
if (!session) return unauthorizedResponse(headers);
395396
response = await handleDeleteSavedByGuid(request, env, ctx);
396397
break;
398+
case url.pathname === '/api/saved/backing':
399+
if (!session) return unauthorizedResponse(headers);
400+
response = await handleSetBacking(request, env);
401+
break;
397402
case url.pathname.startsWith('/api/saved/'):
398403
if (!session) return unauthorizedResponse(headers);
399404
if (request.method === 'PATCH') {

backend/src/routes/integrations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { SEMBLE_SCOPES, MARGIN_SCOPES } from './auth';
66
/**
77
* Check if the session has the required scopes for a specific integration
88
*/
9-
function hasIntegrationScopes(session: Session, integration: 'semble' | 'margin'): boolean {
9+
export function hasIntegrationScopes(session: Session, integration: 'semble' | 'margin'): boolean {
1010
if (!session.grantedScopes) return false;
1111
const granted = new Set(session.grantedScopes.split(' '));
1212
const required = integration === 'semble' ? SEMBLE_SCOPES : MARGIN_SCOPES;

0 commit comments

Comments
 (0)