@nxgt/mongo-meilisearch 0.4.4 → 0.5.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,301 @@
1
+ # The search syncs' lifecycle
2
+
3
+ One `syncIndexes`, one `reindexAll`, one `start`, one `close` for every sync the
4
+ [config](wiring.md) named — and one promise to watch while they run.
5
+
6
+ ```ts
7
+ import { bindIndex } from '@nxgt/meilisearch';
8
+ import { openMongo, defineMongo } from '@nxgt/mongo';
9
+ import { createSearchSyncs } from '@nxgt/mongo-meilisearch';
10
+ import * as collections from './models'; // the defineCollections
11
+ import { meili } from './meili'; // a Meilisearch client
12
+ import { articleIndex, authorIndex } from './search'; // the defineIndexes
13
+
14
+ const mongo = await openMongo(
15
+ defineMongo({ uri: process.env.MONGO_URI!, collections }),
16
+ );
17
+
18
+ const search = createSearchSyncs(mongo, {
19
+ articles: {
20
+ index: bindIndex(meili, articleIndex),
21
+ transform: (article) => ({ id: String(article._id), title: article.title }),
22
+ },
23
+ authors: {
24
+ index: bindIndex(meili, authorIndex),
25
+ transform: (author) => ({ id: String(author._id), name: author.name }),
26
+ },
27
+ });
28
+
29
+ await search.syncIndexes(); // create every index, apply its settings
30
+ await search.reindexAll(); // fill every index
31
+ const running = await search.start(); // follow every collection
32
+ void running.failed.catch(() => process.exit(1)); // await it or catch it
33
+
34
+ // …
35
+
36
+ await running.close(); // flush and stop every sync
37
+ await mongo.close(); // the Mongo is still yours
38
+ ```
39
+
40
+ ## `syncs`
41
+
42
+ Each sync as `@nxgt/mongo-meilisearch` built it, under the key the config
43
+ used — so anything `createSearchSyncs` does not wrap is still reachable:
44
+
45
+ ```ts
46
+ search.syncs.articles.name; // 'articles:articles'
47
+ await search.syncs.articles.reindex(); // just this one
48
+ const one = await search.syncs.authors.start();
49
+ ```
50
+
51
+ ## `state()`
52
+
53
+ Where each sync stands, under its key; `undefined` for one that never
54
+ reindexed:
55
+
56
+ ```ts
57
+ await search.state();
58
+ // { articles: undefined, authors: undefined }
59
+
60
+ await search.reindexAll();
61
+ (await search.state()).articles?.reindexedAt; // a Date
62
+ ```
63
+
64
+ ## `syncIndexes(options?)`
65
+
66
+ Every index the config names brought in line with its definition — created
67
+ with its primary key when it is missing, and only the settings that differ
68
+ updated — one after another, each report under its key. It is
69
+ `@nxgt/meilisearch`'s `syncIndex` per entry, so the options and the report
70
+ are that package's:
71
+
72
+ ```ts
73
+ const reports = await search.syncIndexes();
74
+ // { articles: { uid: 'articles', created: true, changed: [ 'searchableAttributes' ], … },
75
+ // authors: { uid: 'authors', created: false, changed: [], … } }
76
+
77
+ await search.syncIndexes({ dryRun: true }); // what it would send, sends nothing
78
+ await search.syncIndexes({ wait: { timeout: 120_000 } }); // the SDK's WaitOptions, per task
79
+ ```
80
+
81
+ Run it twice and the second run sends nothing. The first index that throws
82
+ stops the rest, and the indexes after it are not looked at: a
83
+ `SearchIndexError` from `@nxgt/meilisearch` — `PRIMARY_KEY_MISMATCH` or
84
+ `TASK_FAILED` — or the SDK's own error for a Meilisearch that refused or is
85
+ not there. `dryRun` shows every **settings** difference at once, but not past
86
+ a primary-key mismatch: that one throws in a dry run too, since no setting
87
+ could make the index right.
88
+
89
+ It is a **deployment step**, like the Mongo's `sync()`, and it comes
90
+ first: `reindexAll` and `start` write documents, and an index a document write
91
+ creates gets the primary key but none of the definition's settings until a
92
+ sync runs — its filters and sorts are refused until then.
93
+
94
+ ## `reindexAll()`
95
+
96
+ Every collection, one after another, each report under its key:
97
+
98
+ ```ts
99
+ const reports = await search.reindexAll();
100
+ // { articles: { indexed: 1, skipped: 1, removed: 0 }, authors: { … } }
101
+ ```
102
+
103
+ **The first that throws stops the rest, and the reports already collected go
104
+ with the rejection.** A reindex removes what a collection no longer gives, so
105
+ a half-finished run is not a state to keep going from: read the error, fix
106
+ it, run it again — a reindex that succeeded is idempotent.
107
+
108
+ It cannot run beside a follower: `@nxgt/mongo-meilisearch` throws
109
+ `SearchSyncError` with the code `RUNNING` for a sync of the same object that
110
+ is already following, or whose name another process holds the lease on, and
111
+ `createSearchSyncs` passes it through.
112
+
113
+ Each reindex holds its sync's lease while it runs. One that finds the lease
114
+ taken over — the process stalled past `leaseMs`, or the lease was removed —
115
+ rejects with `LEASE_LOST`, having recorded nothing, and removed nothing unless
116
+ the lease went while the removal itself ran; the pages it already sent stay in
117
+ the index. `reindexAll()` passes that through, and stops there like any other
118
+ failure.
119
+
120
+ ## `start()`
121
+
122
+ Starts every sync and resolves once they are **all** hearing changes:
123
+
124
+ ```ts
125
+ const running = await search.start();
126
+ running.running.articles.ready; // each RunningSearchSync is reachable
127
+ ```
128
+
129
+ A sync that will not start closes the ones already started before the error
130
+ comes back, so a caller that catches it owns nothing:
131
+
132
+ ```ts
133
+ try {
134
+ await search.start();
135
+ } catch (error) {
136
+ // Nothing is running: no sync to close, no leak.
137
+ }
138
+ ```
139
+
140
+ A sync with nothing recorded reindexes as it starts, which on a real
141
+ collection is minutes — the syncs started before it are following changes
142
+ throughout, which is why a failure closes them rather than leaving them
143
+ behind.
144
+
145
+ Each sync takes the lease on its own name as it starts, so a second process
146
+ starting the same syncs is refused at the first name the other holds — a
147
+ `SearchSyncError` with the code `RUNNING`, naming the holder and when its
148
+ lease ends — and lets go of the names it had already taken:
149
+
150
+ ```ts
151
+ import { SearchSyncError } from '@nxgt/mongo-meilisearch';
152
+
153
+ try {
154
+ await search.start();
155
+ } catch (error) {
156
+ const held =
157
+ error instanceof SearchSyncError &&
158
+ (error.code === 'RUNNING' || error.code === 'LEASE_LOST');
159
+ if (held) {
160
+ // Another process follows these collections, or took one over while
161
+ // this start reindexed: wait, and try again. On RUNNING,
162
+ // `error.expiresAt` says until when that name's lease runs.
163
+ } else throw error;
164
+ }
165
+ ```
166
+
167
+ A `RUNNING` the lease refused carries its `holder` and its `expiresAt`, a
168
+ `Date` on MongoDB's clock, so a standby can wait exactly until that lease
169
+ lapses; the loop is in the [troubleshooting entry](../../troubleshooting.md#search-sync-articlesarticles-is-held-by--until--wait-for-it-to-close-or-for-its-lease-to-lapse-before-you-start-it).
170
+ Both are `undefined` on a `LEASE_LOST`, and on the refusal of syncs that are
171
+ already running in this process.
172
+
173
+ A lease lasts each entry's `leaseMs` (30 s) unrenewed, so a process that
174
+ died holds its names that long at most. `start()` itself rejects with
175
+ `LEASE_LOST` when a sync's first reindex finds its lease taken over, and
176
+ closes the syncs already started, as for any failure. A running sync whose
177
+ lease another process took over stops, and `failed` rejects with
178
+ `LEASE_LOST`. The lease
179
+ itself is the single sync's: see [Following changes](../following-changes.md).
180
+
181
+ ## `failed`
182
+
183
+ | Member | Type | Effect |
184
+ | --- | --- | --- |
185
+ | `running` | `{ [K in keyof I]: RunningSearchSync }` | Each running sync, with its own `ready`, `closed` and `flush` |
186
+ | `failed` | `Promise<never>` | Rejects with the **first** sync that stops on an error. Never resolves |
187
+ | `flush()` | `Promise<void>` | Sends what every sync holds and records where each one is. Stops at the first that fails |
188
+ | `close()` | `Promise<void>` | Flushes, then stops every sync. Idempotent |
189
+
190
+ ```ts
191
+ running.failed.catch((error: SearchSyncError) => {
192
+ console.error({ sync: error.sync, code: error.code, cause: error.cause });
193
+ process.exit(1); // let the supervisor restart the process
194
+ });
195
+ ```
196
+
197
+ - **It must be taken.** A rejection nobody handles ends the process. `createSearchSyncs`
198
+ already takes each sync's own `closed`, so `failed` is the only one left to
199
+ you — handle it.
200
+ - **It never resolves.** A clean stop is not an event to wait for, so
201
+ `await running.failed` after `close()` waits forever. It is for `catch`, or
202
+ for racing against your own shutdown.
203
+ - **A dropped collection leaves it silent.** `@nxgt/mongo-meilisearch` treats
204
+ an invalidated stream as a clean stop — that sync's `closed` *resolves*
205
+ with `'invalidated'` — and `createSearchSyncs` forwards failures only. Watch
206
+ `running.running.articles.closed` when a drop has to be noticed.
207
+
208
+ ## `flush()` and `close()`
209
+
210
+ ```ts
211
+ await running.flush(); // every sync sends what it holds, and records it
212
+ await running.close(); // flushes, then stops; or `await using running = …`
213
+ ```
214
+
215
+ `flush()` stops at the first sync that fails, like `reindexAll()`: the syncs
216
+ after it in the config are neither sent nor recorded, and re-read those
217
+ changes on the next start — safe, but not free.
218
+
219
+ `close()` does not share that: it closes each sync in turn and keeps going
220
+ past one that fails. It swallows the failure `failed` already carried — a
221
+ caller should not have to wrap `close` to hear the same thing twice — and
222
+ throws anything else, including a **second** sync that fell over after
223
+ `failed` had settled, and anything that goes wrong while closing. If more
224
+ than one throws, it reports the first.
225
+
226
+ `close()` is idempotent, and `RunningSearchSyncs` is `AsyncDisposable`.
227
+
228
+ ## A worker process
229
+
230
+ The Mongo and the search syncs, started in order and closed in reverse:
231
+
232
+ ```ts
233
+ import { openMongo } from '@nxgt/mongo';
234
+ import { createSearchSyncs } from '@nxgt/mongo-meilisearch';
235
+ import type { SearchSyncError } from '@nxgt/mongo-meilisearch';
236
+ import { config } from './db';
237
+ import { searchConfig } from './search';
238
+
239
+ const mongo = await openMongo(config);
240
+ const search = createSearchSyncs(mongo, searchConfig(meili));
241
+
242
+ const running = await search.start();
243
+
244
+ const stop = async () => {
245
+ await running.close(); // flushes and records where each sync is
246
+ await mongo.close(); // the search syncs never close it
247
+ process.exit(0);
248
+ };
249
+ process.on('SIGTERM', stop);
250
+ process.on('SIGINT', stop);
251
+
252
+ // The only promise left to handle: the first sync that falls over.
253
+ await running.failed.catch(async (error: SearchSyncError) => {
254
+ console.error({ sync: error.sync, code: error.code, cause: error.cause });
255
+ await running.close().catch(() => undefined);
256
+ await mongo.close();
257
+ process.exit(1);
258
+ });
259
+ ```
260
+
261
+ A deployment step is the same two objects, without `start`:
262
+
263
+ ```ts
264
+ await using mongo = await openMongo(config);
265
+ await mongo.sync(); // the collections
266
+ const search = createSearchSyncs(mongo, searchConfig(meili));
267
+ await search.syncIndexes(); // the indexes
268
+ const reports = await search.reindexAll();
269
+ for (const [key, report] of Object.entries(reports)) {
270
+ console.log(`${key}: ${report.indexed} indexed, ${report.removed} removed`);
271
+ }
272
+ ```
273
+
274
+ ## Signatures
275
+
276
+ ```ts
277
+ interface SearchSyncs<S> {
278
+ readonly syncs: ByKey<S, SearchSync>;
279
+ state(): Promise<ByKey<S, SearchSyncState | undefined>>;
280
+ syncIndexes(options?: SyncOptions): Promise<ByKey<S, SyncReport>>; // @nxgt/meilisearch's
281
+ reindexAll(): Promise<ByKey<S, ReindexReport>>;
282
+ start(): Promise<RunningSearchSyncs<S>>;
283
+ }
284
+
285
+ interface RunningSearchSyncs<S> extends AsyncDisposable {
286
+ readonly running: ByKey<S, RunningSearchSync>;
287
+ readonly failed: Promise<never>;
288
+ flush(): Promise<void>;
289
+ close(): Promise<void>;
290
+ }
291
+
292
+ type ByKey<S, T> = { readonly [K in keyof S]: T };
293
+ ```
294
+
295
+ `SearchSync`, `RunningSearchSync`, `SearchSyncState`, `ReindexReport` and
296
+ `SearchSyncError` are `@nxgt/mongo-meilisearch`'s, unchanged.
297
+
298
+ ## Next
299
+
300
+ - [Wiring it over a Mongo](wiring.md) — the config behind all of this.
301
+ - [Troubleshooting](../../troubleshooting.md) — the messages, with their fixes.
@@ -0,0 +1,188 @@
1
+ # Wiring it over a Mongo
2
+
3
+ `createSearchSyncs(mongo, config)` takes what
4
+ [`@nxgt/mongo`](https://www.npmjs.com/package/@nxgt/mongo)'s `openMongo`
5
+ returned and one entry per collection to follow — an index and a transform — and gives back
6
+ one object holding every sync.
7
+
8
+ ```ts
9
+ import { bindIndex } from '@nxgt/meilisearch';
10
+ import { createSearchSyncs } from '@nxgt/mongo-meilisearch';
11
+ import { mongo } from './db'; // the app's openMongo
12
+ import { meili } from './meili'; // a Meilisearch client
13
+ import { articleIndex, authorIndex } from './search'; // its defineIndexes
14
+
15
+ const search = createSearchSyncs(mongo, {
16
+ articles: {
17
+ index: bindIndex(meili, articleIndex),
18
+ transform: (article) =>
19
+ article.draft ? null : { id: String(article._id), title: article.title },
20
+ },
21
+ authors: {
22
+ index: bindIndex(meili, authorIndex),
23
+ transform: (author) => ({ id: String(author._id), name: author.name }),
24
+ },
25
+ });
26
+ ```
27
+
28
+ `createSearchSyncs` sends nothing: it builds the syncs.
29
+ [`reindexAll` and `start`](lifecycle.md) are what write.
30
+
31
+ ## The keys are the Mongo's own
32
+
33
+ A key is the name a collection is **exported** under — the same one
34
+ `mongo.db.articles` answers to, not the collection's name on the server. That
35
+ is what lets the transform be written inline: its argument is typed by the
36
+ collection the key names, and its result by the index that entry carries.
37
+
38
+ ```ts
39
+ createSearchSyncs(mongo, {
40
+ nowhere: { index: bindIndex(meili, articleIndex), transform: () => null },
41
+ });
42
+ // Type error: index: 'createSearchSyncs: this Mongo wires no collection called "nowhere"'
43
+ // TypeError at run time: createSearchSyncs: this Mongo wires no collection called "nowhere"
44
+ ```
45
+
46
+ A key that is a member of the driver's `Db` — `command`, `watch` — is refused
47
+ the same way: the Mongo never wires a collection under one.
48
+
49
+ Only the collections you name are followed. A Mongo wiring twenty collections
50
+ and a config naming two builds two syncs.
51
+
52
+ ## One database
53
+
54
+ `createSearchSyncs` follows the collections of **one** database, as `mongo.db` itself
55
+ does:
56
+
57
+ ```ts
58
+ const mongo = await openMongo(
59
+ defineMongo({
60
+ databases: {
61
+ main: { uri, collections },
62
+ analytics: { uri, database: 'analytics', collections: events },
63
+ },
64
+ }),
65
+ );
66
+
67
+ createSearchSyncs(mongo, { articles: { index, transform } });
68
+ // TypeError: createSearchSyncs: this Mongo holds 2 databases (main, analytics),
69
+ // and createSearchSyncs follows the collections of one. Build one
70
+ // `createSearchSyncs` per database, from a Mongo that wires that database alone
71
+ ```
72
+
73
+ A Mongo of several has no sole collections, so its keys are `never` and the
74
+ config does not compile either — every entry is refused, with the same
75
+ `wires no collection called "…"` message. Build one Mongo per database,
76
+ and one `createSearchSyncs` over each.
77
+
78
+ ## What an entry takes
79
+
80
+ Everything [`createSearchSync`](../sync-lifecycle.md)
81
+ takes **except `collection`**, which the Mongo already holds:
82
+
83
+ | Option | Type | Default | Effect |
84
+ | --- | --- | --- | --- |
85
+ | `index` | `TypedIndex<I>` | — | Where the documents go: from `bindIndex` |
86
+ | `transform` | `Transform<Col, I>` | — | The document as the index holds it; `null` keeps it out, and takes it out |
87
+ | `toIndexId` | `ToIndexId<Col, I>` | `String` | The index id of a `_id`. Optional while the index's ids are strings, required otherwise |
88
+ | `name` | `string` | `'<collection>:<index uid>'` | What this sync's resume point is recorded under |
89
+ | `stateCollection` | `string` | `'nxgt_search_sync'` | Where resume points are kept, in the Mongo's database |
90
+ | `batchSize` | `number` | `500` | Changes, or documents, sent at once |
91
+ | `flushIntervalMs` | `number` | `1000` | How long a change waits for others |
92
+ | `positionIntervalMs` | `number` | `60000` | How often a quiet sync records where its stream is |
93
+ | `leaseMs` | `number` | `30000` | How long the lease on this sync's name lasts unrenewed; a running sync renews it every third of that |
94
+ | `pageSize` | `number` | `100` | Documents a reindex reads per page |
95
+ | `onHistoryLost` | `'reindex' \| 'fail'` | `'reindex'` | What `start` does when a resume point is older than the server's history |
96
+
97
+ Each entry sets its own: a large collection can take a bigger `pageSize`
98
+ while a small one keeps the default.
99
+
100
+ ```ts
101
+ const search = createSearchSyncs(mongo, {
102
+ articles: {
103
+ index: bindIndex(meili, articleIndex),
104
+ transform: toArticleHit,
105
+ pageSize: 500,
106
+ onHistoryLost: 'fail',
107
+ },
108
+ authors: {
109
+ index: bindIndex(meili, authorIndex),
110
+ transform: toAuthorHit,
111
+ name: 'authors:public',
112
+ },
113
+ });
114
+ ```
115
+
116
+ **Two `createSearchSyncs` built from one config share their resume points**, since
117
+ the default `name` is `'<collection>:<index uid>'`. Give `name` when two are
118
+ meant to be different.
119
+
120
+ ## The whole wiring
121
+
122
+ The clients, the Mongo, the index settings, the syncs:
123
+
124
+ ```ts
125
+ // src/search/index.ts
126
+ import { bindIndex } from '@nxgt/meilisearch';
127
+ import { openMongo } from '@nxgt/mongo';
128
+ import { createSearchSyncs } from '@nxgt/mongo-meilisearch';
129
+ import { Meilisearch } from 'meilisearch';
130
+ import { config } from '../db';
131
+ import { articleIndex, authorIndex, toArticleHit, toAuthorHit } from './indexes';
132
+
133
+ export const meili = new Meilisearch({
134
+ host: process.env.MEILI_HOST!,
135
+ apiKey: process.env.MEILI_KEY!,
136
+ });
137
+
138
+ export async function buildSearch() {
139
+ const mongo = await openMongo(config);
140
+
141
+ const search = createSearchSyncs(mongo, {
142
+ articles: { index: bindIndex(meili, articleIndex), transform: toArticleHit },
143
+ authors: { index: bindIndex(meili, authorIndex), transform: toAuthorHit },
144
+ });
145
+
146
+ // Every index created and its settings applied, before anything writes
147
+ // documents: a deployment step, beside `mongo.sync()`.
148
+ await search.syncIndexes();
149
+
150
+ return { mongo, search };
151
+ }
152
+ ```
153
+
154
+ `createSearchSyncs` **does not own the Mongo**: closing its syncs stops them
155
+ and nothing else, and `mongo.close()` stays the caller's to make.
156
+
157
+ ## Signatures
158
+
159
+ ```ts
160
+ function createSearchSyncs<C, const I extends IndexMap<I>>(
161
+ mongo: Mongo<C>,
162
+ config: SearchSyncsConfig<C, I>,
163
+ ): SearchSyncs<I>;
164
+
165
+ type SearchSyncEntry<Col, I> = Omit<SearchSyncOptions<Col, I>, 'collection'>;
166
+
167
+ type SearchSyncsConfig<C, I extends IndexMap<I>> = {
168
+ [K in keyof I]: K extends keyof SoleCollections<C>
169
+ ? SoleCollections<C>[K] extends infer Col extends AnyCollectionDefinition
170
+ ? SearchSyncEntry<Col, I[K]>
171
+ : never
172
+ : {
173
+ index: `createSearchSyncs: this Mongo wires no collection called "${K & string}"`;
174
+ };
175
+ };
176
+
177
+ type SoleCollections<C>; // what a Mongo's sole database holds, by export name
178
+ type IndexMap<I> = { [K in keyof I]: AnyIndexDefinition };
179
+ type ByKey<S, T> = { readonly [K in keyof S]: T };
180
+ ```
181
+
182
+ `I` is inferred from each entry's `index` alone, which is what lets a
183
+ `transform` be written inline.
184
+
185
+ ## Next
186
+
187
+ - [The search syncs' lifecycle](lifecycle.md) — `reindexAll`, `start`, `failed`,
188
+ `flush` and `close`.
package/docs/roadmap.md CHANGED
@@ -26,12 +26,30 @@ _Nothing queued._
26
26
  but a change to one of them does not reach the index: the sync follows the
27
27
  collection it was given. Run a sync per collection that has to move the
28
28
  index.
29
+ - **`createSearchSyncs` over several databases** — a Mongo holding more than
30
+ one gives `never` for its keys, and `createSearchSyncs` throws naming them.
31
+ Build one `createSearchSyncs` per database, from a Mongo that wires that
32
+ database alone.
33
+ - **`createSearchSyncs` owning the Mongo** — closing the search syncs stops
34
+ them and nothing else: the clients, the databases and the collections
35
+ belong to the Mongo, and `mongo.close()` stays the caller's.
36
+ - **A lock of its own for `createSearchSyncs`** — the lease already provides
37
+ it: each sync takes a lease on its name, so a second process starting the
38
+ same syncs is refused with `RUNNING`.
29
39
  - **One index fed by two collections, or by another writer** — a reindex
30
40
  removes every document the collection does not give it, whoever wrote it.
31
41
  The sync owns its index.
32
42
 
33
43
  ## Shipped
34
44
 
45
+ - **Several collections from one config** — `createSearchSyncs(mongo, config)`
46
+ over what `@nxgt/mongo`'s `openMongo` returns: one entry per collection,
47
+ under the key the Mongo wires it as, and one `syncIndexes`, `reindexAll`,
48
+ `start` and `close` for all of them, with `failed` for the first sync that
49
+ stops. It is `@nxgt/mongo-search-kit`, folded in and renamed
50
+ (`createSearchKit` is `createSearchSyncs`), whose own roadmap is carried
51
+ here: `syncIndexes()` and one follower per sync name across processes came
52
+ with its 0.2.0, and the package is now a deprecated re-export — 0.5.0.
35
53
  - **A standby waits exactly for the holder** — a `RUNNING` the lease refused
36
54
  carries `holder` and `expiresAt`, read from the lease document, so a process
37
55
  waiting for the name sleeps until the holder's lease lapses instead of a