feedkeeper 0.27.0 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedkeeper",
3
- "version": "0.27.0",
3
+ "version": "0.27.2",
4
4
  "description": "A lightweight, self-hosted RSS reader and remote MCP server for your news, blogs, and AI workflows.",
5
5
  "keywords": [
6
6
  "rss",
@@ -123,10 +123,11 @@ itemsRouter.post("/:itemId/extract-content", async (req, res) => {
123
123
  res.status(FULL_TEXT_STATUS[result.error]).json({ error: result.error, ...(result.message ? { message: result.message } : {}) });
124
124
  return;
125
125
  }
126
- const [served] = withArchivedImages([{ id: result.id, link: findItemById(result.id)?.link ?? null, full_content_html: result.fullContentHtml }], requestOrigin(req));
126
+ const currentItem = findItemById(result.id);
127
+ const [served] = withArchivedImages([{ id: result.id, link: currentItem?.link ?? null, full_content_html: result.fullContentHtml }], requestOrigin(req));
127
128
  res.json({
128
129
  id: result.id,
129
- full_content_html: readerHtml(served.full_content_html, result.title ?? findItemById(result.id)?.title, findItemById(result.id)?.author),
130
+ full_content_html: readerHtml(served.full_content_html, result.title ?? currentItem?.title, currentItem?.author),
130
131
  ...(result.cached ? {} : { title: result.title, byline: result.byline }),
131
132
  cached: result.cached,
132
133
  });
@@ -11,7 +11,7 @@ import { loadFullText } from "../../feeds/fullText.js";
11
11
  import { generateOpml, importOpmlFeeds } from "../../feeds/opml.js";
12
12
  import { pollFeed, pollFeeds } from "../../feeds/poller.js";
13
13
  import { canAccessFeed, loadFeedIcon } from "../../feeds/feedIcon.js";
14
- import { addMutedKeyword, canAccessItem, createFolder, deleteFolder, deleteItemNote, findFeedById, findFolderById, findItemForUser, findItemNote, noteConflictState, isUserSubscribed, itemsWithNotes, listFoldersForUser, listItemsForUser, listMutedKeywords, listNotesForUser, listSubscriptionsForUser, markAllRead, removeMutedKeyword, reorderSubscriptions, searchItemsForUser, setItemNote, updateFolder, } from "../../feeds/repository.js";
14
+ import { addMutedKeyword, canAccessItem, createFolder, deleteFolder, deleteItemNote, findFeedById, findFolderById, findItemForUser, findItemsForUser, findSubscriptionForUser, findItemNote, noteConflictState, isUserSubscribed, itemsWithNotes, listFoldersForUser, listItemsForUser, listMutedKeywords, listNotesForUser, listSubscriptionsForUser, markAllRead, removeMutedKeyword, reorderSubscriptions, searchItemsForUser, setItemNote, updateFolder, } from "../../feeds/repository.js";
15
15
  import { saveToInbox, deleteInboxItem } from "../../feeds/inbox.js";
16
16
  import { changeFeedUrl, FeedError, MultipleFeedsFoundError, subscribeToFeed, unsubscribeFromFeed, updateFeedSettings } from "../../feeds/service.js";
17
17
  import { SsrfBlockedError, normalizeUrlCandidate } from "../../feeds/ssrfGuard.js";
@@ -29,7 +29,7 @@ const imageLimiter = rateLimit({ windowMs: 60 * 1000, limit: 600, standardHeader
29
29
  const urlField = z.preprocess((val) => (typeof val === "string" ? normalizeUrlCandidate(val) : val), z.string().url());
30
30
  const invalid = (res, details) => void res.status(400).json({ error: "invalid_input", ...(details ? { details } : {}) });
31
31
  function subscriptionOf(userId, id) {
32
- const subscription = listSubscriptionsForUser(userId).find((entry) => entry.id === id);
32
+ const subscription = findSubscriptionForUser(userId, id);
33
33
  return subscription ? serializeSubscriptionDetail(subscription) : null;
34
34
  }
35
35
  // ---- Account -------------------------------------------------------------
@@ -302,9 +302,9 @@ const itemsQuery = z.object({
302
302
  images: z.enum(["original", "proxy"]).default("original"),
303
303
  });
304
304
  /** Rows ready for a native client: images served by this server where it keeps copies. */
305
- function prepare(rows, req, images) {
306
- const archived = withArchivedImages(rows, origin(req), API_BASE);
307
- return images === "proxy" ? withProxiedImages(archived, origin(req), API_BASE) : archived;
305
+ function prepare(rows, req, images, withContent = true) {
306
+ const archived = withArchivedImages(rows, origin(req), API_BASE, withContent);
307
+ return images === "proxy" ? withProxiedImages(archived, origin(req), API_BASE, withContent) : archived;
308
308
  }
309
309
  const searchQuery = z.object({
310
310
  q: z.string().trim().min(1).max(200),
@@ -340,7 +340,7 @@ resourcesRouter.get("/search", requireCapability("search.fts"), (req, res) => {
340
340
  res.json({
341
341
  query: query.q,
342
342
  total,
343
- items: prepare(rows, req, query.images).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
343
+ items: prepare(rows, req, query.images, withContent).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
344
344
  });
345
345
  });
346
346
  resourcesRouter.get("/items", (req, res) => {
@@ -374,7 +374,7 @@ resourcesRouter.get("/items", (req, res) => {
374
374
  const progress = readingPositions(req.user.id, page.map((row) => row.id));
375
375
  const hasNotes = itemsWithNotes(req.user.id, page.map((row) => row.id));
376
376
  res.json({
377
- items: prepare(page, req, query.images).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
377
+ items: prepare(page, req, query.images, withContent).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
378
378
  newestCursor: page[0] ? encodeCursor(page[0]) : null,
379
379
  nextCursor: hasMore && page.length ? encodeCursor(page[page.length - 1]) : null,
380
380
  });
@@ -385,10 +385,11 @@ resourcesRouter.get("/items/bundle", (req, res) => {
385
385
  if (!parsed.success)
386
386
  return invalid(res, "ids: up to 50 comma separated article ids");
387
387
  const ids = [...new Set(parsed.data.ids.split(",").map(Number))];
388
- const rows = ids.map((id) => findItemForUser(req.user.id, id)).filter((row) => Boolean(row));
388
+ const byId = new Map(findItemsForUser(req.user.id, ids).map((row) => [row.id, row]));
389
+ const rows = ids.map((id) => byId.get(id)).filter((row) => Boolean(row));
389
390
  const progress = readingPositions(req.user.id, rows.map((row) => row.id));
390
391
  const hasNotes = itemsWithNotes(req.user.id, rows.map((row) => row.id));
391
- res.json({ items: prepare(rows, req, parsed.data.images).map((row) => serializeItem(row, progress, true, hasNotes.has(row.id))) });
392
+ res.json({ items: prepare(rows, req, parsed.data.images, true).map((row) => serializeItem(row, progress, true, hasNotes.has(row.id))) });
392
393
  });
393
394
  // Explicit extraction side effect; read-only devices continue to consume the shared cache.
394
395
  resourcesRouter.post("/items/prepare", requireCapability("fulltext"), async (req, res) => {
@@ -515,12 +516,13 @@ resourcesRouter.get("/notes/export", (req, res) => {
515
516
  });
516
517
  // ---- Curated Edition ("Deine Zeitung") ------------------------------------
517
518
  function editionResponse(req, edition) {
519
+ const byId = new Map(findItemsForUser(req.user.id, edition.itemIds).map((row) => [row.id, row]));
518
520
  const rows = edition.itemIds
519
- .map((id) => findItemForUser(req.user.id, id))
521
+ .map((id) => byId.get(id))
520
522
  .filter((row) => Boolean(row));
521
523
  const progress = readingPositions(req.user.id, rows.map((r) => r.id));
522
524
  const hasNotes = itemsWithNotes(req.user.id, rows.map((r) => r.id));
523
- const prepared = prepare(rows, req, req.query.images === "proxy" ? "proxy" : "original");
525
+ const prepared = prepare(rows, req, req.query.images === "proxy" ? "proxy" : "original", false);
524
526
  return {
525
527
  ...edition,
526
528
  items: prepared.map((row) => serializeItem(row, progress, false, hasNotes.has(row.id))),
@@ -38,9 +38,16 @@ export function resolveToken(token) {
38
38
  if (stored.length !== provided.length || !timingSafeEqual(stored, provided)) {
39
39
  return null;
40
40
  }
41
- db.prepare("UPDATE personal_access_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?").run(row.id);
41
+ const now = Date.now();
42
+ const lastTouch = tokenLastTouch.get(row.id) ?? 0;
43
+ if (!row.last_used_at || now - lastTouch >= TOKEN_TOUCH_INTERVAL_MS) {
44
+ tokenLastTouch.set(row.id, now);
45
+ db.prepare("UPDATE personal_access_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?").run(row.id);
46
+ }
42
47
  return { userId: row.user_id, scope: row.scope, kind: row.kind, tokenId: row.id };
43
48
  }
49
+ const tokenLastTouch = new Map();
50
+ const TOKEN_TOUCH_INTERVAL_MS = 5 * 60 * 1000;
44
51
  /** Hand-made tokens only; paired devices are listed separately. */
45
52
  export function listTokensForUser(userId) {
46
53
  return db
@@ -49,6 +56,7 @@ export function listTokensForUser(userId) {
49
56
  .map(({ token_hash, ...rest }) => rest);
50
57
  }
51
58
  export function deleteToken(userId, tokenId) {
59
+ tokenLastTouch.delete(tokenId);
52
60
  db.prepare("DELETE FROM personal_access_tokens WHERE id = ? AND user_id = ? AND kind = 'api'").run(tokenId, userId);
53
61
  }
54
62
  export function listDevicesForUser(userId) {
@@ -59,6 +67,7 @@ export function listDevicesForUser(userId) {
59
67
  }
60
68
  /** Revokes a device; its token stops working immediately. Returns whether one was removed. */
61
69
  export function deleteDevice(userId, deviceId) {
70
+ tokenLastTouch.delete(deviceId);
62
71
  return db.prepare("DELETE FROM personal_access_tokens WHERE id = ? AND user_id = ? AND kind = 'device'").run(deviceId, userId).changes > 0;
63
72
  }
64
73
  //# sourceMappingURL=tokens.js.map
@@ -8,7 +8,11 @@ const MIGRATIONS_DIR = join(__dirname, "migrations");
8
8
  mkdirSync(dirname(config.databasePath), { recursive: true });
9
9
  export const db = new Database(config.databasePath);
10
10
  db.pragma("journal_mode = WAL");
11
+ db.pragma("synchronous = NORMAL");
11
12
  db.pragma("foreign_keys = ON");
13
+ db.pragma("busy_timeout = 5000");
14
+ db.pragma("temp_store = MEMORY");
15
+ db.pragma("cache_size = -65536");
12
16
  function ensureMigrationsTable() {
13
17
  db.exec(`CREATE TABLE IF NOT EXISTS _migrations (
14
18
  name TEXT PRIMARY KEY,
@@ -0,0 +1,7 @@
1
+ -- A client that already holds an item learns of a later author through the content revision. Only the revision
2
+ -- moves: the extraction status stays, so no full text is fetched again for it.
3
+ CREATE TRIGGER trg_author_revision AFTER UPDATE OF author ON items
4
+ WHEN OLD.author IS NOT NEW.author
5
+ BEGIN
6
+ UPDATE items SET content_revision = OLD.content_revision + 1 WHERE id = NEW.id;
7
+ END;
@@ -0,0 +1,96 @@
1
+ -- Foreign key and cascade delete indexes
2
+ CREATE INDEX IF NOT EXISTS idx_item_reads_item ON item_reads(item_id);
3
+ CREATE INDEX IF NOT EXISTS idx_item_progress_item ON item_progress(item_id);
4
+ CREATE INDEX IF NOT EXISTS idx_item_note_revisions_item ON item_note_revisions(item_id);
5
+ CREATE INDEX IF NOT EXISTS idx_full_text_jobs_user ON full_text_jobs(user_id);
6
+ CREATE INDEX IF NOT EXISTS idx_oauth_codes_user ON oauth_codes(user_id);
7
+ CREATE INDEX IF NOT EXISTS idx_oauth_codes_client ON oauth_codes(client_id);
8
+ CREATE INDEX IF NOT EXISTS idx_security_challenges_user ON security_challenges(user_id);
9
+
10
+ -- Housekeeping and retention cleanup indexes
11
+ CREATE INDEX IF NOT EXISTS idx_native_requests_created ON native_requests(created_at);
12
+ CREATE INDEX IF NOT EXISTS idx_applied_mutations_applied ON applied_mutations(applied_at);
13
+ CREATE INDEX IF NOT EXISTS idx_items_feed_eff_pub ON items(
14
+ feed_id,
15
+ COALESCE(julianday(published_at), julianday(created_at)) DESC,
16
+ id DESC
17
+ );
18
+
19
+ -- Item listing, cursor pagination, lead backfill, and full-text candidate indexes
20
+ CREATE INDEX IF NOT EXISTS idx_items_feed_created ON items(feed_id, created_at DESC, id DESC);
21
+ CREATE INDEX IF NOT EXISTS idx_bookmarks_user_created ON item_bookmarks(user_id, created_at DESC, item_id DESC);
22
+ CREATE INDEX IF NOT EXISTS idx_items_lead_version ON items(lead_version);
23
+ CREATE INDEX IF NOT EXISTS idx_items_fulltext_candidates ON items(feed_id, created_at DESC)
24
+ WHERE link IS NOT NULL AND full_content_html IS NULL AND extraction_status != 'feed_only';
25
+
26
+ -- Avoid rebuilding FTS5 entries when non-indexed item columns change during polling or extraction
27
+ DROP TRIGGER IF EXISTS trg_items_fts_update;
28
+ CREATE TRIGGER trg_items_fts_update AFTER UPDATE ON items
29
+ WHEN OLD.title IS NOT NEW.title
30
+ OR OLD.content_snippet IS NOT NEW.content_snippet
31
+ OR OLD.content_html IS NOT NEW.content_html
32
+ OR OLD.full_content_html IS NOT NEW.full_content_html
33
+ BEGIN
34
+ INSERT INTO items_fts(items_fts, rowid, title, content_snippet, content_html, full_content_html)
35
+ VALUES ('delete', old.id, old.title, old.content_snippet, old.content_html, old.full_content_html);
36
+ INSERT INTO items_fts(rowid, title, content_snippet, content_html, full_content_html)
37
+ VALUES (new.id, new.title, new.content_snippet, new.content_html, new.full_content_html);
38
+ END;
39
+
40
+ -- Monotonic per-user sync sequence high-water mark across change log pruning
41
+ ALTER TABLE sync_state ADD COLUMN last_seq INTEGER NOT NULL DEFAULT 0;
42
+ UPDATE sync_state
43
+ SET last_seq = MAX(
44
+ pruned_through,
45
+ COALESCE((SELECT MAX(seq) FROM changes WHERE changes.user_id = sync_state.user_id), 0)
46
+ );
47
+
48
+ DROP TRIGGER IF EXISTS trg_changes_seq_insert;
49
+ CREATE TRIGGER trg_changes_seq_insert
50
+ AFTER INSERT ON changes
51
+ WHEN NEW.seq <= COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
52
+ BEGIN
53
+ UPDATE changes
54
+ SET seq = MAX(
55
+ COALESCE((SELECT MAX(seq) FROM changes WHERE user_id = NEW.user_id), 0),
56
+ COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
57
+ ) + 1
58
+ WHERE user_id = NEW.user_id AND entity = NEW.entity AND entity_id = NEW.entity_id;
59
+ END;
60
+
61
+ DROP TRIGGER IF EXISTS trg_changes_seq_update;
62
+ CREATE TRIGGER trg_changes_seq_update
63
+ AFTER UPDATE OF seq ON changes
64
+ WHEN NEW.seq <= COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
65
+ BEGIN
66
+ UPDATE changes
67
+ SET seq = MAX(
68
+ COALESCE((SELECT MAX(seq) FROM changes WHERE user_id = NEW.user_id), 0),
69
+ COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
70
+ ) + 1
71
+ WHERE user_id = NEW.user_id AND entity = NEW.entity AND entity_id = NEW.entity_id;
72
+ END;
73
+
74
+ -- Record old feed deletion when a subscription moves to a different feed
75
+ DROP TRIGGER IF EXISTS trg_changes_subscription_move;
76
+ CREATE TRIGGER trg_changes_subscription_move
77
+ AFTER UPDATE OF feed_id ON subscriptions
78
+ WHEN OLD.feed_id IS NOT NEW.feed_id
79
+ BEGIN
80
+ INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
81
+ VALUES (OLD.user_id, 'subscription', OLD.feed_id, 'delete', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = OLD.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
82
+ ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
83
+ END;
84
+
85
+ -- Notify subscribers when a feed's URL or poll interval is updated in place
86
+ DROP TRIGGER IF EXISTS trg_changes_feed_update;
87
+ CREATE TRIGGER trg_changes_feed_update
88
+ AFTER UPDATE OF url, poll_interval_minutes ON feeds
89
+ WHEN OLD.url IS NOT NEW.url OR OLD.poll_interval_minutes IS NOT NEW.poll_interval_minutes
90
+ BEGIN
91
+ INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
92
+ SELECT s.user_id, 'subscription', NEW.id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = s.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
93
+ FROM subscriptions s
94
+ WHERE s.feed_id = NEW.id
95
+ ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
96
+ END;
@@ -22,6 +22,7 @@ const archiveWrites = new Set();
22
22
  async function storeArchivedImage(destination, image) {
23
23
  const temporary = `${destination}.tmp`;
24
24
  archiveWrites.add(temporary);
25
+ archiveWrites.add(destination);
25
26
  try {
26
27
  await unlink(temporary).catch((error) => { if (error.code !== "ENOENT")
27
28
  throw error; });
@@ -164,13 +165,18 @@ export async function archiveItem(itemId) {
164
165
  continue;
165
166
  const fileName = `${itemId}-${createHash("sha256").update(url).digest("hex").slice(0, 16)}.${type.ext}`;
166
167
  const destination = join(archiveDir(), fileName);
167
- await storeArchivedImage(destination, image);
168
- // The last bookmark or article may have disappeared while the image was loading.
169
- if (!hasBookmark(itemId) || !findItemById(itemId)) {
170
- unlinkSync(destination);
171
- continue;
168
+ try {
169
+ await storeArchivedImage(destination, image);
170
+ // The last bookmark or article may have disappeared while the image was loading.
171
+ if (!hasBookmark(itemId) || !findItemById(itemId)) {
172
+ unlinkSync(destination);
173
+ continue;
174
+ }
175
+ insert.run(itemId, url, fileName, type.mime, buffer.length);
176
+ }
177
+ finally {
178
+ archiveWrites.delete(destination);
172
179
  }
173
- insert.run(itemId, url, fileName, type.mime, buffer.length);
174
180
  }
175
181
  catch (error) {
176
182
  // Leave the durable bookmark pending so startup can finish it later.
@@ -276,7 +282,7 @@ export function findArchivedImage(userId, imageId) {
276
282
  return { path: join(archiveDir(), row.file_name), mime: row.mime_type };
277
283
  }
278
284
  /** Swap image URLs of archived articles for their local copies before sending them to the browser. */
279
- export function withArchivedImages(items, origin, apiBase = "/api") {
285
+ export function withArchivedImages(items, origin, apiBase = "/api", withContent = true) {
280
286
  const archived = archivedImagesFor(items.map((item) => item.id));
281
287
  return items.map((item) => {
282
288
  const images = archived.get(item.id);
@@ -286,8 +292,8 @@ export function withArchivedImages(items, origin, apiBase = "/api") {
286
292
  return {
287
293
  ...item,
288
294
  image_url: hero && images.has(hero) ? archivedImageUrl(origin, images.get(hero), apiBase) : item.image_url,
289
- content_html: item.content_html ? rewriteArchivedImages(item.content_html, item.link, images, origin, apiBase) : item.content_html,
290
- full_content_html: item.full_content_html ? rewriteArchivedImages(item.full_content_html, item.link, images, origin, apiBase) : item.full_content_html,
295
+ content_html: withContent && item.content_html ? rewriteArchivedImages(item.content_html, item.link, images, origin, apiBase) : item.content_html,
296
+ full_content_html: withContent && item.full_content_html ? rewriteArchivedImages(item.full_content_html, item.link, images, origin, apiBase) : item.full_content_html,
291
297
  };
292
298
  });
293
299
  }
@@ -303,7 +309,7 @@ function proxiedImageUrl(origin, itemId, imageUrl, apiBase = "/api") {
303
309
  * For offline copies: point the first images of articles that are not archived at an endpoint
304
310
  * of this server, so the browser can store them without cross-origin requests.
305
311
  */
306
- export function withProxiedImages(items, origin, apiBase = "/api") {
312
+ export function withProxiedImages(items, origin, apiBase = "/api", withContent = true) {
307
313
  const own = `${origin.replace(/\/$/, "")}/api/`;
308
314
  return items.map((item) => {
309
315
  const urls = new Set(itemImageUrls(item).filter((url) => !url.startsWith(own)));
@@ -314,20 +320,26 @@ export function withProxiedImages(items, origin, apiBase = "/api") {
314
320
  return {
315
321
  ...item,
316
322
  image_url: hero && urls.has(hero) ? proxiedImageUrl(origin, item.id, hero, apiBase) : item.image_url,
317
- content_html: item.content_html ? rewriteImageSources(item.content_html, item.link, replace) : item.content_html,
318
- full_content_html: item.full_content_html ? rewriteImageSources(item.full_content_html, item.link, replace) : item.full_content_html,
323
+ content_html: withContent && item.content_html ? rewriteImageSources(item.content_html, item.link, replace) : item.content_html,
324
+ full_content_html: withContent && item.full_content_html ? rewriteImageSources(item.full_content_html, item.link, replace) : item.full_content_html,
319
325
  };
320
326
  });
321
327
  }
322
328
  /** Download one of an article's own images for the offline copy, or null when it is not one of them. */
323
329
  export async function fetchItemImage(userId, itemId, imageUrl) {
324
330
  const item = findItemForUser(userId, itemId);
325
- if (!item || !itemImageUrls(item).includes(imageUrl))
331
+ if (!item)
332
+ return "forbidden";
333
+ const isMatch = (it) => {
334
+ const hero = it.image_url ? resolveImageUrl(it.image_url, it.link) : null;
335
+ return hero === imageUrl || itemImageUrls(it).includes(imageUrl);
336
+ };
337
+ if (!isMatch(item))
326
338
  return "forbidden";
327
339
  const image = await sharedImage(item, imageUrl);
328
340
  // Access or the article's image list can change while a shared download is running.
329
341
  const current = findItemForUser(userId, itemId);
330
- if (!current || !itemImageUrls(current).includes(imageUrl))
342
+ if (!current || !isMatch(current))
331
343
  return "forbidden";
332
344
  return image;
333
345
  }
@@ -91,8 +91,8 @@ export function runCleanup(custom) {
91
91
  WHERE i.id NOT IN (SELECT item_id FROM item_bookmarks)
92
92
  AND i.id NOT IN (SELECT item_id FROM item_notes)
93
93
  AND i.feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
94
- AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
95
- WHERE selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed')
94
+ AND i.id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
95
+ WHERE e.source = 'curated' AND e.status <> 'dismissed')
96
96
  AND i.id IN (SELECT item_id FROM item_reads)
97
97
  AND NOT EXISTS (
98
98
  SELECT 1 FROM subscriptions s
@@ -109,8 +109,8 @@ export function runCleanup(custom) {
109
109
  WHERE id NOT IN (SELECT item_id FROM item_bookmarks)
110
110
  AND id NOT IN (SELECT item_id FROM item_notes)
111
111
  AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
112
- AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
113
- WHERE selected.value = items.id AND e.source = 'curated' AND e.status <> 'dismissed')
112
+ AND id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
113
+ WHERE e.source = 'curated' AND e.status <> 'dismissed')
114
114
  AND COALESCE(julianday(published_at), julianday(created_at)) <= julianday('now', '-' || ? || ' days')`).run(settings.retentionMaxDays);
115
115
  deletedOldItems = res.changes;
116
116
  }
@@ -120,8 +120,8 @@ export function runCleanup(custom) {
120
120
  WHERE id NOT IN (SELECT item_id FROM item_bookmarks)
121
121
  AND id NOT IN (SELECT item_id FROM item_notes)
122
122
  AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
123
- AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
124
- WHERE selected.value = items.id AND e.source = 'curated' AND e.status <> 'dismissed')
123
+ AND id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
124
+ WHERE e.source = 'curated' AND e.status <> 'dismissed')
125
125
  AND id IN (
126
126
  SELECT id FROM (
127
127
  SELECT id, ROW_NUMBER() OVER (
@@ -139,11 +139,14 @@ export function runCleanup(custom) {
139
139
  pruneArchive();
140
140
  pruneChangeLog();
141
141
  const totalDeleted = deletedReadItems + deletedOldItems + deletedPerFeedExcess;
142
- // Optimize query planning; rebuild storage only when this run freed rows.
142
+ // Optimize query planning; rebuild storage only when this run freed substantial pages.
143
143
  db.exec("PRAGMA optimize;");
144
144
  if (totalDeleted > 0) {
145
145
  try {
146
- db.exec("VACUUM;");
146
+ const freelist = db.pragma("freelist_count", { simple: true }) ?? 0;
147
+ if (freelist > 500) {
148
+ db.exec("VACUUM;");
149
+ }
147
150
  }
148
151
  catch (err) {
149
152
  console.warn("[cleanup] VACUUM skipped or could not run:", err);
@@ -54,7 +54,7 @@ const TIME_OF_DAY = "(?:[,\\s•·|-]+\\d{1,2}[:.]\\d{2}(?::\\d{2})?(?:\\s?(?:Uh
54
54
  const DATE_PART = `(?:\\d{1,2}\\.\\s?\\d{1,2}\\.\\s?\\d{2,4}|\\d{1,2}\\.?\\s(?:${MONTHS})\\.?\\s\\d{4}|(?:${MONTHS})\\.?\\s\\d{1,2},?\\s\\d{4})`;
55
55
  const DATE_ONLY = new RegExp(`^${DATE_PART}${TIME_OF_DAY}$`, "iu");
56
56
  /** The date at the end of a byline (`von Michael Bartl I 15.10.2026 - 11:41`): the author stays, the date is in the reader's head. */
57
- const BYLINE = /^(von|by|text|autor(in)?|author|written by)\b/i;
57
+ const BYLINE = /^(von|by|text|autor(in)?|author|written by|words by|story by|report(ed)? by|posted by|photo(s)? by)\b/i;
58
58
  const BYLINE_DATE_TAIL = new RegExp(`(?:^|\\s)[|•·–—I-]\\s*${DATE_PART}${TIME_OF_DAY}\\s*$`, "iu");
59
59
  function stripBylineDate(block) {
60
60
  const walker = block.ownerDocument.createTreeWalker(block, 4);
@@ -139,6 +139,7 @@ function removeReaderNoise(root, title, stats, author) {
139
139
  const authorKeys = (author ?? "").split(",").map(name => normalized(name)).filter(Boolean);
140
140
  let leading = 0;
141
141
  let reachedText = false;
142
+ let pastStart = false;
142
143
  for (const block of leafBlocks(root)) {
143
144
  const text = flat(block.textContent);
144
145
  if (!text)
@@ -161,23 +162,29 @@ function removeReaderNoise(root, title, stats, author) {
161
162
  }
162
163
  }
163
164
  // Near the start, a line that is only a date or a reading time repeats what the reader shows in its own head.
164
- if (text.length <= 60 && charsBefore(root, block) <= 800) {
165
- if (DATE_ONLY.test(text)) {
166
- drop(block, "dateline");
167
- continue;
168
- }
169
- if (READING_TIME_ONLY.test(text)) {
170
- drop(block, "reading-time");
171
- continue;
165
+ if (!pastStart && text.length <= 60) {
166
+ const before = charsBefore(root, block);
167
+ if (before > 800) {
168
+ pastStart = true;
172
169
  }
173
- // When the feed names the author, the reader shows the name in its head: a byline that only says that goes.
174
- if (BYLINE.test(text) && authorKeys.length > 0 && authorKeys.every(name => normalized(text).includes(name))) {
175
- drop(block, "byline");
176
- continue;
177
- }
178
- if (BYLINE.test(text) && stripBylineDate(block)) {
179
- stats["byline-date"] = (stats["byline-date"] ?? 0) + 1;
180
- changed = true;
170
+ else {
171
+ if (DATE_ONLY.test(text)) {
172
+ drop(block, "dateline");
173
+ continue;
174
+ }
175
+ if (READING_TIME_ONLY.test(text)) {
176
+ drop(block, "reading-time");
177
+ continue;
178
+ }
179
+ // When the feed names the author, the reader shows the name in its head: a byline that only says that goes.
180
+ if (BYLINE.test(text) && authorKeys.length > 0 && authorKeys.every(name => normalized(text).includes(name))) {
181
+ drop(block, "byline");
182
+ continue;
183
+ }
184
+ if (BYLINE.test(text) && stripBylineDate(block)) {
185
+ stats["byline-date"] = (stats["byline-date"] ?? 0) + 1;
186
+ changed = true;
187
+ }
181
188
  }
182
189
  }
183
190
  if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14))) {
@@ -196,7 +203,7 @@ function removeReaderNoise(root, title, stats, author) {
196
203
  drop(block, "player");
197
204
  continue;
198
205
  }
199
- if (text.length <= 70 && block.querySelector("time") && charsBefore(root, block) <= 600) {
206
+ if (!pastStart && text.length <= 70 && block.querySelector("time") && charsBefore(root, block) <= 600) {
200
207
  const rest = flat(text.replace(flat(block.querySelector("time")?.textContent), ""));
201
208
  if (/^(stand|zuletzt aktualisiert|aktualisiert|ver(ö|oe)ffentlicht|updated|published|posted)?\s*[:·|•-]*\s*$/i.test(rest)) {
202
209
  drop(block, "dateline");
@@ -582,8 +589,8 @@ const COMMENT_PATTERNS = [
582
589
  /\b(let us know|tell us) .{0,40}in the comments\b/i,
583
590
  /\bNur angemeldete Benutzer können kommentieren\b/i,
584
591
  ];
585
- const AD_LABEL = /^(Anzeige|Werbung|Advertisement|Advertorial|Sponsored|Gesponsert|Promoted)\s*:?$/i;
586
- const COMMENT_RULES_HEADING = /^(Regeln für Kommentare|Kommentarregeln|Kommentar-Richtlinien|Comment(ing)? (rules|guidelines|policy)|Community[- ]Richtlinien)\s*:?$/i;
592
+ const AD_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Advertorial|Ads?|Sponsored( content| post)?|Paid (content|post)|Gesponsert|Promoted)\s*:?$/i;
593
+ const COMMENT_RULES_HEADING = /^(Regeln für Kommentare|Kommentarregeln|Kommentar-Richtlinien|Community[- ]Richtlinien|(Comment(ing)?|Community|Discussion|House) (rules|guidelines|policy|policies)|Rules for comments|Commenting rules)\s*:?$/i;
587
594
  const TAG_LABEL = /^(Schlagw(ö|oe)rter|Schlagworte|Tags?|Keywords|Themen|Topics)\s*:?$/i;
588
595
  const VIDEO_CARD = /^\d{1,2}:\d{2}(:\d{2})?(?=\D)\s*\S/;
589
596
  const SECTION_LABEL = /^(Auch interessant|Das könnte (dich|Sie|euch) (auch )?interessieren|Mehr zum Thema|Lesen Sie auch|Lies auch|Lest auch|Aktuelle News|Weitere (Artikel|Meldungen|News)|Popular Stories|Related (Stories|Articles|Posts|Reading)|More (Stories|from)|Top Stories|Meistgelesen|Topaktuell|Regeln für Kommentare|Kommentare|Comments?)\b\s*:?/i;
@@ -151,7 +151,7 @@ async function fetchArticleHtml(rawUrl, signal, dispatcher, fetchImpl, ownerSign
151
151
  }
152
152
  chunks.push(value);
153
153
  }
154
- const html = new TextDecoder().decode(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))));
154
+ const html = new TextDecoder().decode(Buffer.concat(chunks, total));
155
155
  return { html, url: safeUrl.toString() };
156
156
  }
157
157
  return null;
@@ -169,14 +169,12 @@ export async function extractArticle(rawUrl, fetchImpl = defaultFetch, priority
169
169
  }, priority);
170
170
  }
171
171
  async function extractArticleContent(rawUrl, fetchImpl, signal) {
172
- signal.throwIfAborted();
173
- const safeUrl = await assertPublicHttpUrl(rawUrl);
174
172
  signal.throwIfAborted();
175
173
  const dispatcher = createPublicDispatcher();
176
174
  const controller = new AbortController();
177
175
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
178
176
  try {
179
- const fetched = await fetchArticleHtml(safeUrl.toString(), AbortSignal.any([signal, controller.signal]), dispatcher, fetchImpl, signal);
177
+ const fetched = await fetchArticleHtml(rawUrl, AbortSignal.any([signal, controller.signal]), dispatcher, fetchImpl, signal);
180
178
  if (!fetched)
181
179
  return { status: "failed" };
182
180
  const cleanedHtml = stripHeavyTags(fetched.html);
@@ -229,6 +229,8 @@ function pump() {
229
229
  if (timer)
230
230
  clearTimeout(timer);
231
231
  const pool = configureFullTextPool();
232
+ if (active.size >= pool.limits.concurrency)
233
+ return;
232
234
  if (seedTimer && db.prepare("SELECT COUNT(*) n FROM full_text_jobs").get().n < pool.limits.concurrency * 2)
233
235
  scheduleFullTexts();
234
236
  const jobs = db.prepare(`SELECT j.*, i.link FROM full_text_jobs j JOIN items i ON i.id=j.item_id
@@ -258,8 +260,10 @@ function pump() {
258
260
  const work = execute(job, host).finally(() => { active.delete(job.item_id); wake(); });
259
261
  active.set(job.item_id, work);
260
262
  }
261
- if (db.prepare("SELECT COUNT(*) n FROM full_text_jobs").get().n) {
262
- timer = setTimeout(wake, 250);
263
+ const nextJob = db.prepare("SELECT MIN(available_at) AS next_at FROM full_text_jobs").get();
264
+ if (nextJob?.next_at) {
265
+ const delay = Math.max(250, Math.min(30_000, Date.parse(nextJob.next_at) - Date.now()));
266
+ timer = setTimeout(wake, delay);
263
267
  timer.unref();
264
268
  }
265
269
  }
@@ -272,7 +276,7 @@ async function execute(job, host) {
272
276
  attemptErrors++;
273
277
  errorCounts[result.error] = (errorCounts[result.error] ?? 0) + 1;
274
278
  }
275
- if (!result.ok && result.error === "source_busy") {
279
+ if (!result.ok && result.error === "source_busy" && !result.deferred) {
276
280
  throttled++;
277
281
  deferFullTextHost(result.host ?? host, result.retryAt);
278
282
  }
@@ -36,6 +36,16 @@ export class ImageCache {
36
36
  if (this.stopping)
37
37
  throw new ImageCacheBusyError();
38
38
  const key = createHash("sha256").update(`${scope}\0${url}`).digest("hex");
39
+ const missing = this.negative.get(key);
40
+ if (missing) {
41
+ if (missing.expires > this.now()) {
42
+ this.counters.negativeHits++;
43
+ if (missing.result === "unsupported")
44
+ return "unsupported";
45
+ throw new FeedHttpError(missing.result, undefined, "");
46
+ }
47
+ this.negative.delete(key);
48
+ }
39
49
  const shared = this.running.get(key);
40
50
  if (shared) {
41
51
  this.counters.shared++;
@@ -5,8 +5,8 @@ import { hasUserCapability } from "../auth/capabilities.js";
5
5
  import { configureFullTextPool, deferFullTextHost } from "./fullTextQueue.js";
6
6
  import { extractArticle } from "./extractor.js";
7
7
  import { assertPublicHttpUrl, normalizeUrlCandidate } from "./ssrfGuard.js";
8
- import { articleImageUrls, scheduleArchive } from "./archive.js";
9
- import { findFeedById, findFeedByUrl, isUserSubscribed, listSubscriptionsForUser, subscribe, bookmarkItem, findItemById, findItemForUser, setItemNote, } from "./repository.js";
8
+ import { articleImageUrls, pruneArchive, scheduleArchive } from "./archive.js";
9
+ import { findFeedById, findFeedByUrl, isUserSubscribed, findSubscriptionForUser, subscribe, bookmarkItem, findItemById, findItemForUser, setItemNote, } from "./repository.js";
10
10
  function escapeHtml(str) {
11
11
  return str
12
12
  .replace(/&/g, "&amp;")
@@ -31,7 +31,7 @@ export function ensureUserInbox(userId) {
31
31
  if (!isUserSubscribed(userId, feed.id)) {
32
32
  subscribe(userId, feed.id, "Inbox");
33
33
  }
34
- const sub = listSubscriptionsForUser(userId).find((s) => s.id === feed.id);
34
+ const sub = findSubscriptionForUser(userId, feed.id);
35
35
  if (!sub) {
36
36
  throw new Error("Failed to ensure user inbox subscription");
37
37
  }
@@ -53,7 +53,7 @@ export async function saveToInbox(userId, input) {
53
53
  try {
54
54
  configureFullTextPool();
55
55
  const extracted = await extractArticle(targetUrl, undefined, 5, () => hasUserCapability(userId, "fulltext"));
56
- if (extracted.status === "throttled")
56
+ if (extracted.status === "throttled" && !extracted.deferred)
57
57
  deferFullTextHost(extracted.host, extracted.retryAt);
58
58
  if (extracted.status === "ok" || extracted.status === "partial") {
59
59
  extractionStatus = extracted.status === "ok" ? "ready" : "partial";
@@ -130,6 +130,7 @@ export function deleteInboxItem(userId, itemId) {
130
130
  db.prepare("DELETE FROM item_notes WHERE item_id = ?").run(itemId);
131
131
  db.prepare("DELETE FROM items WHERE id = ?").run(itemId);
132
132
  })();
133
+ pruneArchive();
133
134
  return true;
134
135
  }
135
136
  //# sourceMappingURL=inbox.js.map
@@ -190,8 +190,17 @@ export class PollQueue {
190
190
  continue;
191
191
  }
192
192
  // A URL may have been changed since enqueue. Do not dispatch under its old host budget.
193
- if (sourceHost(feed.url) !== job.host) {
194
- db.prepare("UPDATE feed_poll_jobs SET host = ? WHERE feed_id = ?").run(sourceHost(feed.url), feed.id);
193
+ let currentHost;
194
+ try {
195
+ currentHost = sourceHost(feed.url);
196
+ }
197
+ catch {
198
+ db.prepare("DELETE FROM feed_poll_jobs WHERE feed_id = ?").run(job.feed_id);
199
+ this.resolve(job.feed_id, { newItems: 0, error: "invalid_feed_url" });
200
+ continue;
201
+ }
202
+ if (currentHost !== job.host) {
203
+ db.prepare("UPDATE feed_poll_jobs SET host = ? WHERE feed_id = ?").run(currentHost, feed.id);
195
204
  continue;
196
205
  }
197
206
  const waiter = this.waiters.get(feed.id);
@@ -132,10 +132,10 @@ async function pollAttempt(feed, signal) {
132
132
  try {
133
133
  signal.throwIfAborted();
134
134
  if (iconCheckDue(feed.icon_checked_at)) {
135
+ updateFeedIcon(feed.id, feed.icon_url);
135
136
  const siteUrl = parsed?.link || feed.site_url;
136
137
  const urls = siteUrl ? await discoverIconUrls(siteUrl, signal) : [];
137
138
  signal.throwIfAborted();
138
- updateFeedIcon(feed.id, feed.icon_url);
139
139
  await refreshFeedIcon(feed.id, siteUrl, urls, signal);
140
140
  }
141
141
  }
@@ -43,8 +43,8 @@ export function removeFeedIfUnused(feedId) {
43
43
  if (countFeedSubscribers(feedId) > 0)
44
44
  return;
45
45
  db.prepare(`DELETE FROM items WHERE feed_id = ? AND id NOT IN (SELECT item_id FROM item_bookmarks) AND id NOT IN (SELECT item_id FROM item_notes)
46
- AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
47
- WHERE selected.value = items.id AND e.source = 'curated' AND e.status <> 'dismissed')`).run(feedId);
46
+ AND id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
47
+ WHERE e.source = 'curated' AND e.status <> 'dismissed')`).run(feedId);
48
48
  db.prepare("DELETE FROM feeds WHERE id = ? AND is_system_inbox = 0 AND NOT EXISTS (SELECT 1 FROM items WHERE feed_id = ?)").run(feedId, feedId);
49
49
  }
50
50
  /** Feeds kept only for saved articles or annotated articles whose last bookmark and note are gone. */
@@ -54,8 +54,10 @@ export function removeUnusedFeeds() {
54
54
  AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.feed_id = feeds.id)
55
55
  AND NOT EXISTS (SELECT 1 FROM items i JOIN item_bookmarks b ON b.item_id = i.id WHERE i.feed_id = feeds.id)
56
56
  AND NOT EXISTS (SELECT 1 FROM items i JOIN item_notes n ON n.item_id = i.id WHERE i.feed_id = feeds.id)
57
- AND NOT EXISTS (SELECT 1 FROM items i, editions e, json_each(e.item_ids) selected
58
- WHERE i.feed_id = feeds.id AND selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed')`).run().changes;
57
+ AND NOT EXISTS (SELECT 1 FROM items i WHERE i.feed_id = feeds.id AND i.id IN (
58
+ SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
59
+ WHERE e.source = 'curated' AND e.status <> 'dismissed'
60
+ ))`).run().changes;
59
61
  }
60
62
  /** Subscribers can open every item of their feeds; anyone can open what they saved or added a note to. */
61
63
  export function canAccessItem(userId, itemId) {
@@ -168,7 +170,7 @@ export function upsertItems(feedId, items) {
168
170
  refreshLead(Number(result.lastInsertRowid));
169
171
  }
170
172
  else {
171
- const previous = db.prepare("SELECT id, title, content_snippet, content_html, full_content_html, lead_version FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
173
+ const previous = db.prepare("SELECT id, title, link, image_url, content_snippet, content_html, full_content_html, lead_version FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
172
174
  const next = row.contentHtml;
173
175
  // Some publishers replace complete feed bodies with short teasers on a later poll.
174
176
  const retained = previous?.content_html && next && next.length < previous.content_html.length * 0.9 &&
@@ -176,14 +178,27 @@ export function upsertItems(feedId, items) {
176
178
  // The summary belongs to the body it was sent with: keeping the longer body keeps its summary too, or
177
179
  // a feed that alternates between two variants would change the content on every poll.
178
180
  const keptPrevious = retained !== null && next !== retained;
179
- update.run(row.title ?? null, row.link ?? null, keptPrevious ? null : row.contentSnippet ?? null, retained, row.publishedAt ?? null, row.imageUrl ?? null, row.author ?? null, feedId, row.guid);
180
- // Polling rewrites every item; only a change of what the lead is made from needs it derived again.
181
+ const nextSnippet = keptPrevious ? null : row.contentSnippet ?? null;
182
+ const nextImage = largerHeroImage(row.imageUrl, retained, row.link ?? previous?.link ?? null) ?? previous?.image_url ?? row.imageUrl ?? null;
181
183
  if (previous) {
182
- const changed = (retained !== null && retained !== previous.content_html) ||
183
- (row.title !== undefined && row.title !== previous.title) ||
184
- (!keptPrevious && row.contentSnippet !== undefined && row.contentSnippet !== previous.content_snippet);
185
- if (previous.lead_version !== LEAD_VERSION || (changed && !previous.full_content_html))
184
+ const changed = (row.title !== undefined && row.title !== previous.title) ||
185
+ (row.link !== undefined && row.link !== previous.link) ||
186
+ (!keptPrevious && row.contentSnippet !== undefined && row.contentSnippet !== previous.content_snippet) ||
187
+ (retained !== null && retained !== previous.content_html) ||
188
+ (row.publishedAt !== undefined && row.publishedAt !== null) ||
189
+ (nextImage !== previous.image_url) ||
190
+ (row.author !== undefined);
191
+ if (changed) {
192
+ update.run(row.title ?? null, row.link ?? null, nextSnippet, retained, row.publishedAt ?? null, nextImage, row.author ?? null, feedId, row.guid);
193
+ const leadChanged = (retained !== null && retained !== previous.content_html) ||
194
+ (row.title !== undefined && row.title !== previous.title) ||
195
+ (!keptPrevious && row.contentSnippet !== undefined && row.contentSnippet !== previous.content_snippet);
196
+ if (previous.lead_version !== LEAD_VERSION || (leadChanged && !previous.full_content_html))
197
+ refreshLead(previous.id);
198
+ }
199
+ else if (previous.lead_version !== LEAD_VERSION && !previous.full_content_html) {
186
200
  refreshLead(previous.id);
201
+ }
187
202
  }
188
203
  }
189
204
  }
@@ -193,7 +208,13 @@ export function upsertItems(feedId, items) {
193
208
  }
194
209
  export function listItemsForUser(userId, opts = {}) {
195
210
  // Saved articles stay visible without a subscription (e.g. after unsubscribing).
196
- const conditions = ["(s.id IS NOT NULL OR b.item_id IS NOT NULL)"];
211
+ const conditions = [];
212
+ if (opts.bookmarkedOnly) {
213
+ conditions.push("b.item_id IS NOT NULL");
214
+ }
215
+ else {
216
+ conditions.push("(s.id IS NOT NULL OR b.item_id IS NOT NULL)");
217
+ }
197
218
  const params = [];
198
219
  if (opts.feedId) {
199
220
  conditions.push("i.feed_id = ?");
@@ -297,6 +318,51 @@ export function findItemForUser(userId, itemId) {
297
318
  EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected WHERE e.user_id = ?
298
319
  AND selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed'))`).get(userId, userId, userId, userId, itemId, userId);
299
320
  }
321
+ export function findItemsForUser(userId, itemIds) {
322
+ if (!itemIds.length)
323
+ return [];
324
+ const unique = [...new Set(itemIds)];
325
+ return db.prepare(`SELECT i.*, COALESCE(NULLIF(TRIM(s.label), ''), NULLIF(TRIM(f.title), ''), f.url) AS feed_title,
326
+ f.site_url AS feed_site_url, f.url AS feed_url, s.full_text_mode AS feed_full_text_mode, f.icon_url AS feed_icon_url,
327
+ (r.item_id IS NOT NULL) AS read, (b.item_id IS NOT NULL) AS bookmarked, b.created_at AS bookmarked_at
328
+ FROM items i
329
+ JOIN feeds f ON f.id = i.feed_id
330
+ LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
331
+ LEFT JOIN item_reads r ON r.item_id = i.id AND r.user_id = ?
332
+ LEFT JOIN item_bookmarks b ON b.item_id = i.id AND b.user_id = ?
333
+ LEFT JOIN item_notes n ON n.item_id = i.id AND n.user_id = ?
334
+ WHERE i.id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))
335
+ AND (s.id IS NOT NULL OR b.item_id IS NOT NULL OR n.item_id IS NOT NULL OR
336
+ EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected WHERE e.user_id = ?
337
+ AND selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed'))`).all(userId, userId, userId, userId, JSON.stringify(unique), userId);
338
+ }
339
+ export function findSubscriptionForUser(userId, feedId) {
340
+ return db
341
+ .prepare(`SELECT
342
+ f.*,
343
+ fi.hash AS icon_hash,
344
+ s.id AS subscription_id,
345
+ NULLIF(TRIM(s.label), '') AS label,
346
+ s.folder_id AS folder_id,
347
+ fo.name AS folder_name,
348
+ s.position AS position,
349
+ s.full_text_mode AS full_text_mode,
350
+ s.notify AS notify,
351
+ s.badge AS badge,
352
+ (
353
+ SELECT COUNT(*) FROM items i
354
+ WHERE i.feed_id = f.id
355
+ AND NOT EXISTS (
356
+ SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = ?
357
+ )
358
+ ) AS unread_count
359
+ FROM subscriptions s
360
+ JOIN feeds f ON f.id = s.feed_id
361
+ LEFT JOIN feed_icons fi ON fi.feed_id = f.id
362
+ LEFT JOIN folders fo ON fo.id = s.folder_id
363
+ WHERE s.user_id = ? AND s.feed_id = ?`)
364
+ .get(userId, userId, feedId) ?? null;
365
+ }
300
366
  export function updateSubscriptionLabel(userId, feedId, label) {
301
367
  db.prepare("UPDATE subscriptions SET label = ? WHERE user_id = ? AND feed_id = ?").run(label?.trim() || null, userId, feedId);
302
368
  }
@@ -678,9 +744,24 @@ export function searchItemsForUser(userId, opts) {
678
744
  }
679
745
  const ranked = opts.sort !== "date" && fts;
680
746
  // Materialize once: a virtual-table MATCH in the LEFT JOIN repeats the full ranking scan for each article.
747
+ // Page IDs before loading HTML to avoid copying large content blobs into temporary B-trees during sorting.
681
748
  const querySql = `
682
- ${ranked ? "WITH ranked_matches AS MATERIALIZED (SELECT rowid, rank FROM items_fts WHERE items_fts MATCH ?)" : ""}
683
- SELECT DISTINCT i.*,
749
+ ${ranked ? "WITH ranked_matches AS MATERIALIZED (SELECT rowid, rank FROM items_fts WHERE items_fts MATCH ?)," : "WITH"}
750
+ page AS (
751
+ SELECT i.id
752
+ FROM items i
753
+ JOIN feeds f ON f.id = i.feed_id
754
+ LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
755
+ LEFT JOIN item_reads r ON r.item_id = i.id AND r.user_id = ?
756
+ LEFT JOIN item_bookmarks b ON b.item_id = i.id AND b.user_id = ?
757
+ LEFT JOIN item_notes n ON n.item_id = i.id AND n.user_id = ?
758
+ ${ranked ? "LEFT JOIN ranked_matches fts ON fts.rowid = i.id" : ""}
759
+ WHERE ${conditions.join(" AND ")}
760
+ GROUP BY i.id
761
+ ORDER BY ${orderBy}
762
+ LIMIT ? OFFSET ?
763
+ )
764
+ SELECT i.*,
684
765
  COALESCE(NULLIF(TRIM(s.label), ''), NULLIF(TRIM(f.title), ''), f.url) AS feed_title,
685
766
  f.site_url AS feed_site_url,
686
767
  f.url AS feed_url,
@@ -689,16 +770,15 @@ export function searchItemsForUser(userId, opts) {
689
770
  (r.item_id IS NOT NULL) AS read,
690
771
  (b.item_id IS NOT NULL) AS bookmarked,
691
772
  b.created_at AS bookmarked_at
692
- FROM items i
773
+ FROM page
774
+ JOIN items i ON i.id = page.id
693
775
  JOIN feeds f ON f.id = i.feed_id
694
776
  LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
695
777
  LEFT JOIN item_reads r ON r.item_id = i.id AND r.user_id = ?
696
778
  LEFT JOIN item_bookmarks b ON b.item_id = i.id AND b.user_id = ?
697
779
  LEFT JOIN item_notes n ON n.item_id = i.id AND n.user_id = ?
698
780
  ${ranked ? "LEFT JOIN ranked_matches fts ON fts.rowid = i.id" : ""}
699
- WHERE ${conditions.join(" AND ")}
700
781
  ORDER BY ${orderBy}
701
- LIMIT ? OFFSET ?
702
782
  `;
703
783
  const queryParams = [
704
784
  ...(ranked ? [fts] : []),
@@ -709,6 +789,10 @@ export function searchItemsForUser(userId, opts) {
709
789
  ...params,
710
790
  limit,
711
791
  offset,
792
+ userId,
793
+ userId,
794
+ userId,
795
+ userId,
712
796
  ];
713
797
  const items = db.prepare(querySql).all(...queryParams);
714
798
  return { items, total };
@@ -1,5 +1,5 @@
1
1
  import { config } from "../config.js";
2
- import { createFeed, findFeedByUrl, isUserSubscribed, subscribe, unsubscribe as unsubscribeRepo, listSubscriptionsForUser, updateSubscriptionLabel, updateSubscriptionFolder, updateFeedPollInterval, updateSubscriptionFullTextMode, updateSubscriptionNotify, updateSubscriptionBadge, findFolderById, findFeedById, countFeedSubscribers, replaceFeedUrl, moveSubscription, } from "./repository.js";
2
+ import { createFeed, findFeedByUrl, isUserSubscribed, subscribe, unsubscribe as unsubscribeRepo, findSubscriptionForUser, updateSubscriptionLabel, updateSubscriptionFolder, updateFeedPollInterval, updateSubscriptionFullTextMode, updateSubscriptionNotify, updateSubscriptionBadge, findFolderById, findFeedById, countFeedSubscribers, replaceFeedUrl, moveSubscription, } from "./repository.js";
3
3
  import { pollFeed, probeFeed } from "./poller.js";
4
4
  import { assertPublicHttpUrl } from "./ssrfGuard.js";
5
5
  import { discoverFeeds } from "./discovery.js";
@@ -66,7 +66,7 @@ export async function subscribeToFeed(userId, inputUrl, label = null, folderId =
66
66
  unsubscribeRepo(userId, feed.id);
67
67
  throw new NoFeedsFoundError();
68
68
  }
69
- const subscription = listSubscriptionsForUser(userId).find((s) => s.id === feed.id);
69
+ const subscription = findSubscriptionForUser(userId, feed.id);
70
70
  if (!subscription)
71
71
  throw new FeedError("subscription_lookup_failed");
72
72
  return subscription;
@@ -109,7 +109,7 @@ export function updateFeedSettings(userId, feedId, settings) {
109
109
  const minutes = Math.max(settings.pollIntervalMinutes, config.minPollIntervalMinutes);
110
110
  updateFeedPollInterval(feedId, minutes);
111
111
  }
112
- const subscription = listSubscriptionsForUser(userId).find((s) => s.id === feedId);
112
+ const subscription = findSubscriptionForUser(userId, feedId);
113
113
  if (!subscription)
114
114
  throw new FeedError("subscription_lookup_failed");
115
115
  return subscription;
@@ -127,7 +127,7 @@ export async function changeFeedUrl(userId, feedId, inputUrl) {
127
127
  const { url } = await resolveFeedUrl(inputUrl);
128
128
  let target = feedId;
129
129
  const lookup = () => {
130
- const subscription = listSubscriptionsForUser(userId).find((s) => s.id === target);
130
+ const subscription = findSubscriptionForUser(userId, target);
131
131
  if (!subscription)
132
132
  throw new FeedError("subscription_lookup_failed");
133
133
  return subscription;
@@ -214,19 +214,23 @@ export function editionCandidates(userId, now = new Date(), limit = 200) {
214
214
  const previousItemIds = db.prepare("SELECT item_ids FROM edition_history WHERE user_id = ? AND created_at >= ?").all(userId, new Date(now.getTime() - 72 * 3_600_000).toISOString()).flatMap((row) => JSON.parse(row.item_ids));
215
215
  const previous = new Set(previousItemIds);
216
216
  // Fetch bounded candidates per source, so a busy feed cannot hide the others.
217
- const rows = db.prepare(`SELECT * FROM (
218
- SELECT i.id, i.feed_id, s.folder_id, i.title, i.link, i.content_snippet, i.image_url,
219
- coalesce(s.label, f.title) AS source_name, folders.name AS folder_name,
220
- strftime('%Y-%m-%dT%H:%M:%fZ', coalesce(julianday(i.published_at), julianday(i.created_at))) AS published,
221
- length(coalesce(i.full_content_html, i.content_html, i.content_snippet, '')) AS content_chars,
222
- row_number() OVER (PARTITION BY i.feed_id ORDER BY coalesce(julianday(i.published_at), julianday(i.created_at)) DESC, i.id DESC) AS source_rank
223
- FROM items i JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
224
- JOIN feeds f ON f.id = i.feed_id LEFT JOIN folders ON folders.id = s.folder_id
225
- WHERE NOT EXISTS (SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = ?)
226
- AND NOT EXISTS (SELECT 1 FROM user_muted_keywords m WHERE m.user_id = ? AND
227
- (instr(lower(coalesce(i.title, '')), lower(m.keyword)) > 0 OR instr(lower(coalesce(i.content_snippet, '')), lower(m.keyword)) > 0))
228
- AND coalesce(julianday(i.published_at), julianday(i.created_at)) BETWEEN julianday(?) AND julianday(?)
229
- ) WHERE source_rank <= 40 ORDER BY source_rank, published DESC, id DESC LIMIT 4000`)
217
+ const rows = db.prepare(`SELECT r.id, r.feed_id, r.folder_id, r.title, r.link, r.content_snippet, r.image_url,
218
+ r.source_name, r.folder_name, r.published, r.source_rank,
219
+ length(coalesce(full_item.full_content_html, full_item.content_html, r.content_snippet, '')) AS content_chars
220
+ FROM (
221
+ SELECT i.id, i.feed_id, s.folder_id, i.title, i.link, i.content_snippet, i.image_url,
222
+ coalesce(s.label, f.title) AS source_name, folders.name AS folder_name,
223
+ strftime('%Y-%m-%dT%H:%M:%fZ', coalesce(julianday(i.published_at), julianday(i.created_at))) AS published,
224
+ row_number() OVER (PARTITION BY i.feed_id ORDER BY coalesce(julianday(i.published_at), julianday(i.created_at)) DESC, i.id DESC) AS source_rank
225
+ FROM items i JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
226
+ JOIN feeds f ON f.id = i.feed_id LEFT JOIN folders ON folders.id = s.folder_id
227
+ WHERE NOT EXISTS (SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = ?)
228
+ AND NOT EXISTS (SELECT 1 FROM user_muted_keywords m WHERE m.user_id = ? AND
229
+ (instr(lower(coalesce(i.title, '')), lower(m.keyword)) > 0 OR instr(lower(coalesce(i.content_snippet, '')), lower(m.keyword)) > 0))
230
+ AND coalesce(julianday(i.published_at), julianday(i.created_at)) BETWEEN julianday(?) AND julianday(?)
231
+ ) r
232
+ JOIN items full_item ON full_item.id = r.id
233
+ WHERE r.source_rank <= 40 ORDER BY r.source_rank, r.published DESC, r.id DESC LIMIT 4000`)
230
234
  .all(userId, userId, userId, new Date(now.getTime() - 7 * 86_400_000).toISOString(), now.toISOString());
231
235
  const scored = rows.filter((row) => row.folder_id === null ? prefs.showUnfiled : !prefs.hiddenFolderIds.includes(row.folder_id))
232
236
  .map((row) => ({
@@ -72,13 +72,20 @@ export function rotateGrant(refreshToken, clientId) {
72
72
  return { ok: true, scope: grant.scope, tokens: { accessToken, refreshToken: newRefresh, expiresIn: ACCESS_TTL_MS / 1000 } };
73
73
  })();
74
74
  }
75
+ const grantLastTouch = new Map();
76
+ const GRANT_TOUCH_INTERVAL_MS = 5 * 60 * 1000;
75
77
  export function resolveAccessToken(token) {
76
78
  const row = db
77
79
  .prepare("SELECT * FROM oauth_grants WHERE access_hash = ?")
78
80
  .get(hash(token));
79
81
  if (!row || row.access_expires_at <= now())
80
82
  return null;
81
- db.prepare("UPDATE oauth_grants SET last_used_at = ? WHERE id = ?").run(now(), row.id);
83
+ const nowMs = Date.now();
84
+ const lastTouch = grantLastTouch.get(row.id) ?? 0;
85
+ if (!row.last_used_at || nowMs - lastTouch >= GRANT_TOUCH_INTERVAL_MS) {
86
+ grantLastTouch.set(row.id, nowMs);
87
+ db.prepare("UPDATE oauth_grants SET last_used_at = ? WHERE id = ?").run(now(), row.id);
88
+ }
82
89
  return { userId: row.user_id, scope: row.scope, grantId: row.id };
83
90
  }
84
91
  /** Revokes by either token. Returns whether a grant was removed. */
@@ -89,6 +96,7 @@ export function revokeByToken(token, clientId) {
89
96
  .get(tokenHash, tokenHash);
90
97
  if (!grant || (clientId && grant.client_id !== clientId))
91
98
  return false;
99
+ grantLastTouch.delete(grant.id);
92
100
  return db.prepare("DELETE FROM oauth_grants WHERE id = ?").run(grant.id).changes > 0;
93
101
  }
94
102
  export function listGrantsForUser(userId) {
@@ -1,8 +1,7 @@
1
- import { contentMetadata } from "../feeds/articleContent.js";
2
- import { canAccessItem, findItemById } from "../feeds/repository.js";
1
+ import { canAccessItem } from "../feeds/repository.js";
3
2
  import { db } from "../db/index.js";
4
- import { findItemNote, listFoldersForUser, listMutedKeywords, listSubscriptionsForUser, } from "../feeds/repository.js";
5
- import { getEditionState } from "../native/editions.js";
3
+ import { listFoldersForUser, listMutedKeywords, listSubscriptionsForUser, } from "../feeds/repository.js";
4
+ import { getEdition, getEditionState } from "../native/editions.js";
6
5
  import { getNativePreferences } from "../native/preferences.js";
7
6
  const TOMBSTONE_DAYS = 90;
8
7
  const MAX_PAGE = 500;
@@ -26,21 +25,59 @@ export function serializeSubscription(subscription) {
26
25
  : `/api/v1/subscriptions/${subscription.id}/icon`,
27
26
  };
28
27
  }
29
- function itemState(userId, itemId) {
30
- const exists = db.prepare("SELECT 1 FROM items WHERE id = ?").get(itemId);
31
- if (!exists)
32
- return null;
33
- const row = db
34
- .prepare(`SELECT EXISTS (SELECT 1 FROM item_reads WHERE user_id = ? AND item_id = ?) AS read,
35
- (SELECT created_at FROM item_bookmarks WHERE user_id = ? AND item_id = ?) AS saved_at,
36
- (SELECT position FROM item_progress WHERE user_id = ? AND item_id = ?) AS progress`)
37
- .get(userId, itemId, userId, itemId, userId, itemId);
38
- return {
39
- read: Boolean(row.read),
40
- saved: row.saved_at !== null,
41
- savedAt: row.saved_at,
42
- progress: row.progress,
43
- };
28
+ function loadItemStates(userId, itemIds) {
29
+ const result = new Map();
30
+ if (!itemIds.length)
31
+ return result;
32
+ const rows = db
33
+ .prepare(`SELECT i.id,
34
+ r.item_id IS NOT NULL AS read,
35
+ b.created_at AS saved_at,
36
+ p.position AS progress
37
+ FROM items i
38
+ LEFT JOIN item_reads r ON r.user_id = ? AND r.item_id = i.id
39
+ LEFT JOIN item_bookmarks b ON b.user_id = ? AND b.item_id = i.id
40
+ LEFT JOIN item_progress p ON p.user_id = ? AND p.item_id = i.id
41
+ WHERE i.id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
42
+ .all(userId, userId, userId, JSON.stringify(itemIds));
43
+ for (const row of rows) {
44
+ result.set(row.id, {
45
+ read: Boolean(row.read),
46
+ saved: row.saved_at !== null,
47
+ savedAt: row.saved_at,
48
+ progress: row.progress,
49
+ });
50
+ }
51
+ return result;
52
+ }
53
+ function loadItemContents(userId, itemIds) {
54
+ const result = new Map();
55
+ if (!itemIds.length)
56
+ return result;
57
+ const rows = db
58
+ .prepare(`SELECT i.id,
59
+ i.content_revision,
60
+ CASE WHEN i.full_content_html IS NOT NULL AND i.full_content_html != '' THEN 'reader'
61
+ WHEN i.content_html IS NOT NULL AND i.content_html != '' THEN 'feed'
62
+ ELSE 'snippet' END AS source,
63
+ i.extraction_status,
64
+ i.extraction_attempted_at,
65
+ i.extraction_retry_at
66
+ FROM items i
67
+ WHERE i.id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
68
+ .all(JSON.stringify(itemIds));
69
+ for (const row of rows) {
70
+ if (!canAccessItem(userId, row.id))
71
+ continue;
72
+ result.set(row.id, {
73
+ revision: row.content_revision,
74
+ source: row.source,
75
+ status: row.extraction_status,
76
+ attemptedAt: row.extraction_attempted_at,
77
+ retryAt: row.extraction_retry_at,
78
+ });
79
+ }
80
+ return result;
44
81
  }
45
82
  /**
46
83
  * Add the data a user already had before the log existed. Everything that is in the log
@@ -83,7 +120,12 @@ export class ResyncRequired extends Error {
83
120
  }
84
121
  }
85
122
  export function currentSeq(userId) {
86
- return db.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM changes WHERE user_id = ?").get(userId).seq;
123
+ return db
124
+ .prepare(`SELECT MAX(
125
+ COALESCE((SELECT MAX(seq) FROM changes WHERE user_id = ?), 0),
126
+ COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = ?), 0)
127
+ ) AS seq`)
128
+ .get(userId, userId).seq;
87
129
  }
88
130
  /** Everything that changed after `since`, oldest first, with the current data of each object. */
89
131
  export function listChanges(userId, since, limit = 200) {
@@ -101,29 +143,63 @@ export function listChanges(userId, since, limit = 200) {
101
143
  .all(userId, since, page + 1);
102
144
  const hasMore = rows.length > page;
103
145
  const slice = rows.slice(0, page);
104
- const subscriptions = new Map(listSubscriptionsForUser(userId).map((subscription) => [subscription.id, subscription]));
105
- const folders = new Map(listFoldersForUser(userId).map((folder) => [folder.id, folder]));
106
- const keywords = new Map(listMutedKeywords(userId).map((keyword) => [keyword.id, keyword]));
146
+ const hasUpsert = (entity) => slice.some((row) => row.entity === entity && row.op !== "delete");
147
+ const subscriptions = hasUpsert("subscription")
148
+ ? new Map(listSubscriptionsForUser(userId).map((subscription) => [subscription.id, subscription]))
149
+ : new Map();
150
+ const folders = hasUpsert("folder")
151
+ ? new Map(listFoldersForUser(userId).map((folder) => [folder.id, folder]))
152
+ : new Map();
153
+ const keywords = hasUpsert("muted_keyword")
154
+ ? new Map(listMutedKeywords(userId).map((keyword) => [keyword.id, keyword]))
155
+ : new Map();
156
+ const stateIds = [...new Set(slice.filter((row) => row.entity === "item_state" && row.op !== "delete").map((row) => row.entity_id))];
157
+ const itemStates = loadItemStates(userId, stateIds);
158
+ const contentIds = [...new Set(slice.filter((row) => row.entity === "item_content" && row.op !== "delete").map((row) => row.entity_id))];
159
+ const itemContents = loadItemContents(userId, contentIds);
160
+ const noteUpsertIds = [...new Set(slice.filter((row) => row.entity === "note" && row.op !== "delete").map((row) => row.entity_id))];
161
+ const notes = new Map();
162
+ if (noteUpsertIds.length) {
163
+ const noteRows = db
164
+ .prepare(`SELECT n.item_id, n.content, COALESCE(r.revision, 1) AS revision, n.created_at, n.updated_at
165
+ FROM item_notes n
166
+ LEFT JOIN item_note_revisions r ON r.user_id = n.user_id AND r.item_id = n.item_id
167
+ WHERE n.user_id = ? AND n.item_id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
168
+ .all(userId, JSON.stringify(noteUpsertIds));
169
+ for (const note of noteRows)
170
+ notes.set(note.item_id, note);
171
+ }
172
+ const noteDeleteIds = [...new Set(slice.filter((row) => row.entity === "note" && row.op === "delete").map((row) => row.entity_id))];
173
+ const noteRevisions = new Map();
174
+ if (noteDeleteIds.length) {
175
+ const revRows = db
176
+ .prepare(`SELECT item_id, revision FROM item_note_revisions
177
+ WHERE user_id = ? AND item_id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
178
+ .all(userId, JSON.stringify(noteDeleteIds));
179
+ for (const rev of revRows)
180
+ noteRevisions.set(rev.item_id, rev.revision);
181
+ }
107
182
  const changes = slice.map((row) => {
108
183
  const change = { seq: row.seq, entity: row.entity, id: row.entity_id, op: row.op };
109
184
  if (row.entity === "edition") {
110
185
  const state = getEditionState(userId);
111
- return state.edition ? { ...change, op: "upsert", data: { ...state.edition } } :
186
+ const edition = getEdition(userId);
187
+ return edition ? { ...change, op: "upsert", data: { ...edition } } :
112
188
  { ...change, op: "delete", data: { revision: state.revision, status: state.status } };
113
189
  }
114
190
  if (row.entity === "note" && row.op === "delete") {
115
- const version = db.prepare("SELECT revision FROM item_note_revisions WHERE user_id = ? AND item_id = ?").get(userId, row.entity_id);
116
- return version ? { ...change, data: { revision: version.revision } } : change;
191
+ const revision = noteRevisions.get(row.entity_id);
192
+ return revision !== undefined ? { ...change, data: { revision } } : change;
117
193
  }
118
194
  if (row.op === "delete")
119
195
  return change;
120
196
  let data;
121
197
  if (row.entity === "item_content") {
122
- const item = findItemById(row.entity_id);
123
- data = item && canAccessItem(userId, row.entity_id) ? contentMetadata(item) : null;
198
+ data = itemContents.get(row.entity_id) ?? null;
199
+ }
200
+ else if (row.entity === "item_state") {
201
+ data = itemStates.get(row.entity_id) ?? null;
124
202
  }
125
- else if (row.entity === "item_state")
126
- data = itemState(userId, row.entity_id);
127
203
  else if (row.entity === "subscription") {
128
204
  const subscription = subscriptions.get(row.entity_id);
129
205
  data = subscription && serializeSubscription(subscription);
@@ -137,7 +213,7 @@ export function listChanges(userId, since, limit = 200) {
137
213
  data = keyword && { keyword: keyword.keyword };
138
214
  }
139
215
  else if (row.entity === "note") {
140
- const note = findItemNote(userId, row.entity_id);
216
+ const note = notes.get(row.entity_id);
141
217
  data = note && {
142
218
  itemId: note.item_id,
143
219
  content: note.content,
@@ -158,8 +234,11 @@ export function listChanges(userId, since, limit = 200) {
158
234
  export function pruneChangeLog() {
159
235
  const cutoff = new Date(Date.now() - TOMBSTONE_DAYS * 86_400_000).toISOString();
160
236
  return db.transaction(() => {
161
- db.prepare(`UPDATE sync_state SET pruned_through = MAX(pruned_through, COALESCE(
162
- (SELECT MAX(seq) FROM changes WHERE changes.user_id = sync_state.user_id AND op = 'delete' AND changed_at < ?), 0))`).run(cutoff);
237
+ db.prepare(`UPDATE sync_state SET
238
+ last_seq = MAX(last_seq, pruned_through, COALESCE(
239
+ (SELECT MAX(seq) FROM changes WHERE changes.user_id = sync_state.user_id), 0)),
240
+ pruned_through = MAX(pruned_through, COALESCE(
241
+ (SELECT MAX(seq) FROM changes WHERE changes.user_id = sync_state.user_id AND op = 'delete' AND changed_at < ?), 0))`).run(cutoff);
163
242
  const old = db.prepare("DELETE FROM changes WHERE op = 'delete' AND changed_at < ?").run(cutoff).changes;
164
243
  const gone = db.prepare("DELETE FROM changes WHERE entity IN ('item_state', 'item_content') AND entity_id NOT IN (SELECT id FROM items)").run().changes;
165
244
  const goneNotes = db.prepare("DELETE FROM changes WHERE entity = 'note' AND entity_id NOT IN (SELECT id FROM items)").run().changes;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.27.0",
3
+ "version": "0.27.2",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }