@jasonwch/nodebb-plugin-meilisearch-r 1.1.1 → 1.2.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.
@@ -0,0 +1,297 @@
1
+ 'use strict';
2
+
3
+ const settings = nodebb.require('./src/meta/settings');
4
+
5
+ // Fixed embedder name used across all three indexes. Meilisearch supports multiple
6
+ // named embedders per index, but this plugin only ever needs one at a time.
7
+ const EMBEDDER_NAME = 'default';
8
+
9
+ // ACP field names that feed buildEmbedderConfig(). Kept as a single list so callers
10
+ // (lib/settings.js) can tell "an embedder-relevant field changed" apart from any other
11
+ // breaking setting (ranking rules, stop words, ...) - re-pushing embedders unnecessarily
12
+ // risks Meilisearch re-embedding every document through a paid API for no reason.
13
+ const SEMANTIC_SETTING_KEYS = [
14
+ 'semanticSearchEnabled',
15
+ 'semanticSearchProvider',
16
+ 'semanticSearchApiKey',
17
+ 'semanticSearchModel',
18
+ 'semanticSearchUrl',
19
+ 'semanticSearchDimensions',
20
+ 'semanticSearchRestRequest',
21
+ 'semanticSearchRestResponse',
22
+ ];
23
+
24
+ // documentTemplate is index-specific (each index has different fields) so it isn't
25
+ // exposed in the ACP - only the provider/credentials/ratio are admin-configurable.
26
+ const DOCUMENT_TEMPLATES = {
27
+ post: 'A forum post: {{doc.content}}',
28
+ topic: 'A forum topic titled: {{doc.title}}',
29
+ chat_message: 'A chat message: {{doc.content}}',
30
+ };
31
+
32
+ function truthy(value) {
33
+ return ['on', true, 'true'].includes(value);
34
+ }
35
+
36
+ // Reads one config value from the in-progress save payload (data) or the persisted settings.
37
+ // Always .trim()s string values so padded input (e.g. " sk-abc " pasted by admin) doesn't reach
38
+ // the embedder config → embedding API (OpenAI returns 401 for keys with trailing whitespace).
39
+ // Client-side .trim() in admin.js validateEmbedderForm is the primary UX guard, but settings.save
40
+ // serializes form values via jQuery .val() which returns raw (untrimmed) strings; this server-side
41
+ // trim is defense-in-depth for settings-save AND for reindex/prepareSearch paths that read from DB.
42
+ async function get(plugin, data, key, fallback) {
43
+ let value;
44
+ if (data && Object.prototype.hasOwnProperty.call(data, key)) {
45
+ value = data[key];
46
+ } else {
47
+ value = await settings.getOne(plugin.id, key);
48
+ }
49
+ if (value === undefined || value === '') return fallback;
50
+ if (typeof value === 'string') value = value.trim();
51
+ return value === '' ? fallback : value;
52
+ }
53
+
54
+ async function isSemanticSearchEnabled(plugin, data) {
55
+ return truthy(await get(plugin, data, 'semanticSearchEnabled', false));
56
+ }
57
+
58
+ async function getSemanticRatio(plugin, data) {
59
+ const raw = parseFloat(await get(plugin, data, 'semanticSearchRatio', 0.5));
60
+ return Number.isFinite(raw) ? Math.min(Math.max(raw, 0), 1) : 0.5;
61
+ }
62
+
63
+ // Meilisearch's rankingScoreThreshold defaults to 0 (no filtering) when omitted, meaning
64
+ // vector search always returns its `limit` nearest neighbours even when none of them are
65
+ // actually a good match for the query - there's no built-in "too dissimilar, drop it" cutoff.
66
+ // This is what surfaces as "every query returns exactly N results" (N = total documents in
67
+ // the index) with garbage in the tail. Defaulting to a modest 0.2 here (rather than 0, which
68
+ // would silently restore the unfiltered behavior) keeps genuinely unrelated hits out while
69
+ // still letting fuzzy-but-real semantic matches through; admins can tune it in the ACP.
70
+ async function getSemanticScoreThreshold(plugin, data) {
71
+ const raw = parseFloat(await get(plugin, data, 'semanticSearchScoreThreshold', 0.2));
72
+ return Number.isFinite(raw) ? Math.min(Math.max(raw, 0), 1) : 0.2;
73
+ }
74
+
75
+ function parseJsonTemplate(raw) {
76
+ if (!raw || typeof raw !== 'string' || !raw.trim()) return undefined;
77
+ try {
78
+ return JSON.parse(raw);
79
+ } catch (e) {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ // Builds the embedder config object for one index, or null to remove the embedder
85
+ // (Meilisearch deletes an embedder when its value is set to null in updateEmbedders).
86
+ async function buildEmbedderConfig(plugin, data, indexName) {
87
+ if (!(await isSemanticSearchEnabled(plugin, data))) {
88
+ return null;
89
+ }
90
+ const provider = await get(plugin, data, 'semanticSearchProvider', 'openAi');
91
+ const dimensions = parseInt(await get(plugin, data, 'semanticSearchDimensions', ''), 10);
92
+ const base = {
93
+ source: provider,
94
+ documentTemplate: DOCUMENT_TEMPLATES[indexName],
95
+ };
96
+ if (Number.isFinite(dimensions) && dimensions > 0) {
97
+ base.dimensions = dimensions;
98
+ }
99
+ switch (provider) {
100
+ case 'openAi': {
101
+ const apiKey = await get(plugin, data, 'semanticSearchApiKey', undefined);
102
+ const model = await get(plugin, data, 'semanticSearchModel', 'text-embedding-3-small');
103
+ // Optional: overrides the default api.openai.com endpoint so OpenAI-compatible
104
+ // providers (OpenRouter, Azure OpenAI, LocalAI, ...) can be used via the "openAi"
105
+ // source directly, without needing the fully custom "rest" request/response templates.
106
+ const url = await get(plugin, data, 'semanticSearchUrl', undefined);
107
+ return { ...base, apiKey, model, ...(url ? { url } : {}) };
108
+ }
109
+ case 'huggingFace': {
110
+ const model = await get(plugin, data, 'semanticSearchModel', 'BAAI/bge-base-en-v1.5');
111
+ return { ...base, model };
112
+ }
113
+ case 'ollama': {
114
+ const url = await get(plugin, data, 'semanticSearchUrl', 'http://localhost:11434/api/embeddings');
115
+ const model = await get(plugin, data, 'semanticSearchModel', 'nomic-embed-text');
116
+ const apiKey = await get(plugin, data, 'semanticSearchApiKey', undefined);
117
+ return { ...base, url, model, apiKey };
118
+ }
119
+ case 'rest': {
120
+ const url = await get(plugin, data, 'semanticSearchUrl', undefined);
121
+ const apiKey = await get(plugin, data, 'semanticSearchApiKey', undefined);
122
+ // "{{..}}" as the 2nd array element (in BOTH templates) tells Meilisearch this
123
+ // endpoint accepts/returns a batch of texts, not just one - Meilisearch batches
124
+ // multiple documents' texts into a single HTTP call during indexing for throughput.
125
+ // Without it here, a batched request (multiple texts) gets a response template that
126
+ // can only decode one embedding, failing with "response has a single embedding, but
127
+ // request has multiple texts to embed".
128
+ const request = parseJsonTemplate(await get(plugin, data, 'semanticSearchRestRequest', undefined)) ||
129
+ { input: ['{{text}}', '{{..}}'], model: 'text-embedding-3-small' };
130
+ const response = parseJsonTemplate(await get(plugin, data, 'semanticSearchRestResponse', undefined)) ||
131
+ { data: [{ embedding: '{{embedding}}' }, '{{..}}'] };
132
+ return {
133
+ ...base, url, apiKey, request, response,
134
+ };
135
+ }
136
+ default:
137
+ return null;
138
+ }
139
+ }
140
+
141
+ // Pre-flight validation: per-provider check of required fields BEFORE attempting to push
142
+ // the embedder config to Meilisearch. Returns null if config is valid (or semantic search
143
+ // is disabled — nothing to validate), or an error string listing the missing/invalid fields.
144
+ //
145
+ // Mirrors buildEmbedderConfig's per-provider defaults: a field is flagged "missing" only
146
+ // when it has no default fallback AND no edge-case override applies. Specifically:
147
+ // - openAi: apiKey required ONLY when url is blank (custom OpenAI-compatible endpoints
148
+ // like LocalAI dev mode may not require auth). model/url-based defaults cover the rest.
149
+ // - huggingFace: nothing required (HF runs inside Meilisearch; model defaulted).
150
+ // - ollama: nothing required (model + url defaulted for local install).
151
+ // - rest: url required (no default); request/response templates have defaults if missing
152
+ // but warn if non-empty invalid JSON (admin's template dropped silently otherwise).
153
+ //
154
+ // Used by lib/client.js plugin.updateEmbedders (defense-in-depth, server-side). A parallel
155
+ // client-side validator in static/lib/admin.js mirrors this logic to block the ACP Save
156
+ // before any POST — primary UX path.
157
+ async function validateEmbedderConfig(plugin, data) {
158
+ if (!(await isSemanticSearchEnabled(plugin, data))) return null;
159
+ const provider = await get(plugin, data, 'semanticSearchProvider', 'openAi');
160
+ const missing = [];
161
+ const invalid = [];
162
+ switch (provider) {
163
+ case 'openAi': {
164
+ const apiKey = await get(plugin, data, 'semanticSearchApiKey', undefined);
165
+ const url = await get(plugin, data, 'semanticSearchUrl', undefined);
166
+ // model has a default; URL is optional. apiKey is required ONLY when URL is blank
167
+ // (admin is using OpenAI's default api.openai.com endpoint, which requires auth).
168
+ // If URL is set, admin is pointing at a custom OpenAI-compatible endpoint that may
169
+ // not require auth — don't block legitimate no-auth setups.
170
+ if (!apiKey && !url) missing.push('apiKey');
171
+ break;
172
+ }
173
+ case 'huggingFace':
174
+ // Model has a default; HF runs in Meilisearch itself - nothing strictly required.
175
+ break;
176
+ case 'ollama':
177
+ // url + model have defaults; apiKey optional. Nothing strictly required for local install.
178
+ break;
179
+ case 'rest': {
180
+ const url = await get(plugin, data, 'semanticSearchUrl', undefined);
181
+ if (!url) missing.push('url');
182
+ // request/response have defaults if missing/invalid. But a non-empty non-JSON string
183
+ // would silently fall back to default via parseJsonTemplate — admin's template dropped
184
+ // without warning. Warn (don't block) on that here too.
185
+ const request = await get(plugin, data, 'semanticSearchRestRequest', undefined);
186
+ const response = await get(plugin, data, 'semanticSearchRestResponse', undefined);
187
+ if (request && request.trim() && parseJsonTemplate(request) === undefined) {
188
+ invalid.push('request');
189
+ }
190
+ if (response && response.trim() && parseJsonTemplate(response) === undefined) {
191
+ invalid.push('response');
192
+ }
193
+ break;
194
+ }
195
+ default:
196
+ return `Unknown provider: ${provider}`;
197
+ }
198
+ if (!missing.length && !invalid.length) return null;
199
+ const parts = [];
200
+ if (missing.length) parts.push(`Required field(s) missing for "${provider}" provider: ${missing.join(', ')}`);
201
+ if (invalid.length) parts.push(`Invalid JSON in field(s): ${invalid.join(', ')}`);
202
+ return parts.join('; ');
203
+ }
204
+
205
+ function deepEqual(a, b) {
206
+ if (a === b) return true;
207
+ if (typeof a !== typeof b || a === null || b === null) return false;
208
+ if (typeof a !== 'object') return false;
209
+ const aKeys = Object.keys(a);
210
+ const bKeys = Object.keys(b);
211
+ if (aKeys.length !== bKeys.length) return false;
212
+ return aKeys.every(key => deepEqual(a[key], b[key]));
213
+ }
214
+
215
+ // Per-index bookkeeping of the last embedder config actually pushed to Meilisearch,
216
+ // so buildEmbedderConfig() can be re-run freely (e.g. as a side effect of unrelated
217
+ // settings saves) without re-issuing an identical updateEmbedders() call - Meilisearch
218
+ // treats every updateEmbedders() call as "this embedder's config is new", which is the
219
+ // one operation that can re-trigger paid-API re-embedding of every document.
220
+ //
221
+ // Returns the config portion of the stored {config, taskUid} wrapper (new format), or
222
+ // the raw stored value for old-format fingerprints (backward-compatible — entry IS the
223
+ // config directly). Returns null for the disabled state (buildEmbedderConfig returns null).
224
+ async function getAppliedConfig(plugin, indexName) {
225
+ const stored = await settings.getOne(plugin.id, 'appliedEmbedders') || {};
226
+ if (!Object.prototype.hasOwnProperty.call(stored, indexName)) return undefined;
227
+ const entry = stored[indexName];
228
+ if (entry === null) return null; // disabled state
229
+ if (typeof entry === 'object' && 'config' in entry && 'taskUid' in entry) {
230
+ return entry.config; // new format: {config, taskUid} wrapper
231
+ }
232
+ return entry; // old format: entry IS the config directly
233
+ }
234
+
235
+ // Persists multiple indexes' applied configs in a single read-modify-write, so concurrent
236
+ // per-index updates can't clobber each other's bookkeeping (see lib/client.js updateEmbedders).
237
+ // Stores {config, taskUid} per index: taskUid is null for sync-verified success, the enqueued
238
+ // taskUid for optimistic-pending (timeout) state. Used by reattachPollers in lib/client.js to
239
+ // reconcile persisted tasks directly via getTask(uid) instead of querying the full task queue.
240
+ async function setAppliedConfigs(plugin, plans) {
241
+ const stored = await settings.getOne(plugin.id, 'appliedEmbedders') || {};
242
+ plans.forEach(({ indexName, config, taskUid }) => {
243
+ stored[indexName] = { config, taskUid: taskUid ?? null };
244
+ });
245
+ await settings.set(plugin.id, { appliedEmbedders: stored }, true);
246
+ }
247
+
248
+ // Clears the "last applied" bookkeeping - call this whenever the Meilisearch connection
249
+ // itself changes (new host). A fresh host has no memory of what this plugin last pushed,
250
+ // so the next updateEmbedders() call must be allowed through even if the config value is
251
+ // identical to what was applied to the OLD host.
252
+ async function resetAppliedConfigs(plugin) {
253
+ await settings.set(plugin.id, { appliedEmbedders: {} }, true);
254
+ }
255
+
256
+ // Clears one index's "last applied" bookkeeping - used by the embedder background poller
257
+ // in lib/client.js when a delayed task failure is detected: the fingerprint was
258
+ // optimistically persisted at enqueue time, so we must clear it on eventual failure to
259
+ // force the next save to push fresh config (rather than silently skipping as "already
260
+ // applied"). Mirrors the read-modify-write pattern of setAppliedConfigs.
261
+ async function clearAppliedConfig(plugin, indexName) {
262
+ const stored = await settings.getOne(plugin.id, 'appliedEmbedders') || {};
263
+ delete stored[indexName];
264
+ await settings.set(plugin.id, { appliedEmbedders: stored }, true);
265
+ }
266
+
267
+ // Reads the persisted taskUid for one index (from the {config, taskUid} wrapper stored by
268
+ // setAppliedConfigs). Returns: a number (optimistic-pending task still running server-side),
269
+ // null (sync-verified success — no reconciliation needed), or undefined (old-format fingerprint
270
+ // without taskUid, or index never pushed). Used by reattachPollers in lib/client.js to
271
+ // reconcile persisted tasks directly via getTask(uid) instead of querying the full task queue.
272
+ async function getAppliedTaskUid(plugin, indexName) {
273
+ const stored = await settings.getOne(plugin.id, 'appliedEmbedders') || {};
274
+ if (!Object.prototype.hasOwnProperty.call(stored, indexName)) return undefined;
275
+ const entry = stored[indexName];
276
+ if (entry === null || entry === undefined) return undefined;
277
+ if (typeof entry === 'object' && 'taskUid' in entry) {
278
+ return entry.taskUid;
279
+ }
280
+ return undefined; // old format — no taskUid stored
281
+ }
282
+
283
+ module.exports = {
284
+ EMBEDDER_NAME,
285
+ SEMANTIC_SETTING_KEYS,
286
+ isSemanticSearchEnabled,
287
+ getSemanticRatio,
288
+ getSemanticScoreThreshold,
289
+ buildEmbedderConfig,
290
+ validateEmbedderConfig,
291
+ deepEqual,
292
+ getAppliedConfig,
293
+ getAppliedTaskUid,
294
+ setAppliedConfigs,
295
+ resetAppliedConfigs,
296
+ clearAppliedConfig,
297
+ };
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+
3
+ const winston = nodebb.require('winston');
4
+ const Messaging = nodebb.require('./src/messaging');
5
+ const { maybeDefer, meiliWrite } = require('../pending-queue');
6
+
7
+ // action:messaging.* hooks (save/edit/restore/delete) for the per-message chat index.
8
+ module.exports = function attachMessageHooks(plugin) {
9
+ plugin.indexMessage = async function ({ message }) {
10
+ const deferPayload = { message };
11
+ if (await maybeDefer(plugin, 'indexMessage', deferPayload)) return;
12
+ if (!message || !message.mid) return;
13
+ // Re-fetch from DB if content/roomId/fromuid/timestamp missing or if this is an edit (payload may be partial).
14
+ // Restore payload (delete.js) omits timestamp; save/edit payloads carry it. Re-fetch fills the gap.
15
+ if (message.content === undefined || message.roomId === undefined || message.fromuid === undefined || message.timestamp === undefined) {
16
+ const fresh = await Messaging.getMessageFields(message.mid, ['mid', 'content', 'roomId', 'fromuid', 'timestamp', 'deleted', 'system']);
17
+ // Bail if message was hard-purged mid-flight (fresh=null or stub with mid=0) — avoids indexing a phantom doc.
18
+ if (!fresh || !fresh.mid) return;
19
+ message = { ...fresh, ...message };
20
+ }
21
+ // Skip deleted messages (admin editing a deleted message should not re-index it).
22
+ if (parseInt(message.deleted, 10) === 1) {
23
+ winston.debug(`[plugin/meilisearch] Skipping deleted message ${message.mid}`);
24
+ return;
25
+ }
26
+ // Skip system messages (join/leave/rename etc. — no searchable content, mirrors dbsearch).
27
+ if (parseInt(message.system, 10) === 1) {
28
+ winston.debug(`[plugin/meilisearch] Skipping system message ${message.mid}`);
29
+ return;
30
+ }
31
+ const doc = {
32
+ mid: message.mid,
33
+ roomId: message.roomId,
34
+ uid: message.fromuid,
35
+ content: message.content,
36
+ timestamp: message.timestamp,
37
+ };
38
+ await meiliWrite(plugin, 'indexMessage', { message: { mid: message.mid } }, () =>
39
+ plugin.client.index('chat_message').updateDocuments([doc], { primaryKey: 'mid' }));
40
+ };
41
+
42
+ plugin.deindexMessage = async function ({ message }) {
43
+ const deferPayload = { message };
44
+ if (await maybeDefer(plugin, 'deindexMessage', deferPayload)) return;
45
+ if (!message || !message.mid) return;
46
+ await meiliWrite(plugin, 'deindexMessage', { message }, () =>
47
+ plugin.client.index('chat_message').deleteDocument(message.mid));
48
+ };
49
+ };
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ const winston = nodebb.require('winston');
4
+ const Posts = nodebb.require('./src/posts');
5
+ const Topics = nodebb.require('./src/topics');
6
+ const batch = nodebb.require('./src/batch');
7
+ const { REINDEX_BATCH_SIZE } = require('../constants');
8
+ const { maybeDefer, meiliWrite, enqueuePending } = require('../pending-queue');
9
+
10
+ // action:post.* hooks (save/edit/move/restore/delete/changeOwner/purge) plus the
11
+ // topic-level post reindex/deindex helpers used when a topic itself moves or is deleted.
12
+ module.exports = function attachPostHooks(plugin) {
13
+ plugin.indexPost = async function ({ post, tid: newTid }) {
14
+ const deferPayload = { post, tid: newTid };
15
+ if (await maybeDefer(plugin, 'indexPost', deferPayload)) return;
16
+ // #3: On post.move, post.tid is stale and content may be missing.
17
+ // Re-fetch to get fresh tid + content from DB (already updated before action hook fires).
18
+ if (newTid !== undefined || !post || post.content === undefined) {
19
+ if (post && post.pid) {
20
+ post = await Posts.getPostData(post.pid);
21
+ }
22
+ if (!post) return;
23
+ }
24
+ // Issue 3: Skip deleted posts (admin editing a deleted post should not re-index it).
25
+ if (parseInt(post.deleted, 10) === 1) {
26
+ winston.debug(`[plugin/meilisearch] Skipping deleted post ${post.pid}`);
27
+ return;
28
+ }
29
+ // Point 1: Skip posts in deleted or scheduled topics.
30
+ const topicData = await Topics.getTopicFields(post.tid, ['deleted', 'timestamp']);
31
+ if (parseInt(topicData?.deleted, 10) === 1) {
32
+ winston.debug(`[plugin/meilisearch] Skipping post ${post.pid} in deleted topic ${post.tid}`);
33
+ return;
34
+ }
35
+ if (topicData?.timestamp && topicData.timestamp > Date.now()) {
36
+ winston.debug(`[plugin/meilisearch] Skipping post ${post.pid} in scheduled topic ${post.tid}`);
37
+ return;
38
+ }
39
+ if (!post.cid) {
40
+ post.cid = await Posts.getCidByPid(post.pid);
41
+ }
42
+ const doc = {
43
+ pid: post.pid,
44
+ tid: post.tid,
45
+ cid: post.cid,
46
+ uid: post.uid,
47
+ content: post.content,
48
+ timestamp: post.timestamp,
49
+ };
50
+ await meiliWrite(plugin, 'indexPost', { post: { pid: post.pid } }, () =>
51
+ plugin.client.index('post').updateDocuments([doc], { primaryKey: 'pid' }));
52
+ };
53
+
54
+ plugin.deindexPost = async function ({ post }) {
55
+ if (await maybeDefer(plugin, 'deindexPost', { post })) return;
56
+ await meiliWrite(plugin, 'deindexPost', { post }, () =>
57
+ plugin.client.index('post').deleteDocument(post.pid));
58
+ };
59
+
60
+ plugin.changePostOwner = async function ({ posts, toUid }) {
61
+ if (await maybeDefer(plugin, 'changePostOwner', { posts, toUid })) return;
62
+ // posts have OLD uid — re-fetch from DB to get fresh uid.
63
+ // Skip deleted posts (changeOwner can be called on deleted posts).
64
+ const validPids = (Array.isArray(posts) ? posts : [])
65
+ .filter(p => p && p.pid && parseInt(p.deleted, 10) !== 1)
66
+ .map(p => p.pid);
67
+ if (!validPids.length) return;
68
+ const freshPosts = await Posts.getPostsFields(validPids, ['pid', 'tid', 'uid', 'content', 'timestamp']);
69
+ // Fix 7: Skip posts in deleted or scheduled topics.
70
+ const tids = [...new Set(freshPosts.map(p => p && p.tid).filter(Boolean))];
71
+ const topicsData = tids.length ? await Topics.getTopicsFields(tids, ['tid', 'deleted', 'timestamp']) : [];
72
+ const tidToDeleted = {};
73
+ const tidToScheduled = {};
74
+ topicsData.forEach((t) => {
75
+ tidToDeleted[t.tid] = parseInt(t.deleted, 10) === 1;
76
+ tidToScheduled[t.tid] = t.timestamp && t.timestamp > Date.now();
77
+ });
78
+ const activePosts = freshPosts.filter(post => post && !tidToDeleted[post.tid] && !tidToScheduled[post.tid]);
79
+ if (!activePosts.length) return;
80
+ const activePids = activePosts.map(p => p.pid);
81
+ const cids = await Posts.getCidsByPids(activePids);
82
+ const docs = activePosts.map((post, index) => ({
83
+ pid: post.pid,
84
+ tid: post.tid,
85
+ cid: cids[index],
86
+ uid: post.uid,
87
+ content: post.content,
88
+ timestamp: post.timestamp,
89
+ }));
90
+ await meiliWrite(plugin, 'changePostOwner', { posts: activePids.map(pid => ({ pid })), toUid }, () =>
91
+ plugin.client.index('post').updateDocuments(docs, { primaryKey: 'pid' }));
92
+ };
93
+
94
+ plugin.deindexPostsPurge = async function ({ posts }) {
95
+ if (await maybeDefer(plugin, 'deindexPostsPurge', { posts })) return;
96
+ const pids = Array.isArray(posts) ? posts.map(p => p && p.pid).filter(Boolean) : [];
97
+ if (!pids.length) {
98
+ return;
99
+ }
100
+ const reducedPayload = { posts: pids.map(pid => ({ pid })) };
101
+ await meiliWrite(plugin, 'deindexPostsPurge', reducedPayload, () =>
102
+ plugin.client.index('post').deleteDocuments(pids));
103
+ };
104
+
105
+ plugin.reindexTopicPosts = async function ({ tid }) {
106
+ if (!tid) return;
107
+ const postIndex = plugin.client.index('post');
108
+ try {
109
+ // Bug A: mainPid is NOT in tid:${tid}:posts — index it separately.
110
+ const mainPid = await Topics.getTopicField(tid, 'mainPid');
111
+ if (mainPid) {
112
+ const [mainPost] = await Posts.getPostsFields([mainPid], ['pid', 'tid', 'uid', 'content', 'timestamp', 'deleted']);
113
+ if (mainPost && parseInt(mainPost.deleted, 10) !== 1) {
114
+ const [mainCid] = await Posts.getCidsByPids([mainPid]);
115
+ await postIndex.updateDocuments([{
116
+ pid: mainPost.pid,
117
+ tid: mainPost.tid,
118
+ cid: mainCid,
119
+ uid: mainPost.uid,
120
+ content: mainPost.content,
121
+ timestamp: mainPost.timestamp,
122
+ }], { primaryKey: 'pid' });
123
+ }
124
+ }
125
+ // Bug B: skip individually-deleted posts in the batch loop.
126
+ await batch.processSortedSet(
127
+ `tid:${tid}:posts`,
128
+ async (pids) => {
129
+ if (!pids.length) return;
130
+ const posts = await Posts.getPostsFields(pids, ['pid', 'tid', 'uid', 'content', 'timestamp', 'deleted']);
131
+ const activePosts = posts.filter(post => post && parseInt(post.deleted, 10) !== 1);
132
+ if (!activePosts.length) return;
133
+ const activePids = activePosts.map(p => p.pid);
134
+ const cids = await Posts.getCidsByPids(activePids);
135
+ await postIndex.updateDocuments(
136
+ activePosts.map((post, index) => ({
137
+ pid: post.pid,
138
+ tid: post.tid,
139
+ cid: cids[index],
140
+ uid: post.uid,
141
+ content: post.content,
142
+ timestamp: post.timestamp,
143
+ })),
144
+ { primaryKey: 'pid' },
145
+ );
146
+ },
147
+ { batch: REINDEX_BATCH_SIZE },
148
+ );
149
+ } catch (err) {
150
+ winston.error(`[plugin/meilisearch] reindexTopicPosts failed: ${err.message}`);
151
+ plugin.healthy = false;
152
+ await enqueuePending('reindexTopicPosts', { tid });
153
+ }
154
+ };
155
+
156
+ plugin.deindexTopicPosts = async function ({ tid }) {
157
+ if (!tid) return;
158
+ const postIndex = plugin.client.index('post');
159
+ try {
160
+ // mainPid is NOT in tid:${tid}:posts — delete it separately.
161
+ const mainPid = await Topics.getTopicField(tid, 'mainPid');
162
+ const mainPidArr = mainPid ? [mainPid] : [];
163
+ await batch.processSortedSet(
164
+ `tid:${tid}:posts`,
165
+ async (pids) => {
166
+ if (!pids.length) return;
167
+ await postIndex.deleteDocuments(pids);
168
+ },
169
+ { batch: REINDEX_BATCH_SIZE },
170
+ );
171
+ if (mainPidArr.length) {
172
+ await postIndex.deleteDocuments(mainPidArr);
173
+ }
174
+ } catch (err) {
175
+ winston.error(`[plugin/meilisearch] deindexTopicPosts failed: ${err.message}`);
176
+ plugin.healthy = false;
177
+ await enqueuePending('deindexTopicPosts', { tid });
178
+ }
179
+ };
180
+ };