@rmdes/indiekit-endpoint-microsub 1.0.41 → 1.0.43

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/index.js CHANGED
@@ -9,7 +9,11 @@ import { readerController } from "./lib/controllers/reader.js";
9
9
  import { handleMediaProxy } from "./lib/media/proxy.js";
10
10
  import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
11
11
  import { ensureActivityPubChannel } from "./lib/storage/channels.js";
12
- import { cleanupAllReadItems, createIndexes } from "./lib/storage/items.js";
12
+ import {
13
+ cleanupAllReadItems,
14
+ cleanupStaleItems,
15
+ createIndexes,
16
+ } from "./lib/storage/items.js";
13
17
  import { webmentionReceiver } from "./lib/webmention/receiver.js";
14
18
  import { websubHandler } from "./lib/websub/handler.js";
15
19
 
@@ -210,6 +214,11 @@ export default class MicrosubEndpoint {
210
214
  cleanupAllReadItems(indiekit).catch((error) => {
211
215
  console.warn("[Microsub] Startup cleanup failed:", error.message);
212
216
  });
217
+
218
+ // Delete stale items (stripped skeletons + unread older than 30 days)
219
+ cleanupStaleItems(indiekit).catch((error) => {
220
+ console.warn("[Microsub] Stale cleanup failed:", error.message);
221
+ });
213
222
  } else {
214
223
  console.warn(
215
224
  "[Microsub] Database not available at init, scheduler not started",
@@ -6,7 +6,7 @@
6
6
  import { IndiekitError } from "@indiekit/error";
7
7
 
8
8
  import { proxyItemImages } from "../media/proxy.js";
9
- import { getChannel } from "../storage/channels.js";
9
+ import { getChannel, getChannelById } from "../storage/channels.js";
10
10
  import {
11
11
  getTimelineItems,
12
12
  markItemsRead,
@@ -72,8 +72,16 @@ export async function action(request, response) {
72
72
 
73
73
  validateChannel(channel);
74
74
 
75
- // Verify channel exists
76
- const channelDocument = await getChannel(application, channel, userId);
75
+ // Verify channel exists — try by UID first, fall back to ObjectId
76
+ // (timeline view may send ObjectId string for items from orphan channels)
77
+ let channelDocument = await getChannel(application, channel, userId);
78
+ if (!channelDocument) {
79
+ try {
80
+ channelDocument = await getChannelById(application, channel);
81
+ } catch {
82
+ // Invalid ObjectId format — channel string is not a valid ObjectId
83
+ }
84
+ }
77
85
  if (!channelDocument) {
78
86
  throw new IndiekitError("Channel not found", {
79
87
  status: 404,
@@ -387,6 +387,10 @@ export async function countReadItems(application, channelId, userId) {
387
387
  // uid, readBy) so the poller doesn't re-ingest them as new unread entries.
388
388
  const MAX_FULL_READ_ITEMS = 200;
389
389
 
390
+ // Maximum age (in days) for stripped skeletons and unread items.
391
+ // After this period, both are hard-deleted to prevent unbounded growth.
392
+ const MAX_ITEM_AGE_DAYS = 30;
393
+
390
394
  /**
391
395
  * Cleanup old read items by stripping content but preserving dedup skeletons.
392
396
  * This prevents the vicious cycle where deleted read items get re-ingested as
@@ -575,6 +579,63 @@ export async function cleanupAllReadItems(application) {
575
579
  return totalCleaned;
576
580
  }
577
581
 
582
+ /**
583
+ * Delete stale items: stripped skeletons and unread items older than MAX_ITEM_AGE_DAYS.
584
+ * Stripped skeletons have served their dedup purpose; stale unread items are unlikely
585
+ * to be read. Both are hard-deleted to prevent unbounded collection growth.
586
+ * @param {object} application - Indiekit application
587
+ * @returns {Promise<number>} Total number of items deleted
588
+ */
589
+ export async function cleanupStaleItems(application) {
590
+ const collection = getCollection(application);
591
+ const cutoff = new Date();
592
+ cutoff.setDate(cutoff.getDate() - MAX_ITEM_AGE_DAYS);
593
+
594
+ // Delete stripped skeletons older than cutoff
595
+ const strippedResult = await collection.deleteMany({
596
+ _stripped: true,
597
+ $or: [
598
+ { published: { $lt: cutoff } },
599
+ { published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
600
+ ],
601
+ });
602
+
603
+ // Delete unread items older than cutoff
604
+ const unreadResult = await collection.deleteMany({
605
+ readBy: { $in: [null, []] },
606
+ _stripped: { $ne: true },
607
+ $or: [
608
+ { published: { $lt: cutoff } },
609
+ { published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
610
+ ],
611
+ });
612
+
613
+ // Also catch items with no readBy field at all
614
+ const noReadByResult = await collection.deleteMany({
615
+ readBy: { $exists: false },
616
+ _stripped: { $ne: true },
617
+ $or: [
618
+ { published: { $lt: cutoff } },
619
+ { published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
620
+ ],
621
+ });
622
+
623
+ const total =
624
+ strippedResult.deletedCount +
625
+ unreadResult.deletedCount +
626
+ noReadByResult.deletedCount;
627
+
628
+ if (total > 0) {
629
+ console.info(
630
+ `[Microsub] Stale cleanup: deleted ${strippedResult.deletedCount} stripped skeletons, ` +
631
+ `${unreadResult.deletedCount + noReadByResult.deletedCount} stale unread items ` +
632
+ `(cutoff: ${MAX_ITEM_AGE_DAYS} days)`,
633
+ );
634
+ }
635
+
636
+ return total;
637
+ }
638
+
578
639
  export async function markItemsRead(application, channelId, entryIds, userId) {
579
640
  const collection = getCollection(application);
580
641
  const channelObjectId =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.41",
3
+ "version": "1.0.43",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -203,6 +203,7 @@
203
203
  data-action="mark-read"
204
204
  data-item-id="{{ item._id }}"
205
205
  {% if item._channelUid %}data-channel-uid="{{ item._channelUid }}"{% endif %}
206
+ {% if item._channelId %}data-channel-id="{{ item._channelId }}"{% endif %}
206
207
  title="Mark as read">
207
208
  {{ icon("checkboxChecked") }}
208
209
  <span class="visually-hidden">Mark read</span>
@@ -108,7 +108,8 @@
108
108
 
109
109
  const itemId = button.dataset.itemId;
110
110
  const channelUid = button.dataset.channelUid;
111
- if (!itemId || !channelUid) return;
111
+ const channelId = button.dataset.channelId;
112
+ if (!itemId || (!channelUid && !channelId)) return;
112
113
 
113
114
  button.disabled = true;
114
115
 
@@ -116,7 +117,7 @@
116
117
  const formData = new URLSearchParams();
117
118
  formData.append('action', 'timeline');
118
119
  formData.append('method', 'mark_read');
119
- formData.append('channel', channelUid);
120
+ formData.append('channel', channelUid || channelId);
120
121
  formData.append('entry', itemId);
121
122
 
122
123
  const response = await fetch(microsubApiUrl, {