@rmdes/indiekit-endpoint-microsub 1.0.30 → 1.0.31

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.
@@ -115,6 +115,7 @@ export async function getChannels(application, userId) {
115
115
  channelId: channel._id,
116
116
  readBy: { $ne: userId },
117
117
  published: { $gte: cutoffDate },
118
+ _stripped: { $ne: true },
118
119
  });
119
120
 
120
121
  return {
@@ -87,8 +87,9 @@ export async function getTimelineItems(application, channelId, options = {}) {
87
87
  typeof channelId === "string" ? new ObjectId(channelId) : channelId;
88
88
  const limit = parseLimit(options.limit);
89
89
 
90
- // Base query - filter out read items unless showRead is true
91
- const baseQuery = { channelId: objectId };
90
+ // Base query - filter out read items unless showRead is true,
91
+ // and always exclude stripped dedup skeletons (no content to display)
92
+ const baseQuery = { channelId: objectId, _stripped: { $ne: true } };
92
93
  if (options.userId && !options.showRead) {
93
94
  baseQuery.readBy = { $ne: options.userId };
94
95
  }
@@ -288,61 +289,83 @@ export async function countReadItems(application, channelId, userId) {
288
289
  * @param {string} userId - User ID
289
290
  * @returns {Promise<number>} Number of items updated
290
291
  */
291
- // Maximum number of read items to keep per channel
292
- const MAX_READ_ITEMS = 30;
292
+ // Maximum number of full read items to keep per channel before stripping content.
293
+ // Items beyond this limit are converted to lightweight dedup skeletons (channelId,
294
+ // uid, readBy) so the poller doesn't re-ingest them as new unread entries.
295
+ const MAX_FULL_READ_ITEMS = 200;
293
296
 
294
297
  /**
295
- * Cleanup old read items, keeping only the most recent MAX_READ_ITEMS
298
+ * Cleanup old read items by stripping content but preserving dedup skeletons.
299
+ * This prevents the vicious cycle where deleted read items get re-ingested as
300
+ * unread by the poller because the dedup record (channelId + uid) was destroyed.
296
301
  * @param {object} collection - MongoDB collection
297
302
  * @param {ObjectId} channelObjectId - Channel ObjectId
298
303
  * @param {string} userId - User ID
299
304
  */
300
305
  async function cleanupOldReadItems(collection, channelObjectId, userId) {
301
- // Count read items in this channel
302
306
  const readCount = await collection.countDocuments({
303
307
  channelId: channelObjectId,
304
308
  readBy: userId,
305
309
  });
306
310
 
307
- if (readCount > MAX_READ_ITEMS) {
308
- // Find the oldest read items to delete
309
- const itemsToDelete = await collection
311
+ if (readCount > MAX_FULL_READ_ITEMS) {
312
+ // Find old read items beyond the retention limit
313
+ const itemsToStrip = await collection
310
314
  .find({
311
315
  channelId: channelObjectId,
312
316
  readBy: userId,
317
+ _stripped: { $ne: true }, // Don't re-strip already-stripped items
313
318
  })
314
- .sort({ published: -1, _id: -1 }) // Newest first
315
- .skip(MAX_READ_ITEMS) // Skip the ones we want to keep
319
+ .sort({ published: -1, _id: -1 })
320
+ .skip(MAX_FULL_READ_ITEMS)
316
321
  .project({ _id: 1 })
317
322
  .toArray();
318
323
 
319
- if (itemsToDelete.length > 0) {
320
- const idsToDelete = itemsToDelete.map((item) => item._id);
321
- const deleteResult = await collection.deleteMany({
322
- _id: { $in: idsToDelete },
323
- });
324
+ if (itemsToStrip.length > 0) {
325
+ const idsToStrip = itemsToStrip.map((item) => item._id);
326
+ // Strip content but keep dedup skeleton (channelId, uid, feedId, readBy)
327
+ const result = await collection.updateMany(
328
+ { _id: { $in: idsToStrip } },
329
+ {
330
+ $set: { _stripped: true },
331
+ $unset: {
332
+ name: "",
333
+ content: "",
334
+ summary: "",
335
+ author: "",
336
+ category: "",
337
+ photo: "",
338
+ video: "",
339
+ audio: "",
340
+ likeOf: "",
341
+ repostOf: "",
342
+ bookmarkOf: "",
343
+ inReplyTo: "",
344
+ source: "",
345
+ },
346
+ },
347
+ );
324
348
  console.info(
325
- `[Microsub] Cleaned up ${deleteResult.deletedCount} old read items (keeping ${MAX_READ_ITEMS})`,
349
+ `[Microsub] Stripped content from ${result.modifiedCount} old read items (keeping ${MAX_FULL_READ_ITEMS} full)`,
326
350
  );
327
351
  }
328
352
  }
329
353
  }
330
354
 
331
355
  /**
332
- * Cleanup all read items across all channels (startup cleanup)
356
+ * Cleanup all read items across all channels (startup cleanup).
357
+ * Strips content from old read items but preserves dedup skeletons.
333
358
  * @param {object} application - Indiekit application
334
- * @returns {Promise<number>} Total number of items deleted
359
+ * @returns {Promise<number>} Total number of items stripped
335
360
  */
336
361
  export async function cleanupAllReadItems(application) {
337
362
  const collection = getCollection(application);
338
363
  const channelsCollection = application.collections.get("microsub_channels");
339
364
 
340
- // Get all channels
341
365
  const channels = await channelsCollection.find({}).toArray();
342
- let totalDeleted = 0;
366
+ let totalStripped = 0;
343
367
 
344
368
  for (const channel of channels) {
345
- // Get unique userIds who have read items in this channel
346
369
  const readByUsers = await collection.distinct("readBy", {
347
370
  channelId: channel._id,
348
371
  readBy: { $exists: true, $ne: [] },
@@ -354,40 +377,60 @@ export async function cleanupAllReadItems(application) {
354
377
  const readCount = await collection.countDocuments({
355
378
  channelId: channel._id,
356
379
  readBy: userId,
380
+ _stripped: { $ne: true },
357
381
  });
358
382
 
359
- if (readCount > MAX_READ_ITEMS) {
360
- const itemsToDelete = await collection
383
+ if (readCount > MAX_FULL_READ_ITEMS) {
384
+ const itemsToStrip = await collection
361
385
  .find({
362
386
  channelId: channel._id,
363
387
  readBy: userId,
388
+ _stripped: { $ne: true },
364
389
  })
365
390
  .sort({ published: -1, _id: -1 })
366
- .skip(MAX_READ_ITEMS)
391
+ .skip(MAX_FULL_READ_ITEMS)
367
392
  .project({ _id: 1 })
368
393
  .toArray();
369
394
 
370
- if (itemsToDelete.length > 0) {
371
- const idsToDelete = itemsToDelete.map((item) => item._id);
372
- const deleteResult = await collection.deleteMany({
373
- _id: { $in: idsToDelete },
374
- });
375
- totalDeleted += deleteResult.deletedCount;
395
+ if (itemsToStrip.length > 0) {
396
+ const idsToStrip = itemsToStrip.map((item) => item._id);
397
+ const result = await collection.updateMany(
398
+ { _id: { $in: idsToStrip } },
399
+ {
400
+ $set: { _stripped: true },
401
+ $unset: {
402
+ name: "",
403
+ content: "",
404
+ summary: "",
405
+ author: "",
406
+ category: "",
407
+ photo: "",
408
+ video: "",
409
+ audio: "",
410
+ likeOf: "",
411
+ repostOf: "",
412
+ bookmarkOf: "",
413
+ inReplyTo: "",
414
+ source: "",
415
+ },
416
+ },
417
+ );
418
+ totalStripped += result.modifiedCount;
376
419
  console.info(
377
- `[Microsub] Startup cleanup: deleted ${deleteResult.deletedCount} old items from channel "${channel.name}"`,
420
+ `[Microsub] Startup cleanup: stripped ${result.modifiedCount} old items from channel "${channel.name}"`,
378
421
  );
379
422
  }
380
423
  }
381
424
  }
382
425
  }
383
426
 
384
- if (totalDeleted > 0) {
427
+ if (totalStripped > 0) {
385
428
  console.info(
386
- `[Microsub] Startup cleanup complete: ${totalDeleted} total items deleted`,
429
+ `[Microsub] Startup cleanup complete: ${totalStripped} total items stripped`,
387
430
  );
388
431
  }
389
432
 
390
- return totalDeleted;
433
+ return totalStripped;
391
434
  }
392
435
 
393
436
  export async function markItemsRead(application, channelId, entryIds, userId) {
@@ -446,9 +489,6 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
446
489
  `[Microsub] markItemsRead result: ${result.modifiedCount} items updated`,
447
490
  );
448
491
 
449
- // Cleanup old read items, keeping only the most recent
450
- await cleanupOldReadItems(collection, channelObjectId, userId);
451
-
452
492
  return result.modifiedCount;
453
493
  }
454
494
 
@@ -577,7 +617,7 @@ export async function getUnreadCount(application, channelId, userId) {
577
617
  const objectId =
578
618
  typeof channelId === "string" ? new ObjectId(channelId) : channelId;
579
619
 
580
- // Only count items from the last UNREAD_RETENTION_DAYS
620
+ // Only count items from the last UNREAD_RETENTION_DAYS, exclude stripped skeletons
581
621
  const cutoffDate = new Date();
582
622
  cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS);
583
623
 
@@ -585,6 +625,7 @@ export async function getUnreadCount(application, channelId, userId) {
585
625
  channelId: objectId,
586
626
  readBy: { $ne: userId },
587
627
  published: { $gte: cutoffDate },
628
+ _stripped: { $ne: true },
588
629
  });
589
630
  }
590
631
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.30",
3
+ "version": "1.0.31",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",