@nxgt/mongo-meilisearch 0.2.0 → 0.3.1

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.
@@ -30,22 +30,30 @@ const report = await articleSearch.reindex();
30
30
 
31
31
  ## What it does, in order
32
32
 
33
+ `reindex()` holds the sync name's
34
+ [lease](following-changes.md#one-process-per-name-the-lease) throughout; see
35
+ [Not beside a follower in another process](#not-beside-a-follower-in-another-process).
36
+
33
37
  1. **Takes the collection's current position** — a change stream's first read
34
38
  answers with it, without waiting for a change.
35
39
  2. **Reads every live document**, `pageSize` at a time (100 by default,
36
40
  lowered to the collection's `maxPageSize` when it asks for more), runs the
37
41
  transform, and sends what it gives in batches of `batchSize` (500).
38
42
  Soft-deleted documents are not live, so they are never sent.
39
- 3. **Pages the whole index** and deletes every document whose id the
40
- collection did not just give it: deleted, turned away by the transform, or
41
- never from this collection at all.
42
- 4. **Records the position from step 1** as the resume point, and stamps
43
- `reindexedAt`.
43
+ After each page, it stops if a renewal found the lease lost.
44
+ 3. **Asks the server whether the lease is still its own**, then **pages the
45
+ whole index** and deletes every document whose id the collection did not
46
+ just give it: deleted, turned away by the transform, or never from this
47
+ collection at all.
48
+ 4. **Asks the server again**, then **records the position from step 1** as
49
+ the resume point, and stamps `reindexedAt`.
44
50
 
45
51
  Step 1 before step 2 is what makes it safe to reindex a live collection: a
46
52
  change made while the documents are read is followed again from that
47
53
  position, so nothing falls between the reindex and the stream. A reindex that
48
- throws records nothing, and the next one starts over.
54
+ throws records nothing, and the next one starts over. One that throws because
55
+ its lease was lost has removed nothing either, unless the lease went while the
56
+ removal itself ran.
49
57
 
50
58
  ```ts
51
59
  const before = await articleSearch.state(); // undefined, the first time
@@ -87,14 +95,46 @@ await articleSearch.reindex();
87
95
  const fresh = await articleSearch.start();
88
96
  ```
89
97
 
90
- The check is per object and per process: the package has no lock across
91
- processes, and [does not pretend to](boundaries.md).
98
+ ## Not beside a follower in another process
99
+
100
+ `reindex()` takes the sync name's [lease](following-changes.md#one-process-per-name-the-lease)
101
+ for as long as it runs, renews it every third of `leaseMs`, and lets go when
102
+ it is done. While another process follows the name, or reindexes it, it is
103
+ refused before it reads anything:
104
+
105
+ ```ts
106
+ await articleSearch.reindex();
107
+ // SearchSyncError: Search sync "articles:articles" is held by
108
+ // worker-1:4127:66f0c2e5a1b2c3d4e5f60718 until 2026-09-22T09:14:07.512Z: wait
109
+ // for it to close, or for its lease to lapse, before you reindex.
110
+ ```
111
+
112
+ The code is `RUNNING` again. Close the follower wherever it runs — its
113
+ `close()` lets go of the name before it resolves — then reindex. While the
114
+ reindex runs, a `start()` elsewhere is refused the same way.
115
+
116
+ A reindex whose lease another process took over while it ran — it was not
117
+ renewed within `leaseMs`, or it was removed — rejects with `LEASE_LOST`: after
118
+ the page it just sent, when a renewal found it lost, or when the server says so
119
+ before documents are removed or before the resume point is recorded. It has
120
+ then **recorded nothing**, and removed nothing unless the lease went while the
121
+ removal itself ran; the pages already sent stay in the index. The same holds
122
+ for the reindex `start()` runs, which also asks before it opens the follower.
123
+ Run it again once the name is free.
124
+
125
+ ```ts
126
+ await articleSearch.reindex();
127
+ // SearchSyncError: Search sync "articles:articles" lost its lease: another
128
+ // process holds the name now, or the lease was removed (it lapses when not
129
+ // renewed within 30000 ms). It stopped rather than run beside it.
130
+ ```
92
131
 
93
132
  ## Errors
94
133
 
95
134
  Anything that goes wrong while reindexing comes back as a `SearchSyncError`
96
135
  with the code `FAILED` — MongoDB refused a read, Meilisearch refused a batch,
97
- the transform threw — and the original error as its `cause`:
136
+ the transform threw — and the original error as its `cause`. `RUNNING` and
137
+ `LEASE_LOST` are the lease's, above:
98
138
 
99
139
  ```ts
100
140
  try {
@@ -107,7 +147,7 @@ try {
107
147
  }
108
148
  ```
109
149
 
110
- Two codes mean something more precise than `FAILED`, and both are the
150
+ Two more codes mean something more precise than `FAILED`, and both are the
111
151
  transform's doing. `ID_MISMATCH`: it gave a document whose primary key is not
112
152
  that document's index id. `NOT_A_DOCUMENT`: it gave back something that is
113
153
  neither a document nor `null`.
@@ -105,11 +105,12 @@ message reports its shape rather than its value.
105
105
  | `index` | `TypedIndex<I>` | — | Where they go: from `bindIndex` |
106
106
  | `transform` | `Transform<C, I>` | — | The document as the index holds it, or `null` to keep it out |
107
107
  | `toIndexId` | `ToIndexId<C, I>` | `String` | The index id of a `_id`. Optional while the index's ids are strings, required otherwise |
108
- | `name` | `string` | `'<collection>:<index uid>'` | What the resume point is recorded under. Two syncs under one name share it |
109
- | `stateCollection` | `string` | `'nxgt_search_sync'` | Where the resume point is kept, in the collection's database |
108
+ | `name` | `string` | `'<collection>:<index uid>'` | What the resume point and the lease are recorded under. Two syncs under one name share both |
109
+ | `stateCollection` | `string` | `'nxgt_search_sync'` | Where the resume point and the lease are kept, in the collection's database |
110
110
  | `batchSize` | `number` | `500` | Changes, or documents, sent to Meilisearch at once |
111
111
  | `flushIntervalMs` | `number` | `1000` | How long a change waits for others; `0` sends at the next tick |
112
112
  | `positionIntervalMs` | `number` | `60000` | How often a sync with nothing to send records where the stream is |
113
+ | `leaseMs` | `number` | `30000` | How long the lease on the name lasts unrenewed; a running sync renews it every third of that. See [the lease](following-changes.md#one-process-per-name-the-lease) |
113
114
  | `pageSize` | `number` | `100` | Documents a reindex reads per page; above the collection's `maxPageSize`, lowered to it |
114
115
  | `onHistoryLost` | `'reindex' \| 'fail'` | `'reindex'` | What `start` does when the resume point is older than the server's change history |
115
116
 
@@ -144,12 +145,14 @@ await sync.state();
144
145
  // { _id: 'articles:public', resumeToken: …, updatedAt: …, reindexedAt: … }
145
146
  ```
146
147
 
147
- `state()` is `undefined` until the first reindex records something.
148
+ `state()` is `undefined` until the first reindex records something. The same
149
+ collection holds the name's lease, under `_id: { lease: 'articles:public' }`,
150
+ while a process follows or reindexes it.
148
151
 
149
152
  ### `batchSize`, `flushIntervalMs`, `pageSize`
150
153
 
151
- `batchSize`, `positionIntervalMs` and `pageSize` must be whole numbers above
152
- zero and `flushIntervalMs` a whole number of milliseconds, or
154
+ `batchSize`, `positionIntervalMs`, `leaseMs` and `pageSize` must be whole
155
+ numbers above zero and `flushIntervalMs` a whole number of milliseconds, or
153
156
  `createSearchSync` throws a `TypeError` before anything runs — as it does for
154
157
  an empty `name` or a `transform` that is not a function.
155
158
 
package/docs/roadmap.md CHANGED
@@ -5,10 +5,7 @@ version an item shipped in is the only number on this page.
5
5
 
6
6
  ## Now
7
7
 
8
- - **A lease on a sync name, renewed while it runs** — a follower holds the
9
- sync's name for a bounded time and renews it as it works, so a process that
10
- dies is taken over once its lease expires rather than leaving the index
11
- behind, and a name nothing is following no longer blocks the next start.
8
+ _Nothing in progress._
12
9
 
13
10
  ## Next
14
11
 
@@ -35,6 +32,14 @@ _Nothing queued._
35
32
 
36
33
  ## Shipped
37
34
 
35
+ - **A lease on a sync name, renewed while it runs** — `start()` and
36
+ `reindex()` take a lease on the sync's name, kept in the state collection
37
+ and timed by the server, renewed every third of the new `leaseMs` option
38
+ (default 30 s); a second process gets `RUNNING` naming the holder, a process
39
+ that dies is taken over once its lease lapses, and a sync whose lease was
40
+ taken stops with the new `LEASE_LOST` code; a reindex asks the server that
41
+ the lease is still its own before it removes documents or records where
42
+ following resumes — 0.3.0.
38
43
  - **A transform that gives back something that is not a document has its own
39
44
  code** — `SearchSyncErrorCode` gains `NOT_A_DOCUMENT`, naming the sync and
40
45
  the document and reporting the shape of what came back, never its value, so
@@ -6,7 +6,8 @@ name is written as it comes out by default — `<collection>:<index uid>`, here
6
6
  `articles:articles`.
7
7
 
8
8
  Everything this package throws is a `SearchSyncError` carrying a `code`
9
- (`HISTORY_LOST`, `ID_MISMATCH`, `NOT_A_DOCUMENT`, `RUNNING`, `FAILED`), the
9
+ (`HISTORY_LOST`, `ID_MISMATCH`, `NOT_A_DOCUMENT`, `RUNNING`, `LEASE_LOST`,
10
+ `FAILED`), the
10
11
  sync's `name`, and the original error as `cause` — except the options, which
11
12
  are refused with a `TypeError` before anything is opened.
12
13
 
@@ -14,10 +15,10 @@ are refused with a `TypeError` before anything is opened.
14
15
  | --- | --- |
15
16
  | [Install](#install) | [ERESOLVE](#npm-error-eresolve-unable-to-resolve-dependency-tree) · [incorrect peer dependency](#warn-incorrect-peer-dependency-nxgtmongo0140) · [TS2307](#error-ts2307-cannot-find-module-nxgtmongo-or-its-corresponding-type-declarations) |
16
17
  | [Options](#options) | [transform](#createsearchsync-transform-must-be-a-function) · [batchSize](#createsearchsync-batchsize-must-be-a-whole-number-above-0-not-0) · [flushIntervalMs](#createsearchsync-flushintervalms-must-be-a-whole-number-of-milliseconds-not--1) · [name](#createsearchsync-name-must-not-be-empty) |
17
- | [Starting](#starting) | [no replica set](#search-sync-articlesarticles-failed-starting-the-changestream-stage-is-only-supported-on-replica-sets) · [privileges](#search-sync-articlesarticles-failed-reindexing-not-authorized-on-app-to-execute-command--aggregate-articles-pipeline---changestream-----) · [history lost](#search-sync-articlesarticles-was-last-at-a-point-the-servers-change-history-no-longer-reaches-reindex-it-or-start-it-with-onhistorylost-reindex) · [already running](#search-sync-articlesarticles-is-already-following-changes-in-this-process-close-it-before-you-start-it-twice) |
18
+ | [Starting](#starting) | [no replica set](#search-sync-articlesarticles-failed-starting-the-changestream-stage-is-only-supported-on-replica-sets) · [privileges](#search-sync-articlesarticles-failed-reindexing-not-authorized-on-app-to-execute-command--aggregate-articles-pipeline---changestream-----) · [history lost](#search-sync-articlesarticles-was-last-at-a-point-the-servers-change-history-no-longer-reaches-reindex-it-or-start-it-with-onhistorylost-reindex) · [already running](#search-sync-articlesarticles-is-already-following-changes-in-this-process-close-it-before-you-start-it-twice) · [held by another process](#search-sync-articlesarticles-is-held-by--until--wait-for-it-to-close-or-for-its-lease-to-lapse-before-you-start-it) · [the lease cannot be taken](#search-sync-articlesarticles-failed-taking-its-lease-) · [the lease cannot be checked](#search-sync-articlesarticles-failed-checking-its-lease-) · [the lease lost while reindexing](#search-sync-articlesarticles-lost-its-lease-another-process-holds-the-name-now-or-the-lease-was-removed-it-lapses-when-not-renewed-within-30000-ms-it-stopped-rather-than-run-beside-it) |
18
19
  | [The transform](#the-transform) | [an id that is not the index's](#search-sync-articlesarticles-transform-gave-id-other-for-the-document--whose-index-id-is-) · [not a document](#search-sync-articlesarticles-transform-gave-a-string-for-the-document-) · [it threw](#search-sync-articlesarticles-failed-following-changes-boom) |
19
20
  | [Sending](#sending) | [an id Meilisearch refuses](#search-sync-articlesarticles-failed-sending-changes-task-3-documentadditionorupdate-on-index-articles-failed-document-identifier--is-invalid) |
20
- | [Stopping](#stopping) | [a dropped collection](#the-sync-stops-and-closed-resolves-with-invalidated) |
21
+ | [Stopping](#stopping) | [lease lost](#search-sync-articlesarticles-lost-its-lease-another-process-holds-the-name-now-or-the-lease-was-removed-it-lapses-when-not-renewed-within-30000-ms-it-stopped-rather-than-run-beside-it) · [a dropped collection](#the-sync-stops-and-closed-resolves-with-invalidated) |
21
22
 
22
23
  ## Install
23
24
 
@@ -111,11 +112,11 @@ createSearchSync({
111
112
 
112
113
  ### `createSearchSync: batchSize must be a whole number above 0, not 0`
113
114
 
114
- The same refusal covers `positionIntervalMs` and `pageSize`.
115
+ The same refusal covers `positionIntervalMs`, `pageSize` and `leaseMs`.
115
116
 
116
117
  **When:** calling `createSearchSync`.
117
118
 
118
- **Why:** those three count documents or milliseconds, and `0`, a fraction,
119
+ **Why:** those four count documents or milliseconds, and `0`, a fraction,
119
120
  `NaN` and a negative number have no meaning for any of them. A value read from
120
121
  the environment is a string until it is parsed, and `Number('')` is `0`.
121
122
 
@@ -130,7 +131,7 @@ createSearchSync({ collection, index, transform, batchSize: 500 }); // the defau
130
131
  **When:** calling `createSearchSync`.
131
132
 
132
133
  **Why:** `flushIntervalMs` is the one option that accepts `0` — send every
133
- change as it comes — so it is checked apart from the three above. A negative
134
+ change as it comes — so it is checked apart from the four above. A negative
134
135
  number or a fraction is still refused.
135
136
 
136
137
  **Fix:**
@@ -178,8 +179,8 @@ mongod --replSet rs0 --dbpath ./data # then, once: rs.initiate()
178
179
 
179
180
  ### `Search sync "articles:articles" failed reindexing: not authorized on app to execute command { aggregate: "articles", pipeline: [ { $changeStream: {} } ], … }`
180
181
 
181
- The same shape appears for the state collection:
182
- `… not authorized on app to execute command { update: "nxgt_search_sync", … }`.
182
+ For the state collection, the refusal comes first, from the lease:
183
+ [`… failed taking its lease: …`](#search-sync-articlesarticles-failed-taking-its-lease-).
183
184
 
184
185
  **When:** `reindex()` or `start()`, against a server with authentication.
185
186
 
@@ -226,8 +227,10 @@ already following.
226
227
 
227
228
  **Why:** a reindex removes what the index holds and the collection no longer
228
229
  gives it — including the documents the running follower has just indexed, which
229
- it will never send again. The refusal covers one process only: there is **no
230
- lock**, so two processes following one name is yours to prevent.
230
+ it will never send again. This check is the sync object's own and comes first;
231
+ another process, or a second `createSearchSync` with the same name, is refused
232
+ by the lease on the name instead, with
233
+ [`… is held by …`](#search-sync-articlesarticles-is-held-by--until--wait-for-it-to-close-or-for-its-lease-to-lapse-before-you-start-it).
231
234
 
232
235
  **Fix:**
233
236
 
@@ -238,6 +241,90 @@ await running.close(); // then reindex, or start again
238
241
  await articleSearch.reindex();
239
242
  ```
240
243
 
244
+ ### `Search sync "articles:articles" is held by … until …: wait for it to close, or for its lease to lapse, before you start it.`
245
+
246
+ Code `RUNNING`. The holder is written `<host>:<pid>:<24 hex digits>`, and the
247
+ date is ISO, in UTC. A `reindex()` ends `… before you reindex.`
248
+
249
+ **When:** `start()` or `reindex()`, while another process — or another sync
250
+ object in this one — follows or reindexes the same name. Also after a process
251
+ that held it died without closing: its lease still runs until `leaseMs`
252
+ (default `30000`) after its last renewal.
253
+
254
+ **Why:** a running sync, and a reindex, hold a lease on the sync's name — one
255
+ document in the state collection, renewed every third of `leaseMs`. Two
256
+ followers on one name would each send and record beside the other, and a
257
+ reindex would remove what the follower had just sent. Expiry is decided on the
258
+ MongoDB server's clock, so hosts whose clocks disagree still agree on it.
259
+
260
+ **Fix:** run one follower per name. A second replica can stand by, retrying
261
+ `start()` until the name is free — on `RUNNING`, and on `LEASE_LOST`, which a
262
+ `start()` whose first reindex lost the name to another process rejects with:
263
+
264
+ ```ts
265
+ import { type RunningSearchSync, SearchSyncError } from '@nxgt/mongo-meilisearch';
266
+
267
+ // The name is held elsewhere, or was taken over while this start reindexed.
268
+ const heldElsewhere = (error: unknown) =>
269
+ error instanceof SearchSyncError &&
270
+ (error.code === 'RUNNING' || error.code === 'LEASE_LOST');
271
+
272
+ async function follow(): Promise<RunningSearchSync> {
273
+ for (;;) {
274
+ try {
275
+ return await articleSearch.start();
276
+ } catch (error) {
277
+ if (!heldElsewhere(error)) throw error;
278
+ await new Promise((resolve) => setTimeout(resolve, 10_000));
279
+ }
280
+ }
281
+ }
282
+ ```
283
+
284
+ Close the sync on shutdown (`await running.close()` on `SIGTERM`): that lets go
285
+ of the name at once, where a killed process leaves it held until its lease
286
+ lapses. To see who holds it, read `{ _id: { lease: 'articles:articles' } }` in
287
+ the state collection (`nxgt_search_sync` by default). Do not delete a live
288
+ holder's lease by hand: its next renewal finds it gone and it stops with
289
+ [`LEASE_LOST`](#search-sync-articlesarticles-lost-its-lease-another-process-holds-the-name-now-or-the-lease-was-removed-it-lapses-when-not-renewed-within-30000-ms-it-stopped-rather-than-run-beside-it).
290
+
291
+ ### `Search sync "articles:articles" failed taking its lease: …`
292
+
293
+ Code `FAILED`; the `cause` is the driver's error, and its message follows the
294
+ colon — for example
295
+ `not authorized on app to execute command { findAndModify: "nxgt_search_sync", … }`.
296
+
297
+ **When:** `start()` or `reindex()`, before anything else is read or sent.
298
+
299
+ **Why:** the lease lives in the state collection, beside the resume point, and
300
+ taking it is one atomic upsert. The MongoDB user needs `find`, `insert` and
301
+ `update` on that collection to take it, and `delete` to let go of it — or the
302
+ server is not reachable at all.
303
+
304
+ **Fix:** grant those four on the state collection, and name it if it lives
305
+ elsewhere:
306
+
307
+ ```ts
308
+ createSearchSync({ collection, index, transform, stateCollection: 'search_state' });
309
+ ```
310
+
311
+ ### `Search sync "articles:articles" failed checking its lease: …`
312
+
313
+ Code `FAILED`; the `cause` is the driver's error.
314
+
315
+ **When:** a reindex — `reindex()`, or the one `start()` runs first — just
316
+ before it removes what the index should no longer hold, just before it records
317
+ its resume point, or `start()` just before it opens the follower.
318
+
319
+ **Why:** those steps would undo what another holder did, so each asks the
320
+ server first that the lease is still this sync's. A check that does not reach
321
+ MongoDB is not tried again later, as a timed renewal is: the reindex stops
322
+ there. The pages it already sent stay in the index; nothing was removed or
323
+ recorded after the failed check.
324
+
325
+ **Fix:** it is the server or the network, not the lease. Run the reindex again
326
+ once MongoDB answers; the next one starts over.
327
+
241
328
  ## The transform
242
329
 
243
330
  ### `Search sync "articles:articles": transform gave "id" "other" for the document …, whose index id is …`
@@ -362,6 +449,55 @@ change stops the sync again.
362
449
 
363
450
  ## Stopping
364
451
 
452
+ ### `Search sync "articles:articles" lost its lease: another process holds the name now, or the lease was removed (it lapses when not renewed within 30000 ms). It stopped rather than run beside it.`
453
+
454
+ Code `LEASE_LOST`; the number is the sync's `leaseMs`. `closed` rejects with
455
+ it, and so do `reindex()` and `start()`.
456
+
457
+ **When:** the lease on the name is no longer this process's — the process
458
+ could not renew it for a whole `leaseMs` (an event loop blocked by synchronous
459
+ work, a long GC pause, MongoDB unreachable for longer than that) and another
460
+ process took the name in between, or the lease document was deleted by hand.
461
+ It is found:
462
+
463
+ - **while following** — at a renewal. `closed` rejects, and nothing more is
464
+ sent or recorded; a flush already in flight still finishes.
465
+ - **while reindexing** — a standalone `reindex()`, or `start()` during its
466
+ first reindex or the one after a lost history. A renewal that found it lost
467
+ stops the reindex after the page it just sent; the server is also asked
468
+ before documents are removed, again before the resume point is recorded,
469
+ and, for `start()`, before the follower opens. The call rejects **having
470
+ recorded nothing**, and having removed nothing unless the lease went while
471
+ the removal itself ran. The pages already sent stay in the index.
472
+
473
+ **Why:** another follower may be running on the same name. Carrying on would
474
+ send, remove and record beside it, so the sync stops, and leaves the new
475
+ holder's lease alone.
476
+
477
+ **Fix:** give `leaseMs` room above the longest pause you expect, and start
478
+ again when it happens — `start()` waits its turn with `RUNNING` while the new
479
+ holder runs. A reindex that stopped this way is run again once the name is
480
+ free:
481
+
482
+ ```ts
483
+ import { createSearchSync, SearchSyncError } from '@nxgt/mongo-meilisearch';
484
+
485
+ declare function follow(): Promise<void>; // the standby loop, below
486
+
487
+ const search = createSearchSync({ collection, index, transform, leaseMs: 120_000 });
488
+ const running = await search.start();
489
+
490
+ running.closed.catch((error) => {
491
+ if (error instanceof SearchSyncError && error.code === 'LEASE_LOST') return follow();
492
+ log.error(error);
493
+ });
494
+ ```
495
+
496
+ `follow()` is the standby loop from
497
+ [`… is held by …`](#search-sync-articlesarticles-is-held-by--until--wait-for-it-to-close-or-for-its-lease-to-lapse-before-you-start-it).
498
+ A larger `leaseMs` also means a process that dies holds the name that much
499
+ longer.
500
+
365
501
  ### The sync stops and `closed` resolves with `'invalidated'`
366
502
 
367
503
  Not an error: `closed` **resolves**, and the index is left as it was.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/mongo-meilisearch",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Keeps a Meilisearch index in step with a MongoDB collection: a typed transform, a full reindex, and a change stream that resumes where it stopped",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  ]
50
50
  },
51
51
  "devDependencies": {
52
- "@nxgt/meilisearch": "^0.2.0",
52
+ "@nxgt/meilisearch": "^0.4.0",
53
53
  "@nxgt/mongo": "^0.17.0",
54
54
  "@types/bun": "^1.4.0",
55
55
  "meilisearch": "0.62.0",
@@ -58,7 +58,7 @@
58
58
  "zod": "4.6.5"
59
59
  },
60
60
  "peerDependencies": {
61
- "@nxgt/meilisearch": "^0.2.0",
61
+ "@nxgt/meilisearch": "^0.4.0",
62
62
  "@nxgt/mongo": "^0.17.0",
63
63
  "meilisearch": ">=0.62.0 <1",
64
64
  "mongodb": ">=7.0.0 <8",