feedkeeper 0.27.1 → 0.28.0

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.
@@ -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,61 @@ 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
+ i.is_empty_gallery,
61
+ CASE WHEN i.full_content_html IS NOT NULL AND i.full_content_html != '' THEN 'reader'
62
+ WHEN i.content_html IS NOT NULL AND i.content_html != '' THEN 'feed'
63
+ ELSE 'snippet' END AS source,
64
+ i.extraction_status,
65
+ i.extraction_attempted_at,
66
+ i.extraction_retry_at
67
+ FROM items i
68
+ WHERE i.id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
69
+ .all(JSON.stringify(itemIds));
70
+ for (const row of rows) {
71
+ if (!canAccessItem(userId, row.id))
72
+ continue;
73
+ result.set(row.id, {
74
+ revision: row.content_revision,
75
+ source: row.source,
76
+ status: row.extraction_status,
77
+ attemptedAt: row.extraction_attempted_at,
78
+ retryAt: row.extraction_retry_at,
79
+ isEmptyGallery: Boolean(row.is_empty_gallery),
80
+ });
81
+ }
82
+ return result;
44
83
  }
45
84
  /**
46
85
  * Add the data a user already had before the log existed. Everything that is in the log
@@ -83,7 +122,12 @@ export class ResyncRequired extends Error {
83
122
  }
84
123
  }
85
124
  export function currentSeq(userId) {
86
- return db.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM changes WHERE user_id = ?").get(userId).seq;
125
+ return db
126
+ .prepare(`SELECT MAX(
127
+ COALESCE((SELECT MAX(seq) FROM changes WHERE user_id = ?), 0),
128
+ COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = ?), 0)
129
+ ) AS seq`)
130
+ .get(userId, userId).seq;
87
131
  }
88
132
  /** Everything that changed after `since`, oldest first, with the current data of each object. */
89
133
  export function listChanges(userId, since, limit = 200) {
@@ -101,29 +145,63 @@ export function listChanges(userId, since, limit = 200) {
101
145
  .all(userId, since, page + 1);
102
146
  const hasMore = rows.length > page;
103
147
  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]));
148
+ const hasUpsert = (entity) => slice.some((row) => row.entity === entity && row.op !== "delete");
149
+ const subscriptions = hasUpsert("subscription")
150
+ ? new Map(listSubscriptionsForUser(userId).map((subscription) => [subscription.id, subscription]))
151
+ : new Map();
152
+ const folders = hasUpsert("folder")
153
+ ? new Map(listFoldersForUser(userId).map((folder) => [folder.id, folder]))
154
+ : new Map();
155
+ const keywords = hasUpsert("muted_keyword")
156
+ ? new Map(listMutedKeywords(userId).map((keyword) => [keyword.id, keyword]))
157
+ : new Map();
158
+ const stateIds = [...new Set(slice.filter((row) => row.entity === "item_state" && row.op !== "delete").map((row) => row.entity_id))];
159
+ const itemStates = loadItemStates(userId, stateIds);
160
+ const contentIds = [...new Set(slice.filter((row) => row.entity === "item_content" && row.op !== "delete").map((row) => row.entity_id))];
161
+ const itemContents = loadItemContents(userId, contentIds);
162
+ const noteUpsertIds = [...new Set(slice.filter((row) => row.entity === "note" && row.op !== "delete").map((row) => row.entity_id))];
163
+ const notes = new Map();
164
+ if (noteUpsertIds.length) {
165
+ const noteRows = db
166
+ .prepare(`SELECT n.item_id, n.content, COALESCE(r.revision, 1) AS revision, n.created_at, n.updated_at
167
+ FROM item_notes n
168
+ LEFT JOIN item_note_revisions r ON r.user_id = n.user_id AND r.item_id = n.item_id
169
+ WHERE n.user_id = ? AND n.item_id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
170
+ .all(userId, JSON.stringify(noteUpsertIds));
171
+ for (const note of noteRows)
172
+ notes.set(note.item_id, note);
173
+ }
174
+ const noteDeleteIds = [...new Set(slice.filter((row) => row.entity === "note" && row.op === "delete").map((row) => row.entity_id))];
175
+ const noteRevisions = new Map();
176
+ if (noteDeleteIds.length) {
177
+ const revRows = db
178
+ .prepare(`SELECT item_id, revision FROM item_note_revisions
179
+ WHERE user_id = ? AND item_id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
180
+ .all(userId, JSON.stringify(noteDeleteIds));
181
+ for (const rev of revRows)
182
+ noteRevisions.set(rev.item_id, rev.revision);
183
+ }
107
184
  const changes = slice.map((row) => {
108
185
  const change = { seq: row.seq, entity: row.entity, id: row.entity_id, op: row.op };
109
186
  if (row.entity === "edition") {
110
187
  const state = getEditionState(userId);
111
- return state.edition ? { ...change, op: "upsert", data: { ...state.edition } } :
188
+ const edition = getEdition(userId);
189
+ return edition ? { ...change, op: "upsert", data: { ...edition } } :
112
190
  { ...change, op: "delete", data: { revision: state.revision, status: state.status } };
113
191
  }
114
192
  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;
193
+ const revision = noteRevisions.get(row.entity_id);
194
+ return revision !== undefined ? { ...change, data: { revision } } : change;
117
195
  }
118
196
  if (row.op === "delete")
119
197
  return change;
120
198
  let data;
121
199
  if (row.entity === "item_content") {
122
- const item = findItemById(row.entity_id);
123
- data = item && canAccessItem(userId, row.entity_id) ? contentMetadata(item) : null;
200
+ data = itemContents.get(row.entity_id) ?? null;
201
+ }
202
+ else if (row.entity === "item_state") {
203
+ data = itemStates.get(row.entity_id) ?? null;
124
204
  }
125
- else if (row.entity === "item_state")
126
- data = itemState(userId, row.entity_id);
127
205
  else if (row.entity === "subscription") {
128
206
  const subscription = subscriptions.get(row.entity_id);
129
207
  data = subscription && serializeSubscription(subscription);
@@ -137,7 +215,7 @@ export function listChanges(userId, since, limit = 200) {
137
215
  data = keyword && { keyword: keyword.keyword };
138
216
  }
139
217
  else if (row.entity === "note") {
140
- const note = findItemNote(userId, row.entity_id);
218
+ const note = notes.get(row.entity_id);
141
219
  data = note && {
142
220
  itemId: note.item_id,
143
221
  content: note.content,
@@ -158,8 +236,11 @@ export function listChanges(userId, since, limit = 200) {
158
236
  export function pruneChangeLog() {
159
237
  const cutoff = new Date(Date.now() - TOMBSTONE_DAYS * 86_400_000).toISOString();
160
238
  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);
239
+ db.prepare(`UPDATE sync_state SET
240
+ last_seq = MAX(last_seq, pruned_through, COALESCE(
241
+ (SELECT MAX(seq) FROM changes WHERE changes.user_id = sync_state.user_id), 0)),
242
+ pruned_through = MAX(pruned_through, COALESCE(
243
+ (SELECT MAX(seq) FROM changes WHERE changes.user_id = sync_state.user_id AND op = 'delete' AND changed_at < ?), 0))`).run(cutoff);
163
244
  const old = db.prepare("DELETE FROM changes WHERE op = 'delete' AND changed_at < ?").run(cutoff).changes;
164
245
  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
246
  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.1",
3
+ "version": "0.28.0",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }