@quaesitor-textus/mongo 0.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.
Files changed (51) hide show
  1. package/LICENSE +180 -0
  2. package/README.md +417 -0
  3. package/dist/adapters/express.cjs +110 -0
  4. package/dist/adapters/express.d.cts +20 -0
  5. package/dist/adapters/express.d.ts +20 -0
  6. package/dist/adapters/express.js +7 -0
  7. package/dist/adapters/fastify.cjs +113 -0
  8. package/dist/adapters/fastify.d.cts +10 -0
  9. package/dist/adapters/fastify.d.ts +10 -0
  10. package/dist/adapters/fastify.js +16 -0
  11. package/dist/adapters/next-app.cjs +120 -0
  12. package/dist/adapters/next-app.d.cts +9 -0
  13. package/dist/adapters/next-app.d.ts +9 -0
  14. package/dist/adapters/next-app.js +23 -0
  15. package/dist/adapters/next-pages.cjs +110 -0
  16. package/dist/adapters/next-pages.d.cts +5 -0
  17. package/dist/adapters/next-pages.d.ts +5 -0
  18. package/dist/adapters/next-pages.js +7 -0
  19. package/dist/chunk-AUIK33V2.js +55 -0
  20. package/dist/chunk-RXTFVXXU.js +42 -0
  21. package/dist/index.cjs +288 -0
  22. package/dist/index.d.cts +51 -0
  23. package/dist/index.d.ts +51 -0
  24. package/dist/index.js +203 -0
  25. package/dist/startSearchSync-Bk7Na8Do.d.cts +39 -0
  26. package/dist/startSearchSync-Bk7Na8Do.d.ts +39 -0
  27. package/package.json +88 -0
  28. package/src/adapters/express.ts +8 -0
  29. package/src/adapters/fastify.ts +19 -0
  30. package/src/adapters/next-app.ts +27 -0
  31. package/src/adapters/next-pages.ts +11 -0
  32. package/src/adapters/shared.ts +61 -0
  33. package/src/buildTextSearchFilter.test.ts +30 -0
  34. package/src/buildTextSearchFilter.ts +34 -0
  35. package/src/computeSearchFields.test.ts +23 -0
  36. package/src/computeSearchFields.ts +31 -0
  37. package/src/config.ts +14 -0
  38. package/src/createLiveSearch.test.ts +48 -0
  39. package/src/createLiveSearch.ts +57 -0
  40. package/src/index.ts +12 -0
  41. package/src/modes.test.ts +20 -0
  42. package/src/modes.ts +24 -0
  43. package/src/parity.test.ts +60 -0
  44. package/src/searchIndexes.test.ts +12 -0
  45. package/src/searchIndexes.ts +22 -0
  46. package/src/sse.test.ts +11 -0
  47. package/src/sse.ts +7 -0
  48. package/src/startSearchSync.test.ts +42 -0
  49. package/src/startSearchSync.ts +91 -0
  50. package/src/version.test.ts +40 -0
  51. package/src/version.ts +41 -0
@@ -0,0 +1,42 @@
1
+ import {
2
+ createLiveSearch,
3
+ formatSse,
4
+ sseComment
5
+ } from "./chunk-AUIK33V2.js";
6
+
7
+ // src/adapters/shared.ts
8
+ var SSE_HEADERS = {
9
+ "Content-Type": "text/event-stream",
10
+ "Cache-Control": "no-cache",
11
+ Connection: "keep-alive"
12
+ };
13
+ function runLiveSearch(opts, write) {
14
+ const sendEvent = (e) => write(formatSse(e));
15
+ const live = createLiveSearch({
16
+ sync: opts.sync,
17
+ collection: opts.collection,
18
+ config: opts.config,
19
+ filter: opts.filter,
20
+ sort: opts.sort,
21
+ cap: opts.cap,
22
+ sendEvent
23
+ });
24
+ const hb = setInterval(() => write(sseComment()), opts.heartbeatMs ?? 25e3);
25
+ return {
26
+ stop: () => {
27
+ clearInterval(hb);
28
+ live.stop();
29
+ }
30
+ };
31
+ }
32
+ function streamToNodeResponse(req, res, opts) {
33
+ res.writeHead(200, SSE_HEADERS);
34
+ const { stop } = runLiveSearch(opts, (chunk) => res.write(chunk));
35
+ req.on("close", stop);
36
+ }
37
+
38
+ export {
39
+ SSE_HEADERS,
40
+ runLiveSearch,
41
+ streamToNodeResponse
42
+ };
package/dist/index.cjs ADDED
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ DEFAULT_NAMESPACE: () => DEFAULT_NAMESPACE,
24
+ DEFAULT_NGRAM_SIZES: () => DEFAULT_NGRAM_SIZES,
25
+ SEARCH_FIELDS_VERSION: () => SEARCH_FIELDS_VERSION,
26
+ buildTextSearchFilter: () => buildTextSearchFilter,
27
+ computeSearchFields: () => computeSearchFields,
28
+ createLiveSearch: () => createLiveSearch,
29
+ createSearchIndexes: () => createSearchIndexes,
30
+ escapeRegex: () => escapeRegex,
31
+ formatSse: () => formatSse,
32
+ modeKey: () => modeKey,
33
+ searchFieldsVersion: () => searchFieldsVersion,
34
+ searchIndexSpecs: () => searchIndexSpecs,
35
+ sseComment: () => sseComment,
36
+ startSearchSync: () => startSearchSync,
37
+ targetModes: () => targetModes
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+
41
+ // src/config.ts
42
+ var DEFAULT_NAMESPACE = "_qt";
43
+ var DEFAULT_NGRAM_SIZES = [2, 3];
44
+
45
+ // src/modes.ts
46
+ function modeKey(o = {}) {
47
+ let k = "norm";
48
+ if (o.caseSensitive) k += "_cs";
49
+ if (o.diacriticSensitive) k += "_ds";
50
+ return k;
51
+ }
52
+ function targetModes(t) {
53
+ const modes = [t.options ?? {}, ...t.queryModes ?? []];
54
+ const seen = /* @__PURE__ */ new Set();
55
+ const out = [];
56
+ for (const m of modes) {
57
+ const k = modeKey(m);
58
+ if (!seen.has(k)) {
59
+ seen.add(k);
60
+ out.push(m);
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ function escapeRegex(s) {
66
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
67
+ }
68
+
69
+ // src/computeSearchFields.ts
70
+ var import_core = require("@quaesitor-textus/core");
71
+
72
+ // src/version.ts
73
+ var SEARCH_FIELDS_VERSION = 2;
74
+ function stableStringify(v) {
75
+ if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
76
+ if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
77
+ const obj = v;
78
+ return "{" + Object.keys(obj).sort().map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
79
+ }
80
+ function cyrb53(str) {
81
+ let h1 = 3735928559;
82
+ let h2 = 1103547991;
83
+ for (let i = 0; i < str.length; i++) {
84
+ const ch = str.charCodeAt(i);
85
+ h1 = Math.imul(h1 ^ ch, 2654435761);
86
+ h2 = Math.imul(h2 ^ ch, 1597334677);
87
+ }
88
+ h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
89
+ h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
90
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
91
+ }
92
+ function searchFieldsVersion(config) {
93
+ const sig = stableStringify({
94
+ ns: config.namespace ?? DEFAULT_NAMESPACE,
95
+ sizes: config.ngramSizes ?? DEFAULT_NGRAM_SIZES,
96
+ targets: config.targets
97
+ });
98
+ return `${SEARCH_FIELDS_VERSION}:${cyrb53(sig)}`;
99
+ }
100
+
101
+ // src/computeSearchFields.ts
102
+ function computeSearchFields(doc, config) {
103
+ const ns = config.namespace ?? DEFAULT_NAMESPACE;
104
+ const sizes = config.ngramSizes ?? DEFAULT_NGRAM_SIZES;
105
+ const targets = {};
106
+ for (const [name, target] of Object.entries(config.targets)) {
107
+ const corpus = (0, import_core.buildCorpus)(doc, target.fields);
108
+ const entry = {
109
+ // n-grams are built on the fully-folded corpus (the coarsest fold) so the
110
+ // index is a superset filter valid for every query mode.
111
+ ngrams: (0, import_core.toNgrams)((0, import_core.normalizeText)(corpus, {}), sizes)
112
+ };
113
+ for (const mode of targetModes(target)) {
114
+ entry[modeKey(mode)] = (0, import_core.normalizeText)(corpus, mode);
115
+ }
116
+ targets[name] = entry;
117
+ }
118
+ targets._v = searchFieldsVersion(config);
119
+ return { [ns]: targets };
120
+ }
121
+
122
+ // src/searchIndexes.ts
123
+ function searchIndexSpecs(config) {
124
+ const ns = config.namespace ?? DEFAULT_NAMESPACE;
125
+ return Object.keys(config.targets).map((name) => ({
126
+ key: { [`${ns}.${name}.ngrams`]: 1 },
127
+ name: `${ns}_${name}_ngrams`
128
+ }));
129
+ }
130
+ async function createSearchIndexes(collection, config) {
131
+ for (const spec of searchIndexSpecs(config)) {
132
+ await collection.createIndex(spec.key, { name: spec.name });
133
+ }
134
+ }
135
+
136
+ // src/buildTextSearchFilter.ts
137
+ var import_core2 = require("@quaesitor-textus/core");
138
+ function buildTextSearchFilter(target, patterns, config, options) {
139
+ if (patterns.length === 0) return {};
140
+ const ns = config.namespace ?? DEFAULT_NAMESPACE;
141
+ const sizes = config.ngramSizes ?? DEFAULT_NGRAM_SIZES;
142
+ const t = config.targets[target];
143
+ if (!t) throw new Error(`Unknown search target: ${target}`);
144
+ const mode = options ?? t.options ?? {};
145
+ const ngramField = `${ns}.${target}.ngrams`;
146
+ const verifyField = `${ns}.${target}.${modeKey(mode)}`;
147
+ const ngramTerms = [
148
+ ...new Set(patterns.flatMap((p) => (0, import_core2.toNgrams)((0, import_core2.normalizeText)(p, {}), sizes)))
149
+ ];
150
+ const verifyConditions = patterns.map((p) => ({
151
+ [verifyField]: { $regex: escapeRegex((0, import_core2.normalizeText)(p, mode)) }
152
+ }));
153
+ return { $and: [{ [ngramField]: { $all: ngramTerms } }, ...verifyConditions] };
154
+ }
155
+
156
+ // src/startSearchSync.ts
157
+ function startSearchSync(collection, config, options = {}) {
158
+ const ns = config.namespace ?? DEFAULT_NAMESPACE;
159
+ const { idleMs = 750, backfill = false } = options;
160
+ const stream = collection.watch([], { fullDocument: "updateLookup" });
161
+ const listeners = /* @__PURE__ */ new Set();
162
+ const emit = (e) => {
163
+ for (const l of listeners) l(e);
164
+ };
165
+ let active = false;
166
+ let count = 0;
167
+ let startedAt = 0;
168
+ let idleTimer;
169
+ stream.on("change", (change) => {
170
+ if (!["insert", "update", "replace"].includes(change.operationType)) return;
171
+ const doc = change.fullDocument;
172
+ if (!doc) return;
173
+ const derived = computeSearchFields(doc, config);
174
+ if (JSON.stringify(doc[ns]) === JSON.stringify(derived[ns])) return;
175
+ if (!active) {
176
+ active = true;
177
+ count = 0;
178
+ startedAt = Date.now();
179
+ emit({ type: "indexing-started" });
180
+ }
181
+ count += 1;
182
+ void collection.updateOne({ _id: doc._id }, { $set: { [ns]: derived[ns] } }).then(() => emit({ type: "indexed", id: doc._id })).catch(() => {
183
+ });
184
+ if (idleTimer) clearTimeout(idleTimer);
185
+ idleTimer = setTimeout(() => {
186
+ active = false;
187
+ emit({ type: "indexing-finished", count, durationMs: Date.now() - startedAt });
188
+ }, idleMs);
189
+ });
190
+ if (backfill) void runBackfill();
191
+ async function runBackfill() {
192
+ const startedAt2 = Date.now();
193
+ let n = 0;
194
+ emit({ type: "indexing-started" });
195
+ const version = searchFieldsVersion(config);
196
+ const cursor = collection.find({
197
+ $or: [{ [ns]: { $exists: false } }, { [`${ns}._v`]: { $ne: version } }]
198
+ });
199
+ for await (const doc of cursor) {
200
+ const derived = computeSearchFields(doc, config);
201
+ await collection.updateOne({ _id: doc._id }, { $set: { [ns]: derived[ns] } }).catch(() => {
202
+ });
203
+ n += 1;
204
+ }
205
+ emit({ type: "indexing-finished", count: n, durationMs: Date.now() - startedAt2 });
206
+ }
207
+ return {
208
+ on: (l) => {
209
+ listeners.add(l);
210
+ },
211
+ off: (l) => {
212
+ listeners.delete(l);
213
+ },
214
+ stop: async () => {
215
+ if (idleTimer) clearTimeout(idleTimer);
216
+ listeners.clear();
217
+ await stream.close();
218
+ }
219
+ };
220
+ }
221
+
222
+ // src/createLiveSearch.ts
223
+ function createLiveSearch(opts) {
224
+ const { sync, collection, config: _config, filter, sort, cap = 500, sendEvent } = opts;
225
+ const seen = /* @__PURE__ */ new Set();
226
+ let count = 0;
227
+ let capped = false;
228
+ const idOf = (doc) => String(doc._id);
229
+ const cursor = collection.find(filter);
230
+ if (sort) cursor.sort({ [sort.field]: sort.dir });
231
+ void cursor.limit(cap).toArray().then((items) => {
232
+ for (const it of items) seen.add(idOf(it));
233
+ count = items.length;
234
+ sendEvent({ type: "snapshot", items });
235
+ if (count >= cap) {
236
+ capped = true;
237
+ sendEvent({ type: "capped" });
238
+ }
239
+ }).catch(() => sendEvent({ type: "snapshot", items: [] }));
240
+ const listener = (e) => {
241
+ if (e.type !== "indexed" || capped) return;
242
+ void collection.findOne({ $and: [{ _id: e.id }, filter] }).then((doc) => {
243
+ if (!doc || capped) return;
244
+ const id = idOf(doc);
245
+ if (seen.has(id)) return;
246
+ seen.add(id);
247
+ count += 1;
248
+ sendEvent({ type: "match", item: doc });
249
+ if (count >= cap) {
250
+ capped = true;
251
+ sendEvent({ type: "capped" });
252
+ }
253
+ }).catch(() => {
254
+ });
255
+ };
256
+ sync.on(listener);
257
+ return { stop: () => sync.off(listener) };
258
+ }
259
+
260
+ // src/sse.ts
261
+ function formatSse(event) {
262
+ return `data: ${JSON.stringify(event)}
263
+
264
+ `;
265
+ }
266
+ function sseComment(text = "ping") {
267
+ return `: ${text}
268
+
269
+ `;
270
+ }
271
+ // Annotate the CommonJS export names for ESM import in node:
272
+ 0 && (module.exports = {
273
+ DEFAULT_NAMESPACE,
274
+ DEFAULT_NGRAM_SIZES,
275
+ SEARCH_FIELDS_VERSION,
276
+ buildTextSearchFilter,
277
+ computeSearchFields,
278
+ createLiveSearch,
279
+ createSearchIndexes,
280
+ escapeRegex,
281
+ formatSse,
282
+ modeKey,
283
+ searchFieldsVersion,
284
+ searchIndexSpecs,
285
+ sseComment,
286
+ startSearchSync,
287
+ targetModes
288
+ });
@@ -0,0 +1,51 @@
1
+ import { M as MongoSearchTarget, a as MongoSearchConfig, S as SearchSync } from './startSearchSync-Bk7Na8Do.cjs';
2
+ export { D as DEFAULT_NAMESPACE, b as DEFAULT_NGRAM_SIZES, c as SearchSyncEvent, d as SearchSyncListener, e as StartSearchSyncOptions, s as startSearchSync } from './startSearchSync-Bk7Na8Do.cjs';
3
+ import { SearchOptions } from '@quaesitor-textus/core';
4
+ import { Collection, Filter, Document } from 'mongodb';
5
+
6
+ declare function modeKey(o?: SearchOptions): string;
7
+ declare function targetModes(t: MongoSearchTarget): SearchOptions[];
8
+ declare function escapeRegex(s: string): string;
9
+
10
+ declare function computeSearchFields(doc: unknown, config: MongoSearchConfig): Record<string, unknown>;
11
+
12
+ declare const SEARCH_FIELDS_VERSION = 2;
13
+ declare function searchFieldsVersion(config: MongoSearchConfig): string;
14
+
15
+ declare function searchIndexSpecs(config: MongoSearchConfig): Array<{
16
+ key: Record<string, 1>;
17
+ name: string;
18
+ }>;
19
+ declare function createSearchIndexes(collection: Collection, config: MongoSearchConfig): Promise<void>;
20
+
21
+ declare function buildTextSearchFilter(target: string, patterns: string[], config: MongoSearchConfig, options?: SearchOptions): Filter<Document>;
22
+
23
+ type LiveEvent = {
24
+ type: 'snapshot';
25
+ items: Document[];
26
+ } | {
27
+ type: 'match';
28
+ item: Document;
29
+ } | {
30
+ type: 'capped';
31
+ };
32
+ interface CreateLiveSearchOptions {
33
+ sync: SearchSync;
34
+ collection: Collection;
35
+ config: MongoSearchConfig;
36
+ filter: Filter<Document>;
37
+ sort?: {
38
+ field: string;
39
+ dir: 1 | -1;
40
+ };
41
+ cap?: number;
42
+ sendEvent: (event: LiveEvent) => void;
43
+ }
44
+ declare function createLiveSearch(opts: CreateLiveSearchOptions): {
45
+ stop: () => void;
46
+ };
47
+
48
+ declare function formatSse(event: unknown): string;
49
+ declare function sseComment(text?: string): string;
50
+
51
+ export { type CreateLiveSearchOptions, type LiveEvent, MongoSearchConfig, MongoSearchTarget, SEARCH_FIELDS_VERSION, SearchSync, buildTextSearchFilter, computeSearchFields, createLiveSearch, createSearchIndexes, escapeRegex, formatSse, modeKey, searchFieldsVersion, searchIndexSpecs, sseComment, targetModes };
@@ -0,0 +1,51 @@
1
+ import { M as MongoSearchTarget, a as MongoSearchConfig, S as SearchSync } from './startSearchSync-Bk7Na8Do.js';
2
+ export { D as DEFAULT_NAMESPACE, b as DEFAULT_NGRAM_SIZES, c as SearchSyncEvent, d as SearchSyncListener, e as StartSearchSyncOptions, s as startSearchSync } from './startSearchSync-Bk7Na8Do.js';
3
+ import { SearchOptions } from '@quaesitor-textus/core';
4
+ import { Collection, Filter, Document } from 'mongodb';
5
+
6
+ declare function modeKey(o?: SearchOptions): string;
7
+ declare function targetModes(t: MongoSearchTarget): SearchOptions[];
8
+ declare function escapeRegex(s: string): string;
9
+
10
+ declare function computeSearchFields(doc: unknown, config: MongoSearchConfig): Record<string, unknown>;
11
+
12
+ declare const SEARCH_FIELDS_VERSION = 2;
13
+ declare function searchFieldsVersion(config: MongoSearchConfig): string;
14
+
15
+ declare function searchIndexSpecs(config: MongoSearchConfig): Array<{
16
+ key: Record<string, 1>;
17
+ name: string;
18
+ }>;
19
+ declare function createSearchIndexes(collection: Collection, config: MongoSearchConfig): Promise<void>;
20
+
21
+ declare function buildTextSearchFilter(target: string, patterns: string[], config: MongoSearchConfig, options?: SearchOptions): Filter<Document>;
22
+
23
+ type LiveEvent = {
24
+ type: 'snapshot';
25
+ items: Document[];
26
+ } | {
27
+ type: 'match';
28
+ item: Document;
29
+ } | {
30
+ type: 'capped';
31
+ };
32
+ interface CreateLiveSearchOptions {
33
+ sync: SearchSync;
34
+ collection: Collection;
35
+ config: MongoSearchConfig;
36
+ filter: Filter<Document>;
37
+ sort?: {
38
+ field: string;
39
+ dir: 1 | -1;
40
+ };
41
+ cap?: number;
42
+ sendEvent: (event: LiveEvent) => void;
43
+ }
44
+ declare function createLiveSearch(opts: CreateLiveSearchOptions): {
45
+ stop: () => void;
46
+ };
47
+
48
+ declare function formatSse(event: unknown): string;
49
+ declare function sseComment(text?: string): string;
50
+
51
+ export { type CreateLiveSearchOptions, type LiveEvent, MongoSearchConfig, MongoSearchTarget, SEARCH_FIELDS_VERSION, SearchSync, buildTextSearchFilter, computeSearchFields, createLiveSearch, createSearchIndexes, escapeRegex, formatSse, modeKey, searchFieldsVersion, searchIndexSpecs, sseComment, targetModes };
package/dist/index.js ADDED
@@ -0,0 +1,203 @@
1
+ import {
2
+ createLiveSearch,
3
+ formatSse,
4
+ sseComment
5
+ } from "./chunk-AUIK33V2.js";
6
+
7
+ // src/config.ts
8
+ var DEFAULT_NAMESPACE = "_qt";
9
+ var DEFAULT_NGRAM_SIZES = [2, 3];
10
+
11
+ // src/modes.ts
12
+ function modeKey(o = {}) {
13
+ let k = "norm";
14
+ if (o.caseSensitive) k += "_cs";
15
+ if (o.diacriticSensitive) k += "_ds";
16
+ return k;
17
+ }
18
+ function targetModes(t) {
19
+ const modes = [t.options ?? {}, ...t.queryModes ?? []];
20
+ const seen = /* @__PURE__ */ new Set();
21
+ const out = [];
22
+ for (const m of modes) {
23
+ const k = modeKey(m);
24
+ if (!seen.has(k)) {
25
+ seen.add(k);
26
+ out.push(m);
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+ function escapeRegex(s) {
32
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
+ }
34
+
35
+ // src/computeSearchFields.ts
36
+ import { buildCorpus, normalizeText, toNgrams } from "@quaesitor-textus/core";
37
+
38
+ // src/version.ts
39
+ var SEARCH_FIELDS_VERSION = 2;
40
+ function stableStringify(v) {
41
+ if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
42
+ if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
43
+ const obj = v;
44
+ return "{" + Object.keys(obj).sort().map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
45
+ }
46
+ function cyrb53(str) {
47
+ let h1 = 3735928559;
48
+ let h2 = 1103547991;
49
+ for (let i = 0; i < str.length; i++) {
50
+ const ch = str.charCodeAt(i);
51
+ h1 = Math.imul(h1 ^ ch, 2654435761);
52
+ h2 = Math.imul(h2 ^ ch, 1597334677);
53
+ }
54
+ h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
55
+ h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
56
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
57
+ }
58
+ function searchFieldsVersion(config) {
59
+ const sig = stableStringify({
60
+ ns: config.namespace ?? DEFAULT_NAMESPACE,
61
+ sizes: config.ngramSizes ?? DEFAULT_NGRAM_SIZES,
62
+ targets: config.targets
63
+ });
64
+ return `${SEARCH_FIELDS_VERSION}:${cyrb53(sig)}`;
65
+ }
66
+
67
+ // src/computeSearchFields.ts
68
+ function computeSearchFields(doc, config) {
69
+ const ns = config.namespace ?? DEFAULT_NAMESPACE;
70
+ const sizes = config.ngramSizes ?? DEFAULT_NGRAM_SIZES;
71
+ const targets = {};
72
+ for (const [name, target] of Object.entries(config.targets)) {
73
+ const corpus = buildCorpus(doc, target.fields);
74
+ const entry = {
75
+ // n-grams are built on the fully-folded corpus (the coarsest fold) so the
76
+ // index is a superset filter valid for every query mode.
77
+ ngrams: toNgrams(normalizeText(corpus, {}), sizes)
78
+ };
79
+ for (const mode of targetModes(target)) {
80
+ entry[modeKey(mode)] = normalizeText(corpus, mode);
81
+ }
82
+ targets[name] = entry;
83
+ }
84
+ targets._v = searchFieldsVersion(config);
85
+ return { [ns]: targets };
86
+ }
87
+
88
+ // src/searchIndexes.ts
89
+ function searchIndexSpecs(config) {
90
+ const ns = config.namespace ?? DEFAULT_NAMESPACE;
91
+ return Object.keys(config.targets).map((name) => ({
92
+ key: { [`${ns}.${name}.ngrams`]: 1 },
93
+ name: `${ns}_${name}_ngrams`
94
+ }));
95
+ }
96
+ async function createSearchIndexes(collection, config) {
97
+ for (const spec of searchIndexSpecs(config)) {
98
+ await collection.createIndex(spec.key, { name: spec.name });
99
+ }
100
+ }
101
+
102
+ // src/buildTextSearchFilter.ts
103
+ import { normalizeText as normalizeText2, toNgrams as toNgrams2 } from "@quaesitor-textus/core";
104
+ function buildTextSearchFilter(target, patterns, config, options) {
105
+ if (patterns.length === 0) return {};
106
+ const ns = config.namespace ?? DEFAULT_NAMESPACE;
107
+ const sizes = config.ngramSizes ?? DEFAULT_NGRAM_SIZES;
108
+ const t = config.targets[target];
109
+ if (!t) throw new Error(`Unknown search target: ${target}`);
110
+ const mode = options ?? t.options ?? {};
111
+ const ngramField = `${ns}.${target}.ngrams`;
112
+ const verifyField = `${ns}.${target}.${modeKey(mode)}`;
113
+ const ngramTerms = [
114
+ ...new Set(patterns.flatMap((p) => toNgrams2(normalizeText2(p, {}), sizes)))
115
+ ];
116
+ const verifyConditions = patterns.map((p) => ({
117
+ [verifyField]: { $regex: escapeRegex(normalizeText2(p, mode)) }
118
+ }));
119
+ return { $and: [{ [ngramField]: { $all: ngramTerms } }, ...verifyConditions] };
120
+ }
121
+
122
+ // src/startSearchSync.ts
123
+ function startSearchSync(collection, config, options = {}) {
124
+ const ns = config.namespace ?? DEFAULT_NAMESPACE;
125
+ const { idleMs = 750, backfill = false } = options;
126
+ const stream = collection.watch([], { fullDocument: "updateLookup" });
127
+ const listeners = /* @__PURE__ */ new Set();
128
+ const emit = (e) => {
129
+ for (const l of listeners) l(e);
130
+ };
131
+ let active = false;
132
+ let count = 0;
133
+ let startedAt = 0;
134
+ let idleTimer;
135
+ stream.on("change", (change) => {
136
+ if (!["insert", "update", "replace"].includes(change.operationType)) return;
137
+ const doc = change.fullDocument;
138
+ if (!doc) return;
139
+ const derived = computeSearchFields(doc, config);
140
+ if (JSON.stringify(doc[ns]) === JSON.stringify(derived[ns])) return;
141
+ if (!active) {
142
+ active = true;
143
+ count = 0;
144
+ startedAt = Date.now();
145
+ emit({ type: "indexing-started" });
146
+ }
147
+ count += 1;
148
+ void collection.updateOne({ _id: doc._id }, { $set: { [ns]: derived[ns] } }).then(() => emit({ type: "indexed", id: doc._id })).catch(() => {
149
+ });
150
+ if (idleTimer) clearTimeout(idleTimer);
151
+ idleTimer = setTimeout(() => {
152
+ active = false;
153
+ emit({ type: "indexing-finished", count, durationMs: Date.now() - startedAt });
154
+ }, idleMs);
155
+ });
156
+ if (backfill) void runBackfill();
157
+ async function runBackfill() {
158
+ const startedAt2 = Date.now();
159
+ let n = 0;
160
+ emit({ type: "indexing-started" });
161
+ const version = searchFieldsVersion(config);
162
+ const cursor = collection.find({
163
+ $or: [{ [ns]: { $exists: false } }, { [`${ns}._v`]: { $ne: version } }]
164
+ });
165
+ for await (const doc of cursor) {
166
+ const derived = computeSearchFields(doc, config);
167
+ await collection.updateOne({ _id: doc._id }, { $set: { [ns]: derived[ns] } }).catch(() => {
168
+ });
169
+ n += 1;
170
+ }
171
+ emit({ type: "indexing-finished", count: n, durationMs: Date.now() - startedAt2 });
172
+ }
173
+ return {
174
+ on: (l) => {
175
+ listeners.add(l);
176
+ },
177
+ off: (l) => {
178
+ listeners.delete(l);
179
+ },
180
+ stop: async () => {
181
+ if (idleTimer) clearTimeout(idleTimer);
182
+ listeners.clear();
183
+ await stream.close();
184
+ }
185
+ };
186
+ }
187
+ export {
188
+ DEFAULT_NAMESPACE,
189
+ DEFAULT_NGRAM_SIZES,
190
+ SEARCH_FIELDS_VERSION,
191
+ buildTextSearchFilter,
192
+ computeSearchFields,
193
+ createLiveSearch,
194
+ createSearchIndexes,
195
+ escapeRegex,
196
+ formatSse,
197
+ modeKey,
198
+ searchFieldsVersion,
199
+ searchIndexSpecs,
200
+ sseComment,
201
+ startSearchSync,
202
+ targetModes
203
+ };
@@ -0,0 +1,39 @@
1
+ import { Collection } from 'mongodb';
2
+ import { SearchOptions } from '@quaesitor-textus/core';
3
+
4
+ interface MongoSearchTarget {
5
+ fields: string[];
6
+ options?: SearchOptions;
7
+ queryModes?: SearchOptions[];
8
+ }
9
+ interface MongoSearchConfig {
10
+ namespace?: string;
11
+ ngramSizes?: number[];
12
+ targets: Record<string, MongoSearchTarget>;
13
+ }
14
+ declare const DEFAULT_NAMESPACE = "_qt";
15
+ declare const DEFAULT_NGRAM_SIZES: number[];
16
+
17
+ type SearchSyncEvent = {
18
+ type: 'indexing-started';
19
+ } | {
20
+ type: 'indexing-finished';
21
+ count: number;
22
+ durationMs: number;
23
+ } | {
24
+ type: 'indexed';
25
+ id: unknown;
26
+ };
27
+ type SearchSyncListener = (event: SearchSyncEvent) => void;
28
+ interface SearchSync {
29
+ on(listener: SearchSyncListener): void;
30
+ off(listener: SearchSyncListener): void;
31
+ stop(): Promise<void>;
32
+ }
33
+ interface StartSearchSyncOptions {
34
+ idleMs?: number;
35
+ backfill?: boolean;
36
+ }
37
+ declare function startSearchSync(collection: Collection, config: MongoSearchConfig, options?: StartSearchSyncOptions): SearchSync;
38
+
39
+ export { DEFAULT_NAMESPACE as D, type MongoSearchTarget as M, type SearchSync as S, type MongoSearchConfig as a, DEFAULT_NGRAM_SIZES as b, type SearchSyncEvent as c, type SearchSyncListener as d, type StartSearchSyncOptions as e, startSearchSync as s };