@rmdes/indiekit-endpoint-rss 1.0.14 → 1.0.16

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/README.md CHANGED
@@ -36,7 +36,8 @@ export default {
36
36
  maxItemsPerFeed: 50, // Max items per feed to cache
37
37
  fetchTimeout: 10_000, // 10 second timeout per feed
38
38
  maxConcurrentFetches: 3, // Parallel feed fetches
39
- retentionDays: 30 // Days to keep items
39
+ retentionDays: 30, // Days to keep items
40
+ minItemsPerFeed: 10 // Newest items always kept, whatever their age
40
41
  })
41
42
  ],
42
43
  // MongoDB database is REQUIRED
package/index.js CHANGED
@@ -7,6 +7,7 @@ import { feedsController } from "./lib/controllers/feeds.js";
7
7
  import { itemsController } from "./lib/controllers/items.js";
8
8
  import { statusController } from "./lib/controllers/status.js";
9
9
  import { startSync } from "./lib/sync.js";
10
+ import { waitForReady } from "@rmdes/indiekit-startup-gate";
10
11
 
11
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
13
 
@@ -20,6 +21,7 @@ const defaults = {
20
21
  fetchTimeout: 10_000,
21
22
  maxConcurrentFetches: 3,
22
23
  retentionDays: 30,
24
+ minItemsPerFeed: 10,
23
25
  };
24
26
 
25
27
  export default class RssEndpoint {
@@ -110,7 +112,14 @@ export default class RssEndpoint {
110
112
 
111
113
  // Start background sync if database is available
112
114
  if (Indiekit.config.application.mongodbUrl) {
113
- startSync(Indiekit, this.options);
115
+ this._stopGate = waitForReady(
116
+ () => startSync(Indiekit, this.options),
117
+ { label: "RSS" },
118
+ );
114
119
  }
115
120
  }
121
+
122
+ destroy() {
123
+ this._stopGate?.();
124
+ }
116
125
  }
package/lib/sync.js CHANGED
@@ -122,6 +122,7 @@ export async function runSync(dbOrIndiekit, options) {
122
122
  itemsCollection,
123
123
  feedsCollection,
124
124
  retentionDays,
125
+ options.minItemsPerFeed ?? 10,
125
126
  );
126
127
 
127
128
  syncState.lastSync = new Date().toISOString();
@@ -258,40 +259,67 @@ async function createIndexes(feedsCollection, itemsCollection) {
258
259
  }
259
260
 
260
261
  /**
261
- * Prune items older than retention period and update feed item counts
262
+ * Prune items older than the retention period.
263
+ *
264
+ * The newest `minItemsPerFeed` items of every feed are kept whatever their
265
+ * age. Without that floor a low-traffic feed whose whole backlog predates the
266
+ * cutoff is emptied and refetched on every single sync, so it churns forever
267
+ * and always reads as empty.
262
268
  * @param {Collection} itemsCollection - Items collection
263
269
  * @param {Collection} feedsCollection - Feeds collection
264
270
  * @param {number} retentionDays - Days to keep items
271
+ * @param {number} minItemsPerFeed - Newest items always kept per feed
265
272
  * @returns {Promise<number>} Number of items pruned
266
273
  */
267
- async function pruneOldItems(itemsCollection, feedsCollection, retentionDays) {
274
+ export async function pruneOldItems(
275
+ itemsCollection,
276
+ feedsCollection,
277
+ retentionDays,
278
+ minItemsPerFeed = 10,
279
+ ) {
268
280
  const cutoff = new Date();
269
281
  cutoff.setDate(cutoff.getDate() - retentionDays);
270
282
 
271
283
  try {
272
- const result = await itemsCollection.deleteMany({
273
- pubDate: { $lt: cutoff },
274
- });
275
-
276
- if (result.deletedCount > 0) {
277
- console.log(
278
- `[RSS] Pruned ${result.deletedCount} items older than ${retentionDays} days`,
279
- );
280
-
281
- // Update item counts for all feeds
282
- const feeds = await feedsCollection.find({}).toArray();
283
- for (const feed of feeds) {
284
- const count = await itemsCollection.countDocuments({
284
+ const feeds = await feedsCollection.find({}).toArray();
285
+ let deletedCount = 0;
286
+
287
+ for (const feed of feeds) {
288
+ const keep = await itemsCollection
289
+ .find({ feedId: feed._id })
290
+ .sort({ pubDate: -1 })
291
+ .limit(minItemsPerFeed)
292
+ .project({ _id: 1 })
293
+ .toArray();
294
+
295
+ const result = await itemsCollection.deleteMany({
296
+ feedId: feed._id,
297
+ // $ne: null also excludes missing dates: in BSON null sorts before
298
+ // Date, so a bare $lt would delete every undated item as "too old".
299
+ pubDate: { $lt: cutoff, $ne: null },
300
+ _id: { $nin: keep.map((item) => item._id) },
301
+ });
302
+
303
+ if (result.deletedCount > 0) {
304
+ deletedCount += result.deletedCount;
305
+
306
+ const itemCount = await itemsCollection.countDocuments({
285
307
  feedId: feed._id,
286
308
  });
287
309
  await feedsCollection.updateOne(
288
310
  { _id: feed._id },
289
- { $set: { itemCount: count } },
311
+ { $set: { itemCount } },
290
312
  );
291
313
  }
292
314
  }
293
315
 
294
- return result.deletedCount;
316
+ if (deletedCount > 0) {
317
+ console.log(
318
+ `[RSS] Pruned ${deletedCount} items older than ${retentionDays} days`,
319
+ );
320
+ }
321
+
322
+ return deletedCount;
295
323
  } catch (err) {
296
324
  console.error("[RSS] Prune error:", err.message);
297
325
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-rss",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "RSS feed reader endpoint for Indiekit. Aggregates multiple feeds, caches in MongoDB, displays on frontend.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -30,6 +30,9 @@
30
30
  },
31
31
  "type": "module",
32
32
  "main": "index.js",
33
+ "scripts": {
34
+ "test": "node --test \"test/*.test.js\""
35
+ },
33
36
  "exports": {
34
37
  ".": "./index.js"
35
38
  },
@@ -41,6 +44,7 @@
41
44
  "index.js"
42
45
  ],
43
46
  "dependencies": {
47
+ "@rmdes/indiekit-startup-gate": "^1.0.0",
44
48
  "@indiekit/error": "^1.0.0-beta.25",
45
49
  "express": "^5.0.0",
46
50
  "rss-parser": "^3.13.0",