@delali/narsil 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,9 +1,413 @@
1
- import { createDirectExecutor, fanOutQuery, tokenize, validateSchema, getLanguage } from './chunk-5HXMVMSM.mjs';
2
- export { getLanguage, registerLanguage } from './chunk-5HXMVMSM.mjs';
1
+ import { createDirectExecutor, createPartitionRouter, getLanguage, validateSchema, deserializePayloadV2, tokenize, fanOutQuery, createPartitionIndex, detectRuntime, fnv1a, kWayMerge } from './chunk-LIRVYUQ5.mjs';
2
+ export { getLanguage, registerLanguage } from './chunk-LIRVYUQ5.mjs';
3
+ export { isSimdAvailable } from './chunk-GVN2W4VA.mjs';
3
4
  import './chunk-WYC635GI.mjs';
4
- import { NarsilError, ErrorCodes } from './chunk-JKIBUPAY.mjs';
5
- export { ErrorCodes, NarsilError } from './chunk-JKIBUPAY.mjs';
6
- import { generateId } from './chunk-N37VMC62.mjs';
5
+ import { NarsilError, ErrorCodes } from './chunk-7GKPWGCN.mjs';
6
+ export { ErrorCodes, NarsilError } from './chunk-7GKPWGCN.mjs';
7
+ import { generateId, isValidWorkerResponse, createRequestId } from './chunk-HRABJLPB.mjs';
8
+ import { decode } from '@msgpack/msgpack';
9
+
10
+ // src/workers/worker-executor.ts
11
+ var DEFAULT_BACKPRESSURE_LIMIT = 100;
12
+ var DEFAULT_REQUEST_TIMEOUT = 3e4;
13
+ var SHUTDOWN_TIMEOUT = 5e3;
14
+ function createWorkerExecutor(worker, config) {
15
+ const backpressureLimit = DEFAULT_BACKPRESSURE_LIMIT;
16
+ const requestTimeout = DEFAULT_REQUEST_TIMEOUT;
17
+ const pending = /* @__PURE__ */ new Map();
18
+ function processResponse(msg) {
19
+ if (!isValidWorkerResponse(msg)) {
20
+ return;
21
+ }
22
+ const response = msg;
23
+ const entry = pending.get(response.requestId);
24
+ if (!entry) {
25
+ return;
26
+ }
27
+ clearTimeout(entry.timeoutId);
28
+ pending.delete(response.requestId);
29
+ if (response.type === "error") {
30
+ entry.reject(new NarsilError(response.code, response.message));
31
+ } else {
32
+ entry.resolve(response.data);
33
+ }
34
+ }
35
+ if (typeof worker.on === "function") {
36
+ worker.on("message", (msg) => processResponse(msg));
37
+ } else if (typeof worker.addEventListener === "function") {
38
+ worker.addEventListener("message", (event) => {
39
+ const msg = event.data;
40
+ processResponse(msg);
41
+ });
42
+ }
43
+ function execute(action) {
44
+ if (pending.size >= backpressureLimit) {
45
+ return Promise.reject(
46
+ new NarsilError(ErrorCodes.WORKER_BUSY, `Backpressure limit of ${backpressureLimit} pending requests reached`)
47
+ );
48
+ }
49
+ const requestId = createRequestId();
50
+ const taggedAction = { ...action, requestId };
51
+ return new Promise((resolve, reject) => {
52
+ const timeoutId = setTimeout(() => {
53
+ pending.delete(requestId);
54
+ reject(new NarsilError(ErrorCodes.WORKER_TIMEOUT, `Request ${requestId} timed out after ${requestTimeout}ms`));
55
+ }, requestTimeout);
56
+ pending.set(requestId, {
57
+ resolve,
58
+ reject,
59
+ timeoutId
60
+ });
61
+ worker.postMessage(taggedAction);
62
+ });
63
+ }
64
+ async function shutdown() {
65
+ const requestId = createRequestId();
66
+ const shutdownAction = { type: "shutdown", requestId };
67
+ const shutdownPromise = new Promise((resolve, reject) => {
68
+ const timeoutId = setTimeout(() => {
69
+ pending.delete(requestId);
70
+ reject(new NarsilError(ErrorCodes.WORKER_TIMEOUT, "Shutdown timed out"));
71
+ }, SHUTDOWN_TIMEOUT);
72
+ pending.set(requestId, {
73
+ resolve: () => resolve(),
74
+ reject,
75
+ timeoutId
76
+ });
77
+ worker.postMessage(shutdownAction);
78
+ });
79
+ try {
80
+ await shutdownPromise;
81
+ } finally {
82
+ for (const [id, entry] of pending) {
83
+ clearTimeout(entry.timeoutId);
84
+ entry.reject(new NarsilError(ErrorCodes.WORKER_CRASHED, "Worker is shutting down"));
85
+ pending.delete(id);
86
+ }
87
+ }
88
+ }
89
+ return { execute, shutdown };
90
+ }
91
+
92
+ // src/workers/factory.ts
93
+ function resolveEntryPoint() {
94
+ const base = import.meta.url;
95
+ if (base.endsWith(".mjs") || base.endsWith(".js")) {
96
+ return new URL("./workers/entry.mjs", base).href;
97
+ }
98
+ return base.replace(/\/src\/workers\/[^/]+$/, "/dist/workers/entry.mjs");
99
+ }
100
+ async function createWorkerFactory(entryPoint) {
101
+ const runtime = detectRuntime();
102
+ const resolvedEntry = resolveEntryPoint();
103
+ if (runtime.supportsWorkerThreads) {
104
+ const workerThreadsModule = await import('worker_threads');
105
+ return function nodeFactory(_workerId) {
106
+ const instance = new workerThreadsModule.Worker(new URL(resolvedEntry));
107
+ return createWorkerExecutor(instance);
108
+ };
109
+ }
110
+ if (runtime.supportsWebWorkers) {
111
+ return function webFactory(_workerId) {
112
+ const instance = new Worker(resolvedEntry, { type: "module" });
113
+ return createWorkerExecutor(instance);
114
+ };
115
+ }
116
+ throw new NarsilError(ErrorCodes.WORKER_CRASHED, `No worker support available on runtime "${runtime.runtime}"`);
117
+ }
118
+
119
+ // src/workers/pool.ts
120
+ function resolveWorkerCount(requested) {
121
+ if (requested !== void 0 && requested > 0) {
122
+ return requested;
123
+ }
124
+ let cpuCount = 4;
125
+ try {
126
+ if (navigator?.hardwareConcurrency) {
127
+ cpuCount = navigator.hardwareConcurrency;
128
+ } else if (typeof process !== "undefined") {
129
+ const ap = process.availableParallelism;
130
+ if (typeof ap === "function") {
131
+ cpuCount = ap();
132
+ }
133
+ }
134
+ } catch {
135
+ cpuCount = 4;
136
+ }
137
+ return Math.max(2, Math.min(8, cpuCount - 1));
138
+ }
139
+ function createWorkerPool(config) {
140
+ const workerCount = resolveWorkerCount(config.count);
141
+ const workers = [];
142
+ const indexAssignment = /* @__PURE__ */ new Map();
143
+ let isShutdown = false;
144
+ function ensureWorker(slotIndex) {
145
+ if (!workers[slotIndex]) {
146
+ workers[slotIndex] = {
147
+ executor: config.workerFactory(slotIndex),
148
+ indexes: /* @__PURE__ */ new Set(),
149
+ memoryStats: { heapUsed: 0, heapTotal: 0, external: 0 }
150
+ };
151
+ }
152
+ return workers[slotIndex];
153
+ }
154
+ function assignSlot(indexName) {
155
+ return fnv1a(indexName) % workerCount;
156
+ }
157
+ function addIndex(indexName) {
158
+ if (isShutdown) {
159
+ throw new NarsilError(ErrorCodes.WORKER_CRASHED, "Worker pool has been shut down");
160
+ }
161
+ if (indexAssignment.has(indexName)) {
162
+ return;
163
+ }
164
+ const slotIndex = assignSlot(indexName);
165
+ const slot = ensureWorker(slotIndex);
166
+ slot.indexes.add(indexName);
167
+ indexAssignment.set(indexName, slotIndex);
168
+ }
169
+ function removeIndex(indexName) {
170
+ const slotIndex = indexAssignment.get(indexName);
171
+ if (slotIndex === void 0) {
172
+ return;
173
+ }
174
+ const slot = workers[slotIndex];
175
+ if (slot) {
176
+ slot.indexes.delete(indexName);
177
+ }
178
+ indexAssignment.delete(indexName);
179
+ }
180
+ function getExecutor(indexName) {
181
+ if (isShutdown) {
182
+ throw new NarsilError(ErrorCodes.WORKER_CRASHED, "Worker pool has been shut down");
183
+ }
184
+ const slotIndex = indexAssignment.get(indexName);
185
+ if (slotIndex === void 0) {
186
+ throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, `Index "${indexName}" is not registered in the worker pool`);
187
+ }
188
+ return workers[slotIndex].executor;
189
+ }
190
+ function getMemoryStats() {
191
+ const stats = [];
192
+ for (let i = 0; i < workers.length; i++) {
193
+ const slot = workers[i];
194
+ if (slot) {
195
+ stats.push({
196
+ workerId: i,
197
+ heapUsed: slot.memoryStats.heapUsed,
198
+ heapTotal: slot.memoryStats.heapTotal,
199
+ external: slot.memoryStats.external
200
+ });
201
+ }
202
+ }
203
+ return stats;
204
+ }
205
+ async function shutdown() {
206
+ if (isShutdown) {
207
+ return;
208
+ }
209
+ isShutdown = true;
210
+ const shutdownPromises = workers.filter(Boolean).map((slot) => {
211
+ const timeoutPromise = new Promise((resolve) => {
212
+ setTimeout(resolve, 5e3);
213
+ });
214
+ return Promise.race([slot.executor.shutdown(), timeoutPromise]);
215
+ });
216
+ await Promise.allSettled(shutdownPromises);
217
+ indexAssignment.clear();
218
+ }
219
+ function getAllExecutors() {
220
+ return workers.filter(Boolean).map((slot) => slot.executor);
221
+ }
222
+ function addIndexToAll(indexName) {
223
+ if (isShutdown) {
224
+ throw new NarsilError(ErrorCodes.WORKER_CRASHED, "Worker pool has been shut down");
225
+ }
226
+ for (let i = 0; i < workerCount; i++) {
227
+ const slot = ensureWorker(i);
228
+ slot.indexes.add(indexName);
229
+ }
230
+ indexAssignment.set(indexName, 0);
231
+ }
232
+ return {
233
+ getExecutor,
234
+ getAllExecutors,
235
+ get workerCount() {
236
+ return workerCount;
237
+ },
238
+ addIndex,
239
+ addIndexToAll,
240
+ removeIndex,
241
+ getMemoryStats,
242
+ shutdown
243
+ };
244
+ }
245
+
246
+ // src/engine/orchestration.ts
247
+ function createWorkerOrchestrator(config, executor, promoter, indexRegistry, callbacks) {
248
+ let workerPool = null;
249
+ let promotionInProgress = false;
250
+ const promotionBuffer = [];
251
+ const workersEnabled = config?.workers?.enabled === true;
252
+ async function checkPromotion() {
253
+ if (!workersEnabled || promotionInProgress || workerPool) return;
254
+ const indexMap = /* @__PURE__ */ new Map();
255
+ for (const [name] of indexRegistry) {
256
+ const mgr = executor.getManager(name);
257
+ indexMap.set(name, { documentCount: mgr?.countDocuments() ?? 0 });
258
+ }
259
+ const result = promoter.check(indexMap);
260
+ if (result.shouldPromote) {
261
+ promotionInProgress = true;
262
+ setTimeout(() => {
263
+ runPromotion(result.reason).catch((err) => {
264
+ console.warn("Worker promotion failed:", err);
265
+ promotionInProgress = false;
266
+ });
267
+ }, 0);
268
+ }
269
+ }
270
+ async function runPromotion(reason) {
271
+ try {
272
+ const factory = await createWorkerFactory();
273
+ const pool = createWorkerPool({
274
+ count: config?.workers?.count,
275
+ workerFactory: factory
276
+ });
277
+ for (const [name] of indexRegistry) {
278
+ pool.addIndexToAll(name);
279
+ }
280
+ const allExecutors = pool.getAllExecutors();
281
+ for (const [name, entry] of indexRegistry) {
282
+ await Promise.all(
283
+ allExecutors.map(
284
+ (workerExecutor) => workerExecutor.execute({
285
+ type: "createIndex",
286
+ indexName: name,
287
+ config: entry.config,
288
+ requestId: `promote-create-${name}`
289
+ })
290
+ )
291
+ );
292
+ const manager = executor.getManager(name);
293
+ if (manager) {
294
+ for (let i = 0; i < manager.partitionCount; i++) {
295
+ const serialized = manager.serializePartition(i);
296
+ await Promise.all(
297
+ allExecutors.map(
298
+ (workerExecutor) => workerExecutor.execute({
299
+ type: "deserialize",
300
+ indexName: name,
301
+ partitionId: i,
302
+ data: serialized,
303
+ requestId: `promote-sync-${name}-${i}`
304
+ })
305
+ )
306
+ );
307
+ }
308
+ }
309
+ }
310
+ workerPool = pool;
311
+ promoter.markPromoted();
312
+ if (promotionBuffer.length > 0) {
313
+ const buffered = [...promotionBuffer];
314
+ promotionBuffer.length = 0;
315
+ for (const action of buffered) {
316
+ await replicateToWorkers(action);
317
+ }
318
+ }
319
+ callbacks?.onPromotion?.(pool.workerCount, reason);
320
+ } catch (err) {
321
+ promotionBuffer.length = 0;
322
+ throw err;
323
+ } finally {
324
+ promotionInProgress = false;
325
+ }
326
+ }
327
+ async function replicateToWorkers(action) {
328
+ if (promotionInProgress) {
329
+ promotionBuffer.push(action);
330
+ return;
331
+ }
332
+ if (!workerPool) return;
333
+ const allExecutors = workerPool.getAllExecutors();
334
+ const results = await Promise.allSettled(allExecutors.map((workerExecutor) => workerExecutor.execute(action)));
335
+ for (const result of results) {
336
+ if (result.status === "rejected") {
337
+ console.warn("Worker replication failed:", result.reason);
338
+ }
339
+ }
340
+ }
341
+ async function searchViaWorker(indexName, params) {
342
+ if (!workerPool) return null;
343
+ const manager = executor.getManager(indexName);
344
+ if (!manager) return null;
345
+ const allExecutors = workerPool.getAllExecutors();
346
+ const totalPartitions = manager.partitionCount;
347
+ const numWorkers = allExecutors.length;
348
+ if (numWorkers === 0) return null;
349
+ if (numWorkers === 1) {
350
+ try {
351
+ return await allExecutors[0].execute({
352
+ type: "query",
353
+ indexName,
354
+ params,
355
+ requestId: createRequestId()
356
+ });
357
+ } catch (err) {
358
+ console.warn("Worker search failed, falling back to local:", err);
359
+ return null;
360
+ }
361
+ }
362
+ try {
363
+ const workerAssignments = Array.from({ length: numWorkers }, () => []);
364
+ for (let p = 0; p < totalPartitions; p++) {
365
+ workerAssignments[p % numWorkers].push(p);
366
+ }
367
+ const results = await Promise.all(
368
+ allExecutors.map(
369
+ (workerExecutor, idx) => workerExecutor.execute({
370
+ type: "query",
371
+ indexName,
372
+ params,
373
+ requestId: createRequestId(),
374
+ partitionIds: workerAssignments[idx]
375
+ })
376
+ )
377
+ );
378
+ const allScored = results.map((r) => r.scored);
379
+ const merged = kWayMerge(allScored);
380
+ let totalMatched = 0;
381
+ for (const r of results) {
382
+ totalMatched += r.totalMatched;
383
+ }
384
+ return { scored: merged, totalMatched };
385
+ } catch (err) {
386
+ console.warn("Parallel worker search failed, falling back to local:", err);
387
+ return null;
388
+ }
389
+ }
390
+ function isPromoted() {
391
+ return workerPool !== null;
392
+ }
393
+ function getMemoryStats() {
394
+ const workers = workerPool ? workerPool.getMemoryStats().map((s) => ({
395
+ workerId: s.workerId,
396
+ heapUsed: s.heapUsed,
397
+ heapTotal: s.heapTotal,
398
+ external: s.external
399
+ })) : [];
400
+ const totalBytes = workers.reduce((sum, w) => sum + w.heapUsed, 0);
401
+ return { totalBytes, workers };
402
+ }
403
+ async function shutdown() {
404
+ if (workerPool) {
405
+ await workerPool.shutdown();
406
+ workerPool = null;
407
+ }
408
+ }
409
+ return { checkPromotion, replicateToWorkers, searchViaWorker, isPromoted, getMemoryStats, shutdown };
410
+ }
7
411
 
8
412
  // src/highlighting/highlighter.ts
9
413
  function buildTokenCharOffsets(text, originalTokens) {
@@ -139,222 +543,66 @@ function highlightField(text, queryTokens, language, options) {
139
543
  return { snippet: result, positions: mergedPositions };
140
544
  }
141
545
 
142
- // src/persistence/flush-manager.ts
143
- var DEFAULT_INTERVAL = 5e3;
144
- var DEFAULT_MUTATION_THRESHOLD = 1e3;
145
- var DEFAULT_MAX_RETRIES = 3;
146
- var DEFAULT_BASE_RETRY_DELAY = 100;
147
- function createFlushManager(config, getPartitionData, getInstanceId) {
148
- const {
149
- persistence,
150
- invalidation,
151
- interval = DEFAULT_INTERVAL,
152
- mutationThreshold = DEFAULT_MUTATION_THRESHOLD,
153
- maxRetries = DEFAULT_MAX_RETRIES,
154
- baseRetryDelay = DEFAULT_BASE_RETRY_DELAY,
155
- onError
156
- } = config;
157
- const dirtyPartitions = /* @__PURE__ */ new Map();
158
- let mutationCount = 0;
159
- let flushTimerId = null;
160
- let isShuttingDown = false;
161
- let flushPromise = null;
162
- function startTimer() {
163
- if (flushTimerId !== null || isShuttingDown) {
164
- return;
546
+ // src/search/grouping.ts
547
+ function getNestedValue(obj, path) {
548
+ const segments = path.split(".");
549
+ let current = obj;
550
+ for (const segment of segments) {
551
+ if (current === null || current === void 0 || typeof current !== "object") {
552
+ return void 0;
165
553
  }
166
- flushTimerId = setInterval(() => {
167
- flush();
168
- }, interval);
169
- }
170
- function addToDirtySet(indexName, partitionId) {
171
- let partitions = dirtyPartitions.get(indexName);
172
- if (partitions === void 0) {
173
- partitions = /* @__PURE__ */ new Set();
174
- dirtyPartitions.set(indexName, partitions);
554
+ if (!Object.hasOwn(current, segment)) {
555
+ return void 0;
175
556
  }
176
- partitions.add(partitionId);
557
+ current = current[segment];
177
558
  }
178
- function snapshotAndClearDirty() {
179
- const snapshot = /* @__PURE__ */ new Map();
180
- for (const [indexName, partitions] of dirtyPartitions) {
181
- snapshot.set(indexName, new Set(partitions));
182
- }
183
- dirtyPartitions.clear();
184
- return snapshot;
559
+ return current;
560
+ }
561
+ function applyGrouping(hits, group, getDocument) {
562
+ if (!group.fields || group.fields.length === 0) {
563
+ return [{ values: {}, hits }];
185
564
  }
186
- async function saveWithRetry(indexName, partitionId) {
187
- const key = `${indexName}/${partitionId}`;
188
- const data = getPartitionData(indexName, partitionId);
189
- for (let attempt = 0; attempt < maxRetries; attempt++) {
190
- try {
191
- await persistence.save(key, data);
192
- return true;
193
- } catch (err) {
194
- const isLastAttempt = attempt === maxRetries - 1;
195
- if (isLastAttempt) {
196
- onError?.(indexName, partitionId, err, true);
197
- return false;
198
- }
199
- const cappedAttempt = Math.min(attempt, 20);
200
- const delay = baseRetryDelay * 2 ** cappedAttempt * (0.5 + Math.random() * 0.5);
201
- await new Promise((resolve) => setTimeout(resolve, delay));
202
- }
565
+ const groupMap = /* @__PURE__ */ new Map();
566
+ for (const hit of hits) {
567
+ const doc = getDocument(hit.id);
568
+ const fieldValues = [];
569
+ const valuesRecord = {};
570
+ for (const field of group.fields) {
571
+ const value = doc ? getNestedValue(doc, field) : void 0;
572
+ fieldValues.push(value);
573
+ valuesRecord[field] = value;
574
+ }
575
+ const compositeKey = JSON.stringify(fieldValues);
576
+ const existing = groupMap.get(compositeKey);
577
+ if (existing) {
578
+ existing.hits.push(hit);
579
+ } else {
580
+ groupMap.set(compositeKey, { values: valuesRecord, hits: [hit] });
203
581
  }
204
- return false;
205
582
  }
206
- async function performFlush() {
207
- const snapshot = snapshotAndClearDirty();
208
- for (const [indexName, partitions] of snapshot) {
209
- const succeededPartitions = [];
210
- for (const partitionId of partitions) {
211
- const saved = await saveWithRetry(indexName, partitionId);
212
- if (saved) {
213
- succeededPartitions.push(partitionId);
214
- } else {
215
- addToDirtySet(indexName, partitionId);
583
+ const groups = [];
584
+ for (const entry of groupMap.values()) {
585
+ let groupHits = entry.hits;
586
+ if (group.maxPerGroup !== void 0 && group.maxPerGroup > 0) {
587
+ groupHits = groupHits.slice(0, group.maxPerGroup);
588
+ }
589
+ const result = { values: entry.values, hits: groupHits };
590
+ if (group.reduce) {
591
+ try {
592
+ let accumulator = group.reduce.initialValue();
593
+ for (const hit of groupHits) {
594
+ const doc = getDocument(hit.id);
595
+ if (doc) {
596
+ accumulator = group.reduce.reducer(accumulator, doc, hit.score);
597
+ }
216
598
  }
217
- }
218
- if (succeededPartitions.length > 0) {
219
- await invalidation.publish({
220
- type: "partition",
221
- indexName,
222
- partitions: succeededPartitions,
223
- timestamp: Date.now(),
224
- sourceInstanceId: getInstanceId()
225
- });
599
+ ;
600
+ result.reduced = accumulator;
601
+ } catch (err) {
602
+ result.reducerError = err instanceof Error ? err.message : String(err);
226
603
  }
227
604
  }
228
- mutationCount = 0;
229
- }
230
- function flush() {
231
- if (flushPromise !== null) {
232
- return flushPromise;
233
- }
234
- flushPromise = performFlush().finally(() => {
235
- flushPromise = null;
236
- });
237
- return flushPromise;
238
- }
239
- function markDirty(indexName, partitionId) {
240
- if (isShuttingDown) return;
241
- addToDirtySet(indexName, partitionId);
242
- mutationCount++;
243
- if (mutationCount >= mutationThreshold) {
244
- flush();
245
- }
246
- startTimer();
247
- }
248
- async function shutdown() {
249
- isShuttingDown = true;
250
- if (flushTimerId !== null) {
251
- clearInterval(flushTimerId);
252
- flushTimerId = null;
253
- }
254
- await flush();
255
- }
256
- return {
257
- markDirty,
258
- flush,
259
- shutdown
260
- };
261
- }
262
-
263
- // src/plugins/registry.ts
264
- function isThenable(value) {
265
- return value !== null && typeof value === "object" && typeof value.then === "function";
266
- }
267
- async function continueAsync(pending, hooks, startIndex, context) {
268
- await pending;
269
- for (let i = startIndex; i < hooks.length; i++) {
270
- await hooks[i](context);
271
- }
272
- }
273
- function createPluginRegistry() {
274
- const plugins = [];
275
- return {
276
- register(plugin) {
277
- plugins.push(plugin);
278
- },
279
- runHook(hookName, context) {
280
- const hooks = [];
281
- for (const plugin of plugins) {
282
- const hook = plugin[hookName];
283
- if (typeof hook === "function") {
284
- hooks.push(hook.bind(plugin));
285
- }
286
- }
287
- if (hooks.length === 0) return;
288
- for (let i = 0; i < hooks.length; i++) {
289
- const result = hooks[i](context);
290
- if (isThenable(result)) {
291
- return continueAsync(result, hooks, i + 1, context);
292
- }
293
- }
294
- }
295
- };
296
- }
297
-
298
- // src/search/grouping.ts
299
- function getNestedValue(obj, path) {
300
- const segments = path.split(".");
301
- let current = obj;
302
- for (const segment of segments) {
303
- if (current === null || current === void 0 || typeof current !== "object") {
304
- return void 0;
305
- }
306
- if (!Object.hasOwn(current, segment)) {
307
- return void 0;
308
- }
309
- current = current[segment];
310
- }
311
- return current;
312
- }
313
- function applyGrouping(hits, group, getDocument) {
314
- if (!group.fields || group.fields.length === 0) {
315
- return [{ values: {}, hits }];
316
- }
317
- const groupMap = /* @__PURE__ */ new Map();
318
- for (const hit of hits) {
319
- const doc = getDocument(hit.id);
320
- const fieldValues = [];
321
- const valuesRecord = {};
322
- for (const field of group.fields) {
323
- const value = doc ? getNestedValue(doc, field) : void 0;
324
- fieldValues.push(value);
325
- valuesRecord[field] = value;
326
- }
327
- const compositeKey = JSON.stringify(fieldValues);
328
- const existing = groupMap.get(compositeKey);
329
- if (existing) {
330
- existing.hits.push(hit);
331
- } else {
332
- groupMap.set(compositeKey, { values: valuesRecord, hits: [hit] });
333
- }
334
- }
335
- const groups = [];
336
- for (const entry of groupMap.values()) {
337
- let groupHits = entry.hits;
338
- if (group.maxPerGroup !== void 0 && group.maxPerGroup > 0) {
339
- groupHits = groupHits.slice(0, group.maxPerGroup);
340
- }
341
- const result = { values: entry.values, hits: groupHits };
342
- if (group.reduce) {
343
- try {
344
- let accumulator = group.reduce.initialValue();
345
- for (const hit of groupHits) {
346
- const doc = getDocument(hit.id);
347
- if (doc) {
348
- accumulator = group.reduce.reducer(accumulator, doc, hit.score);
349
- }
350
- }
351
- ;
352
- result.reduced = accumulator;
353
- } catch (err) {
354
- result.reducerError = err instanceof Error ? err.message : String(err);
355
- }
356
- }
357
- groups.push(result);
605
+ groups.push(result);
358
606
  }
359
607
  groups.sort((a, b) => {
360
608
  const aScore = a.hits.length > 0 ? a.hits[0].score : 0;
@@ -543,13 +791,583 @@ function applySorting(hits, sort, getDocument) {
543
791
  } else if (typeof aVal === "boolean" && typeof bVal === "boolean") {
544
792
  comparison = aVal === bVal ? 0 : aVal ? 1 : -1;
545
793
  }
546
- if (comparison !== 0) {
547
- return direction === "desc" ? -comparison : comparison;
794
+ if (comparison !== 0) {
795
+ return direction === "desc" ? -comparison : comparison;
796
+ }
797
+ }
798
+ return 0;
799
+ });
800
+ return sorted;
801
+ }
802
+
803
+ // src/engine/validation.ts
804
+ var INDEX_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
805
+ var MAX_INDEX_NAME_LENGTH = 256;
806
+ var MAX_DOC_ID_LENGTH = 512;
807
+ var BATCH_CHUNK_SIZE = 1e3;
808
+ var MAX_LIMIT = 1e4;
809
+ var MAX_OFFSET = 1e5;
810
+ var DEFAULT_LIMIT = 10;
811
+ var DEFAULT_OFFSET = 0;
812
+ function now() {
813
+ if (typeof performance !== "undefined" && typeof performance.now === "function") {
814
+ return performance.now();
815
+ }
816
+ return Date.now();
817
+ }
818
+ function validateIndexName(name) {
819
+ if (!name || name.length === 0) {
820
+ throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, "Index name must not be empty", { indexName: name });
821
+ }
822
+ if (name.length > MAX_INDEX_NAME_LENGTH) {
823
+ throw new NarsilError(
824
+ ErrorCodes.INDEX_NOT_FOUND,
825
+ `Index name must not exceed ${MAX_INDEX_NAME_LENGTH} characters`,
826
+ {
827
+ indexName: name,
828
+ length: name.length
829
+ }
830
+ );
831
+ }
832
+ if (!INDEX_NAME_PATTERN.test(name)) {
833
+ throw new NarsilError(
834
+ ErrorCodes.INDEX_NOT_FOUND,
835
+ `Index name "${name}" contains invalid characters; use alphanumeric, dots, hyphens, and underscores only`,
836
+ { indexName: name }
837
+ );
838
+ }
839
+ if (name.includes("..")) {
840
+ throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, `Index name "${name}" must not contain ".."`, { indexName: name });
841
+ }
842
+ }
843
+ function validateDocId(docId) {
844
+ if (!docId || docId.length === 0) {
845
+ throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Document ID must not be empty", { docId });
846
+ }
847
+ if (docId.length > MAX_DOC_ID_LENGTH) {
848
+ throw new NarsilError(
849
+ ErrorCodes.DOC_VALIDATION_FAILED,
850
+ `Document ID must not exceed ${MAX_DOC_ID_LENGTH} characters`,
851
+ {
852
+ docId,
853
+ length: docId.length
854
+ }
855
+ );
856
+ }
857
+ if (docId.includes("\0")) {
858
+ throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Document ID must not contain null bytes", { docId });
859
+ }
860
+ }
861
+ function clampLimit(limit) {
862
+ if (limit === void 0) return DEFAULT_LIMIT;
863
+ return Math.max(0, Math.min(limit, MAX_LIMIT));
864
+ }
865
+ function clampOffset(offset) {
866
+ if (offset === void 0) return DEFAULT_OFFSET;
867
+ return Math.max(0, Math.min(offset, MAX_OFFSET));
868
+ }
869
+
870
+ // src/engine/query.ts
871
+ async function executeQuery(params, context) {
872
+ const { manager, language, config, workerSearch, indexName } = context;
873
+ const startTime = now();
874
+ const limit = clampLimit(params.limit);
875
+ const offset = clampOffset(params.offset);
876
+ let fanOutResult;
877
+ const workerResult = workerSearch ? await workerSearch(indexName, params) : null;
878
+ if (workerResult) {
879
+ fanOutResult = workerResult;
880
+ } else {
881
+ const searchOptions = {
882
+ bm25Params: config.bm25,
883
+ stopWords: config.stopWords,
884
+ customTokenizer: config.tokenizer
885
+ };
886
+ fanOutResult = await fanOutQuery(
887
+ manager,
888
+ params,
889
+ language,
890
+ config.schema,
891
+ { scoringMode: params.scoring ?? config.defaultScoring ?? "local" },
892
+ searchOptions
893
+ );
894
+ }
895
+ const needsFullHits = params.sort !== void 0 || params.group !== void 0 || params.pinned !== void 0 || params.searchAfter !== void 0;
896
+ let hits;
897
+ if (needsFullHits) {
898
+ hits = fanOutResult.scored.map((scored) => ({
899
+ id: scored.docId,
900
+ score: scored.score,
901
+ document: void 0,
902
+ scoreComponents: params.includeScoreComponents ? { termFrequencies: scored.termFrequencies, fieldLengths: scored.fieldLengths, idf: scored.idf } : void 0
903
+ }));
904
+ } else {
905
+ const end = Math.min(offset + limit + 1, fanOutResult.scored.length);
906
+ hits = new Array(end);
907
+ for (let i = 0; i < end; i++) {
908
+ const scored = fanOutResult.scored[i];
909
+ hits[i] = {
910
+ id: scored.docId,
911
+ score: scored.score,
912
+ document: void 0,
913
+ scoreComponents: params.includeScoreComponents ? { termFrequencies: scored.termFrequencies, fieldLengths: scored.fieldLengths, idf: scored.idf } : void 0
914
+ };
915
+ }
916
+ }
917
+ if (params.sort) {
918
+ hits = applySorting(hits, params.sort, (docId) => manager.getRef(docId));
919
+ }
920
+ let groups;
921
+ if (params.group) {
922
+ groups = applyGrouping(hits, params.group, (docId) => manager.getRef(docId));
923
+ }
924
+ if (params.pinned) {
925
+ hits = applyPinning(hits, params.pinned, (docId) => {
926
+ const doc = manager.getRef(docId);
927
+ if (!doc) return void 0;
928
+ return { id: docId, score: 0, document: doc };
929
+ });
930
+ }
931
+ const { paginated, nextCursor } = applyPagination(hits, limit, offset, params.searchAfter);
932
+ for (const hit of paginated) {
933
+ hit.document = manager.get(hit.id) ?? {};
934
+ }
935
+ if (groups) {
936
+ for (const group of groups) {
937
+ for (const hit of group.hits) {
938
+ hit.document = manager.get(hit.id) ?? {};
939
+ }
940
+ }
941
+ }
942
+ if (params.highlight) {
943
+ applyHighlights(paginated, params, language, config);
944
+ }
945
+ const elapsed = now() - startTime;
946
+ return {
947
+ hits: paginated,
948
+ count: fanOutResult.totalMatched,
949
+ elapsed,
950
+ cursor: nextCursor,
951
+ facets: fanOutResult.facets,
952
+ groups
953
+ };
954
+ }
955
+ async function executePreflight(params, context) {
956
+ const { manager, language, config, workerSearch, indexName } = context;
957
+ const startTime = now();
958
+ let totalMatched;
959
+ const workerResult = workerSearch ? await workerSearch(indexName, params) : null;
960
+ if (workerResult) {
961
+ totalMatched = workerResult.totalMatched;
962
+ } else {
963
+ const searchOptions = {
964
+ bm25Params: config.bm25,
965
+ stopWords: config.stopWords,
966
+ customTokenizer: config.tokenizer
967
+ };
968
+ const fanOutResult = await fanOutQuery(
969
+ manager,
970
+ params,
971
+ language,
972
+ config.schema,
973
+ { scoringMode: params.scoring ?? config.defaultScoring ?? "local" },
974
+ searchOptions
975
+ );
976
+ totalMatched = fanOutResult.totalMatched;
977
+ }
978
+ const elapsed = now() - startTime;
979
+ return { count: totalMatched, elapsed };
980
+ }
981
+ function applyHighlights(hits, params, language, config) {
982
+ if (!params.highlight) return;
983
+ const queryTokenResult = tokenize(params.term ?? "", language, {
984
+ stem: true,
985
+ removeStopWords: true,
986
+ customTokenizer: config.tokenizer,
987
+ stopWordOverride: config.stopWords
988
+ });
989
+ for (const hit of hits) {
990
+ const highlights = {};
991
+ for (const field of params.highlight.fields) {
992
+ const doc = hit.document;
993
+ const fieldValue = doc[field];
994
+ if (typeof fieldValue === "string") {
995
+ highlights[field] = highlightField(fieldValue, queryTokenResult.tokens, language, {
996
+ preTag: params.highlight.preTag,
997
+ postTag: params.highlight.postTag,
998
+ maxSnippetLength: params.highlight.maxSnippetLength
999
+ });
1000
+ }
1001
+ }
1002
+ hit.highlights = highlights;
1003
+ }
1004
+ }
1005
+
1006
+ // src/partitioning/rebalancer.ts
1007
+ var CHUNK_SIZE = 1e3;
1008
+ function createRebalancer() {
1009
+ let rebalancing = false;
1010
+ async function rebalance(manager, newPartitionCount, router, onProgress) {
1011
+ if (rebalancing) {
1012
+ throw new NarsilError(
1013
+ ErrorCodes.PARTITION_REBALANCING_BACKPRESSURE,
1014
+ "A rebalance operation is already in progress"
1015
+ );
1016
+ }
1017
+ if (newPartitionCount <= 0) {
1018
+ throw new NarsilError(
1019
+ ErrorCodes.PARTITION_REBALANCING_BACKPRESSURE,
1020
+ `New partition count must be greater than 0, received ${newPartitionCount}`,
1021
+ { newPartitionCount }
1022
+ );
1023
+ }
1024
+ if (newPartitionCount === manager.partitionCount) {
1025
+ throw new NarsilError(
1026
+ ErrorCodes.PARTITION_REBALANCING_BACKPRESSURE,
1027
+ `New partition count (${newPartitionCount}) is the same as the current count`,
1028
+ { newPartitionCount, currentPartitionCount: manager.partitionCount }
1029
+ );
1030
+ }
1031
+ rebalancing = true;
1032
+ try {
1033
+ const collectedDocs = [];
1034
+ const currentPartitions = manager.getAllPartitions();
1035
+ for (const partition of currentPartitions) {
1036
+ for (const docId of partition.docIds()) {
1037
+ const document = partition.get(docId);
1038
+ if (document) {
1039
+ collectedDocs.push({ docId, document });
1040
+ }
1041
+ }
1042
+ }
1043
+ const documentsTotal = collectedDocs.length;
1044
+ onProgress?.({
1045
+ phase: "scanning",
1046
+ documentsProcessed: 0,
1047
+ documentsTotal
1048
+ });
1049
+ const newPartitions = [];
1050
+ for (let i = 0; i < newPartitionCount; i++) {
1051
+ newPartitions.push(createPartitionIndex(i));
1052
+ }
1053
+ let documentsProcessed = 0;
1054
+ for (let chunkStart = 0; chunkStart < collectedDocs.length; chunkStart += CHUNK_SIZE) {
1055
+ const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, collectedDocs.length);
1056
+ for (let i = chunkStart; i < chunkEnd; i++) {
1057
+ const { docId, document } = collectedDocs[i];
1058
+ const targetPartitionId = router.route(docId, newPartitionCount);
1059
+ const targetPartition = newPartitions[targetPartitionId];
1060
+ if (targetPartition.has(docId)) {
1061
+ continue;
1062
+ }
1063
+ targetPartition.insert(docId, document, manager.schema, manager.language, { validate: false });
1064
+ documentsProcessed++;
1065
+ }
1066
+ onProgress?.({
1067
+ phase: "moving",
1068
+ documentsProcessed,
1069
+ documentsTotal
1070
+ });
1071
+ await new Promise((resolve) => setTimeout(resolve, 0));
1072
+ }
1073
+ onProgress?.({
1074
+ phase: "swapping",
1075
+ documentsProcessed,
1076
+ documentsTotal
1077
+ });
1078
+ manager.setPartitions(newPartitions);
1079
+ onProgress?.({
1080
+ phase: "complete",
1081
+ documentsProcessed,
1082
+ documentsTotal
1083
+ });
1084
+ } finally {
1085
+ rebalancing = false;
1086
+ }
1087
+ }
1088
+ return {
1089
+ rebalance,
1090
+ isRebalancing() {
1091
+ return rebalancing;
1092
+ }
1093
+ };
1094
+ }
1095
+
1096
+ // src/partitioning/write-ahead-queue.ts
1097
+ function createWriteAheadQueue(maxSize = 1e4) {
1098
+ const entries = [];
1099
+ let nextSequence = 1;
1100
+ return {
1101
+ push(entry) {
1102
+ if (entries.length >= maxSize) {
1103
+ throw new NarsilError(
1104
+ ErrorCodes.PARTITION_REBALANCING_BACKPRESSURE,
1105
+ `Write-ahead queue is full (${maxSize} entries). Retry after rebalancing completes.`,
1106
+ { maxSize, currentSize: entries.length }
1107
+ );
1108
+ }
1109
+ const seq = nextSequence++;
1110
+ entries.push({ ...entry, sequenceNumber: seq });
1111
+ return seq;
1112
+ },
1113
+ drain() {
1114
+ const result = entries.splice(0);
1115
+ result.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
1116
+ return result;
1117
+ },
1118
+ clear() {
1119
+ entries.length = 0;
1120
+ },
1121
+ get size() {
1122
+ return entries.length;
1123
+ },
1124
+ get isFull() {
1125
+ return entries.length >= maxSize;
1126
+ }
1127
+ };
1128
+ }
1129
+
1130
+ // src/persistence/flush-manager.ts
1131
+ var DEFAULT_INTERVAL = 5e3;
1132
+ var DEFAULT_MUTATION_THRESHOLD = 1e3;
1133
+ var DEFAULT_MAX_RETRIES = 3;
1134
+ var DEFAULT_BASE_RETRY_DELAY = 100;
1135
+ function createFlushManager(config, getPartitionData, getInstanceId) {
1136
+ const {
1137
+ persistence,
1138
+ invalidation,
1139
+ interval = DEFAULT_INTERVAL,
1140
+ mutationThreshold = DEFAULT_MUTATION_THRESHOLD,
1141
+ maxRetries = DEFAULT_MAX_RETRIES,
1142
+ baseRetryDelay = DEFAULT_BASE_RETRY_DELAY,
1143
+ onError
1144
+ } = config;
1145
+ const dirtyPartitions = /* @__PURE__ */ new Map();
1146
+ let mutationCount = 0;
1147
+ let flushTimerId = null;
1148
+ let isShuttingDown = false;
1149
+ let flushPromise = null;
1150
+ function startTimer() {
1151
+ if (flushTimerId !== null || isShuttingDown) {
1152
+ return;
1153
+ }
1154
+ flushTimerId = setInterval(() => {
1155
+ flush();
1156
+ }, interval);
1157
+ }
1158
+ function addToDirtySet(indexName, partitionId) {
1159
+ let partitions = dirtyPartitions.get(indexName);
1160
+ if (partitions === void 0) {
1161
+ partitions = /* @__PURE__ */ new Set();
1162
+ dirtyPartitions.set(indexName, partitions);
1163
+ }
1164
+ partitions.add(partitionId);
1165
+ }
1166
+ function snapshotAndClearDirty() {
1167
+ const snapshot = /* @__PURE__ */ new Map();
1168
+ for (const [indexName, partitions] of dirtyPartitions) {
1169
+ snapshot.set(indexName, new Set(partitions));
1170
+ }
1171
+ dirtyPartitions.clear();
1172
+ return snapshot;
1173
+ }
1174
+ async function saveWithRetry(indexName, partitionId) {
1175
+ const key = `${indexName}/${partitionId}`;
1176
+ const data = getPartitionData(indexName, partitionId);
1177
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
1178
+ try {
1179
+ await persistence.save(key, data);
1180
+ return true;
1181
+ } catch (err) {
1182
+ const isLastAttempt = attempt === maxRetries - 1;
1183
+ if (isLastAttempt) {
1184
+ onError?.(indexName, partitionId, err, true);
1185
+ return false;
1186
+ }
1187
+ const cappedAttempt = Math.min(attempt, 20);
1188
+ const delay = baseRetryDelay * 2 ** cappedAttempt * (0.5 + Math.random() * 0.5);
1189
+ await new Promise((resolve) => setTimeout(resolve, delay));
1190
+ }
1191
+ }
1192
+ return false;
1193
+ }
1194
+ async function performFlush() {
1195
+ const snapshot = snapshotAndClearDirty();
1196
+ for (const [indexName, partitions] of snapshot) {
1197
+ const succeededPartitions = [];
1198
+ for (const partitionId of partitions) {
1199
+ const saved = await saveWithRetry(indexName, partitionId);
1200
+ if (saved) {
1201
+ succeededPartitions.push(partitionId);
1202
+ } else {
1203
+ addToDirtySet(indexName, partitionId);
1204
+ }
1205
+ }
1206
+ if (succeededPartitions.length > 0) {
1207
+ await invalidation.publish({
1208
+ type: "partition",
1209
+ indexName,
1210
+ partitions: succeededPartitions,
1211
+ timestamp: Date.now(),
1212
+ sourceInstanceId: getInstanceId()
1213
+ });
1214
+ }
1215
+ }
1216
+ mutationCount = 0;
1217
+ }
1218
+ function flush() {
1219
+ if (flushPromise !== null) {
1220
+ return flushPromise;
1221
+ }
1222
+ flushPromise = performFlush().finally(() => {
1223
+ flushPromise = null;
1224
+ });
1225
+ return flushPromise;
1226
+ }
1227
+ function markDirty(indexName, partitionId) {
1228
+ if (isShuttingDown) return;
1229
+ addToDirtySet(indexName, partitionId);
1230
+ mutationCount++;
1231
+ if (mutationCount >= mutationThreshold) {
1232
+ flush();
1233
+ }
1234
+ startTimer();
1235
+ }
1236
+ async function shutdown() {
1237
+ isShuttingDown = true;
1238
+ if (flushTimerId !== null) {
1239
+ clearInterval(flushTimerId);
1240
+ flushTimerId = null;
1241
+ }
1242
+ await flush();
1243
+ }
1244
+ return {
1245
+ markDirty,
1246
+ flush,
1247
+ shutdown
1248
+ };
1249
+ }
1250
+
1251
+ // src/plugins/registry.ts
1252
+ function isThenable(value) {
1253
+ return value !== null && typeof value === "object" && typeof value.then === "function";
1254
+ }
1255
+ async function continueAsync(pending, hooks, startIndex, context) {
1256
+ await pending;
1257
+ for (let i = startIndex; i < hooks.length; i++) {
1258
+ await hooks[i](context);
1259
+ }
1260
+ }
1261
+ function createPluginRegistry() {
1262
+ const plugins = [];
1263
+ return {
1264
+ register(plugin) {
1265
+ plugins.push(plugin);
1266
+ },
1267
+ hasHooks(hookName) {
1268
+ return plugins.some((p) => typeof p[hookName] === "function");
1269
+ },
1270
+ runHook(hookName, context) {
1271
+ const hooks = [];
1272
+ for (const plugin of plugins) {
1273
+ const hook = plugin[hookName];
1274
+ if (typeof hook === "function") {
1275
+ hooks.push(hook.bind(plugin));
1276
+ }
1277
+ }
1278
+ if (hooks.length === 0) return;
1279
+ for (let i = 0; i < hooks.length; i++) {
1280
+ const result = hooks[i](context);
1281
+ if (isThenable(result)) {
1282
+ return continueAsync(result, hooks, i + 1, context);
1283
+ }
548
1284
  }
549
1285
  }
550
- return 0;
551
- });
552
- return sorted;
1286
+ };
1287
+ }
1288
+ var VALID_HNSW_METRICS = /* @__PURE__ */ new Set(["cosine", "dotProduct", "euclidean"]);
1289
+ function validateHnswMetric(value) {
1290
+ if (value === void 0 || value === null) return void 0;
1291
+ if (typeof value === "string" && VALID_HNSW_METRICS.has(value)) {
1292
+ return value;
1293
+ }
1294
+ return void 0;
1295
+ }
1296
+ function wireToPartition(raw) {
1297
+ const documents = {};
1298
+ for (const [docId, doc] of Object.entries(raw.documents ?? {})) {
1299
+ documents[docId] = {
1300
+ fields: doc.fields,
1301
+ fieldLengths: doc.field_lengths ?? {}
1302
+ };
1303
+ }
1304
+ const invertedIndex = {};
1305
+ for (const [token, list] of Object.entries(raw.inverted_index ?? {})) {
1306
+ invertedIndex[token] = {
1307
+ docFrequency: list.doc_freq,
1308
+ postings: (list.postings ?? []).map((p) => ({
1309
+ docId: p.doc_id,
1310
+ termFrequency: p.term_freq,
1311
+ field: p.field,
1312
+ positions: p.positions ?? []
1313
+ }))
1314
+ };
1315
+ }
1316
+ const numeric = {};
1317
+ for (const [field, entries] of Object.entries(raw.field_indexes?.numeric ?? {})) {
1318
+ numeric[field] = entries.map((e) => ({ value: e.value, docId: e.doc_id }));
1319
+ }
1320
+ const boolean = {};
1321
+ for (const [field, idx] of Object.entries(raw.field_indexes?.boolean ?? {})) {
1322
+ boolean[field] = { trueDocs: idx.true_docs ?? [], falseDocs: idx.false_docs ?? [] };
1323
+ }
1324
+ const geopoint = {};
1325
+ for (const [field, entries] of Object.entries(raw.field_indexes?.geopoint ?? {})) {
1326
+ geopoint[field] = entries.map((e) => ({ lat: e.lat, lon: e.lon, docId: e.doc_id }));
1327
+ }
1328
+ const vectorData = {};
1329
+ for (const [field, data] of Object.entries(raw.vector_data ?? {})) {
1330
+ vectorData[field] = {
1331
+ dimension: data.dimension,
1332
+ vectors: (data.vectors ?? []).map((v) => ({ docId: v.doc_id, vector: v.vector })),
1333
+ hnswGraph: data.hnsw_graph ? {
1334
+ entryPoint: data.hnsw_graph.entry_point,
1335
+ maxLayer: data.hnsw_graph.max_layer,
1336
+ m: data.hnsw_graph.m,
1337
+ efConstruction: data.hnsw_graph.ef_construction,
1338
+ metric: validateHnswMetric(data.hnsw_graph.metric),
1339
+ nodes: data.hnsw_graph.nodes
1340
+ } : null
1341
+ };
1342
+ }
1343
+ return {
1344
+ indexName: raw.index_name,
1345
+ partitionId: raw.partition_id,
1346
+ totalPartitions: raw.total_partitions,
1347
+ language: raw.language ?? "english",
1348
+ schema: raw.schema ?? {},
1349
+ docCount: raw.doc_count ?? 0,
1350
+ avgDocLength: raw.avg_doc_length ?? 0,
1351
+ documents,
1352
+ invertedIndex,
1353
+ fieldIndexes: {
1354
+ numeric,
1355
+ boolean,
1356
+ enum: raw.field_indexes?.enum ?? {},
1357
+ geopoint
1358
+ },
1359
+ vectorData,
1360
+ statistics: {
1361
+ totalDocuments: raw.statistics?.total_documents ?? 0,
1362
+ totalFieldLengths: raw.statistics?.total_field_lengths ?? {},
1363
+ averageFieldLengths: raw.statistics?.average_field_lengths ?? {},
1364
+ docFrequencies: raw.statistics?.doc_frequencies ?? {}
1365
+ }
1366
+ };
1367
+ }
1368
+ function deserializePayloadV1(data) {
1369
+ const raw = decode(data);
1370
+ return wireToPartition(raw);
553
1371
  }
554
1372
 
555
1373
  // src/workers/promoter.ts
@@ -591,65 +1409,6 @@ function createExecutionPromoter(config) {
591
1409
  }
592
1410
 
593
1411
  // src/narsil.ts
594
- var INDEX_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
595
- var MAX_INDEX_NAME_LENGTH = 256;
596
- var MAX_DOC_ID_LENGTH = 512;
597
- var BATCH_CHUNK_SIZE = 1e3;
598
- var MAX_LIMIT = 1e4;
599
- var MAX_OFFSET = 1e5;
600
- var DEFAULT_LIMIT = 10;
601
- var DEFAULT_OFFSET = 0;
602
- function now() {
603
- if (typeof performance !== "undefined" && typeof performance.now === "function") {
604
- return performance.now();
605
- }
606
- return Date.now();
607
- }
608
- function validateIndexName(name) {
609
- if (!name || name.length === 0) {
610
- throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, "Index name must not be empty", { indexName: name });
611
- }
612
- if (name.length > MAX_INDEX_NAME_LENGTH) {
613
- throw new NarsilError(
614
- ErrorCodes.INDEX_NOT_FOUND,
615
- `Index name must not exceed ${MAX_INDEX_NAME_LENGTH} characters`,
616
- { indexName: name, length: name.length }
617
- );
618
- }
619
- if (!INDEX_NAME_PATTERN.test(name)) {
620
- throw new NarsilError(
621
- ErrorCodes.INDEX_NOT_FOUND,
622
- `Index name "${name}" contains invalid characters; use alphanumeric, dots, hyphens, and underscores only`,
623
- { indexName: name }
624
- );
625
- }
626
- if (name.includes("..")) {
627
- throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, `Index name "${name}" must not contain ".."`, { indexName: name });
628
- }
629
- }
630
- function validateDocId(docId) {
631
- if (!docId || docId.length === 0) {
632
- throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Document ID must not be empty", { docId });
633
- }
634
- if (docId.length > MAX_DOC_ID_LENGTH) {
635
- throw new NarsilError(
636
- ErrorCodes.DOC_VALIDATION_FAILED,
637
- `Document ID must not exceed ${MAX_DOC_ID_LENGTH} characters`,
638
- { docId, length: docId.length }
639
- );
640
- }
641
- if (docId.includes("\0")) {
642
- throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Document ID must not contain null bytes", { docId });
643
- }
644
- }
645
- function clampLimit(limit) {
646
- if (limit === void 0) return DEFAULT_LIMIT;
647
- return Math.max(0, Math.min(limit, MAX_LIMIT));
648
- }
649
- function clampOffset(offset) {
650
- if (offset === void 0) return DEFAULT_OFFSET;
651
- return Math.max(0, Math.min(offset, MAX_OFFSET));
652
- }
653
1412
  async function createNarsil(config) {
654
1413
  const executor = createDirectExecutor();
655
1414
  const promoter = createExecutionPromoter({
@@ -687,6 +1446,21 @@ async function createNarsil(config) {
687
1446
  const indexRegistry = /* @__PURE__ */ new Map();
688
1447
  const eventHandlers = /* @__PURE__ */ new Map();
689
1448
  let isShutdown = false;
1449
+ const orchestrator = createWorkerOrchestrator(config, executor, promoter, indexRegistry, {
1450
+ onPromotion(workerCount, reason) {
1451
+ const handlers = eventHandlers.get("workerPromote");
1452
+ if (handlers) {
1453
+ for (const handler of handlers) {
1454
+ handler({ workerCount, reason });
1455
+ }
1456
+ }
1457
+ }
1458
+ });
1459
+ const rebalancer = createRebalancer();
1460
+ const rebalanceRouter = createPartitionRouter();
1461
+ const rebalancingIndexes = /* @__PURE__ */ new Set();
1462
+ const waqMap = /* @__PURE__ */ new Map();
1463
+ const lastAppliedSeqMap = /* @__PURE__ */ new Map();
690
1464
  function guardShutdown() {
691
1465
  if (isShutdown) {
692
1466
  throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, "This Narsil instance has been shut down");
@@ -699,6 +1473,20 @@ async function createNarsil(config) {
699
1473
  }
700
1474
  return entry;
701
1475
  }
1476
+ function bufferIfRebalancing(indexName, entry) {
1477
+ if (!rebalancingIndexes.has(indexName)) return false;
1478
+ const waq = waqMap.get(indexName);
1479
+ if (!waq) return false;
1480
+ waq.push(entry);
1481
+ return true;
1482
+ }
1483
+ function requireManager(indexName) {
1484
+ const manager = executor.getManager(indexName);
1485
+ if (!manager) {
1486
+ throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, `Index "${indexName}" manager not found`, { indexName });
1487
+ }
1488
+ return manager;
1489
+ }
702
1490
  const narsil = {
703
1491
  async createIndex(name, indexConfig) {
704
1492
  guardShutdown();
@@ -717,10 +1505,7 @@ async function createNarsil(config) {
717
1505
  const entry = requireIndex(name);
718
1506
  executor.dropIndex(name);
719
1507
  indexRegistry.delete(name);
720
- await pluginRegistry.runHook("onIndexDrop", {
721
- indexName: name,
722
- config: entry.config
723
- });
1508
+ await pluginRegistry.runHook("onIndexDrop", { indexName: name, config: entry.config });
724
1509
  },
725
1510
  listIndexes() {
726
1511
  const infos = [];
@@ -739,61 +1524,90 @@ async function createNarsil(config) {
739
1524
  guardShutdown();
740
1525
  const entry = requireIndex(indexName);
741
1526
  const manager = executor.getManager(indexName);
1527
+ const estimatedMemory = manager?.estimateMemoryBytes() ?? 0;
742
1528
  return {
743
1529
  documentCount: manager?.countDocuments() ?? 0,
744
1530
  partitionCount: manager?.partitionCount ?? 0,
745
- memoryBytes: 0,
746
- indexSizeBytes: 0,
1531
+ memoryBytes: estimatedMemory,
1532
+ indexSizeBytes: estimatedMemory,
747
1533
  language: entry.language.name,
748
1534
  schema: entry.config.schema
749
1535
  };
750
1536
  },
751
- async insert(indexName, document, docId) {
1537
+ getPartitionStats(indexName) {
1538
+ guardShutdown();
1539
+ requireIndex(indexName);
1540
+ const manager = executor.getManager(indexName);
1541
+ return manager?.getPartitionStats() ?? [];
1542
+ },
1543
+ async insert(indexName, document, docId, options) {
752
1544
  guardShutdown();
753
1545
  requireIndex(indexName);
754
1546
  const resolvedDocId = docId ?? idGenerator();
755
1547
  validateDocId(resolvedDocId);
756
- await pluginRegistry.runHook("beforeInsert", {
757
- indexName,
758
- docId: resolvedDocId,
759
- document
760
- });
1548
+ if (bufferIfRebalancing(indexName, { action: "insert", docId: resolvedDocId, document, indexName })) {
1549
+ return resolvedDocId;
1550
+ }
1551
+ await pluginRegistry.runHook("beforeInsert", { indexName, docId: resolvedDocId, document });
761
1552
  await executor.execute({
762
1553
  type: "insert",
763
1554
  indexName,
764
1555
  docId: resolvedDocId,
765
1556
  document,
766
- requestId: resolvedDocId
1557
+ requestId: resolvedDocId,
1558
+ skipClone: options?.skipClone
767
1559
  });
768
1560
  try {
769
- await pluginRegistry.runHook("afterInsert", {
770
- indexName,
771
- docId: resolvedDocId,
772
- document
773
- });
1561
+ await pluginRegistry.runHook("afterInsert", { indexName, docId: resolvedDocId, document });
774
1562
  } catch (err) {
775
1563
  console.warn("afterInsert plugin hook error:", err);
776
1564
  }
777
1565
  flushManager?.markDirty(indexName, 0);
778
- const indexMap = /* @__PURE__ */ new Map();
779
- for (const [name] of indexRegistry) {
780
- const mgr = executor.getManager(name);
781
- indexMap.set(name, { documentCount: mgr?.countDocuments() ?? 0 });
782
- }
783
- promoter.check(indexMap);
1566
+ await orchestrator.replicateToWorkers({
1567
+ type: "insert",
1568
+ indexName,
1569
+ docId: resolvedDocId,
1570
+ document,
1571
+ requestId: `replicate-insert-${resolvedDocId}`,
1572
+ skipClone: options?.skipClone
1573
+ });
1574
+ await orchestrator.checkPromotion();
784
1575
  return resolvedDocId;
785
1576
  },
786
- async insertBatch(indexName, documents) {
1577
+ async insertBatch(indexName, documents, options) {
787
1578
  guardShutdown();
788
1579
  requireIndex(indexName);
789
1580
  const succeeded = [];
790
1581
  const failed = [];
1582
+ const hasBeforeHook = pluginRegistry.hasHooks("beforeInsert");
1583
+ const hasAfterHook = pluginRegistry.hasHooks("afterInsert");
791
1584
  for (let chunkStart = 0; chunkStart < documents.length; chunkStart += BATCH_CHUNK_SIZE) {
792
1585
  const chunkEnd = Math.min(chunkStart + BATCH_CHUNK_SIZE, documents.length);
793
1586
  for (let i = chunkStart; i < chunkEnd; i++) {
794
1587
  const docId = idGenerator();
795
1588
  try {
796
- await narsil.insert(indexName, documents[i], docId);
1589
+ validateDocId(docId);
1590
+ if (hasBeforeHook) {
1591
+ await pluginRegistry.runHook("beforeInsert", { indexName, docId, document: documents[i] });
1592
+ }
1593
+ const result = executor.execute({
1594
+ type: "insert",
1595
+ indexName,
1596
+ docId,
1597
+ document: documents[i],
1598
+ requestId: docId,
1599
+ skipClone: options?.skipClone
1600
+ });
1601
+ if (result && typeof result.then === "function") {
1602
+ await result;
1603
+ }
1604
+ if (hasAfterHook) {
1605
+ try {
1606
+ await pluginRegistry.runHook("afterInsert", { indexName, docId, document: documents[i] });
1607
+ } catch (err) {
1608
+ console.warn("afterInsert plugin hook error:", err);
1609
+ }
1610
+ }
797
1611
  succeeded.push(docId);
798
1612
  } catch (err) {
799
1613
  failed.push({
@@ -806,25 +1620,31 @@ async function createNarsil(config) {
806
1620
  await new Promise((r) => setTimeout(r, 0));
807
1621
  }
808
1622
  }
1623
+ flushManager?.markDirty(indexName, 0);
1624
+ await orchestrator.checkPromotion();
809
1625
  return { succeeded, failed };
810
1626
  },
811
1627
  async remove(indexName, docId) {
812
1628
  guardShutdown();
813
1629
  requireIndex(indexName);
814
1630
  validateDocId(docId);
1631
+ if (bufferIfRebalancing(indexName, { action: "remove", docId, indexName })) {
1632
+ return;
1633
+ }
815
1634
  await pluginRegistry.runHook("beforeRemove", { indexName, docId });
816
- await executor.execute({
817
- type: "remove",
818
- indexName,
819
- docId,
820
- requestId: docId
821
- });
1635
+ await executor.execute({ type: "remove", indexName, docId, requestId: docId });
822
1636
  try {
823
1637
  await pluginRegistry.runHook("afterRemove", { indexName, docId });
824
1638
  } catch (err) {
825
1639
  console.warn("afterRemove plugin hook error:", err);
826
1640
  }
827
1641
  flushManager?.markDirty(indexName, 0);
1642
+ await orchestrator.replicateToWorkers({
1643
+ type: "remove",
1644
+ indexName,
1645
+ docId,
1646
+ requestId: `replicate-remove-${docId}`
1647
+ });
828
1648
  },
829
1649
  async removeBatch(indexName, docIds) {
830
1650
  guardShutdown();
@@ -854,6 +1674,9 @@ async function createNarsil(config) {
854
1674
  guardShutdown();
855
1675
  requireIndex(indexName);
856
1676
  validateDocId(docId);
1677
+ if (bufferIfRebalancing(indexName, { action: "update", docId, document, indexName })) {
1678
+ return;
1679
+ }
857
1680
  const manager = executor.getManager(indexName);
858
1681
  const oldDocument = manager?.get(docId);
859
1682
  await pluginRegistry.runHook("beforeUpdate", {
@@ -862,13 +1685,7 @@ async function createNarsil(config) {
862
1685
  oldDocument: oldDocument ?? {},
863
1686
  newDocument: document
864
1687
  });
865
- await executor.execute({
866
- type: "update",
867
- indexName,
868
- docId,
869
- document,
870
- requestId: docId
871
- });
1688
+ await executor.execute({ type: "update", indexName, docId, document, requestId: docId });
872
1689
  try {
873
1690
  await pluginRegistry.runHook("afterUpdate", {
874
1691
  indexName,
@@ -880,6 +1697,13 @@ async function createNarsil(config) {
880
1697
  console.warn("afterUpdate plugin hook error:", err);
881
1698
  }
882
1699
  flushManager?.markDirty(indexName, 0);
1700
+ await orchestrator.replicateToWorkers({
1701
+ type: "update",
1702
+ indexName,
1703
+ docId,
1704
+ document,
1705
+ requestId: `replicate-update-${docId}`
1706
+ });
883
1707
  },
884
1708
  async updateBatch(indexName, updates) {
885
1709
  guardShutdown();
@@ -935,86 +1759,16 @@ async function createNarsil(config) {
935
1759
  async query(indexName, params) {
936
1760
  guardShutdown();
937
1761
  const entry = requireIndex(indexName);
938
- const startTime = now();
939
- const limit = clampLimit(params.limit);
940
- const offset = clampOffset(params.offset);
1762
+ const manager = requireManager(indexName);
941
1763
  await pluginRegistry.runHook("beforeSearch", { indexName, params });
942
- const manager = executor.getManager(indexName);
943
- if (!manager) {
944
- throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, `Index "${indexName}" manager not found`, { indexName });
945
- }
946
- const searchOptions = {
947
- bm25Params: entry.config.bm25,
948
- stopWords: entry.config.stopWords,
949
- customTokenizer: entry.config.tokenizer
950
- };
951
- const fanOutResult = fanOutQuery(
1764
+ const workerSearch = orchestrator.isPromoted() ? orchestrator.searchViaWorker.bind(orchestrator) : void 0;
1765
+ const result = await executeQuery(params, {
952
1766
  manager,
953
- params,
954
- entry.language,
955
- entry.config.schema,
956
- {
957
- scoringMode: params.scoring ?? entry.config.defaultScoring ?? "local"
958
- },
959
- searchOptions
960
- );
961
- let hits = fanOutResult.scored.map((scored) => ({
962
- id: scored.docId,
963
- score: scored.score,
964
- document: manager.get(scored.docId) ?? {},
965
- scoreComponents: {
966
- termFrequencies: scored.termFrequencies,
967
- fieldLengths: scored.fieldLengths,
968
- idf: scored.idf
969
- }
970
- }));
971
- if (params.highlight) {
972
- const queryTokenResult = tokenize(params.term ?? "", entry.language, {
973
- stem: true,
974
- removeStopWords: true,
975
- customTokenizer: entry.config.tokenizer,
976
- stopWordOverride: entry.config.stopWords
977
- });
978
- for (const hit of hits) {
979
- const highlights = {};
980
- for (const field of params.highlight.fields) {
981
- const doc = hit.document;
982
- const fieldValue = doc[field];
983
- if (typeof fieldValue === "string") {
984
- highlights[field] = highlightField(fieldValue, queryTokenResult.tokens, entry.language, {
985
- preTag: params.highlight.preTag,
986
- postTag: params.highlight.postTag,
987
- maxSnippetLength: params.highlight.maxSnippetLength
988
- });
989
- }
990
- }
991
- hit.highlights = highlights;
992
- }
993
- }
994
- if (params.sort) {
995
- hits = applySorting(hits, params.sort, (docId) => manager.get(docId));
996
- }
997
- let groups;
998
- if (params.group) {
999
- groups = applyGrouping(hits, params.group, (docId) => manager.get(docId));
1000
- }
1001
- if (params.pinned) {
1002
- hits = applyPinning(hits, params.pinned, (docId) => {
1003
- const doc = manager.get(docId);
1004
- if (!doc) return void 0;
1005
- return { id: docId, score: 0, document: doc };
1006
- });
1007
- }
1008
- const { paginated, nextCursor } = applyPagination(hits, limit, offset, params.searchAfter);
1009
- const elapsed = now() - startTime;
1010
- const result = {
1011
- hits: paginated,
1012
- count: fanOutResult.totalMatched,
1013
- elapsed,
1014
- cursor: nextCursor,
1015
- facets: fanOutResult.facets,
1016
- groups
1017
- };
1767
+ language: entry.language,
1768
+ config: entry.config,
1769
+ workerSearch,
1770
+ indexName
1771
+ });
1018
1772
  try {
1019
1773
  await pluginRegistry.runHook("afterSearch", {
1020
1774
  indexName,
@@ -1029,36 +1783,233 @@ async function createNarsil(config) {
1029
1783
  async preflight(indexName, params) {
1030
1784
  guardShutdown();
1031
1785
  const entry = requireIndex(indexName);
1032
- const startTime = now();
1033
- const manager = executor.getManager(indexName);
1034
- if (!manager) {
1035
- throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, `Index "${indexName}" manager not found`, { indexName });
1036
- }
1037
- const searchOptions = {
1038
- bm25Params: entry.config.bm25,
1039
- stopWords: entry.config.stopWords,
1040
- customTokenizer: entry.config.tokenizer
1041
- };
1042
- const fanOutResult = fanOutQuery(
1786
+ const manager = requireManager(indexName);
1787
+ const workerSearch = orchestrator.isPromoted() ? orchestrator.searchViaWorker.bind(orchestrator) : void 0;
1788
+ return executePreflight(params, {
1043
1789
  manager,
1044
- params,
1045
- entry.language,
1046
- entry.config.schema,
1047
- {
1048
- scoringMode: params.scoring ?? entry.config.defaultScoring ?? "local"
1049
- },
1050
- searchOptions
1051
- );
1052
- const elapsed = now() - startTime;
1053
- return { count: fanOutResult.totalMatched, elapsed };
1790
+ language: entry.language,
1791
+ config: entry.config,
1792
+ workerSearch,
1793
+ indexName
1794
+ });
1795
+ },
1796
+ async suggest(indexName, params) {
1797
+ guardShutdown();
1798
+ const entry = requireIndex(indexName);
1799
+ const manager = requireManager(indexName);
1800
+ const t0 = performance.now();
1801
+ const limit = Math.max(1, Math.min(params.limit ?? 10, 100));
1802
+ const rawPrefix = params.prefix.trim();
1803
+ if (rawPrefix.length === 0) {
1804
+ return { terms: [], elapsed: performance.now() - t0 };
1805
+ }
1806
+ const unstemmed = tokenize(rawPrefix, entry.language, { stem: false, removeStopWords: false });
1807
+ const lastToken = unstemmed.tokens.length > 0 ? unstemmed.tokens[unstemmed.tokens.length - 1].token : rawPrefix.toLowerCase();
1808
+ if (lastToken.length === 0) {
1809
+ return { terms: [], elapsed: performance.now() - t0 };
1810
+ }
1811
+ const stemmed = entry.language.stemmer ? entry.language.stemmer(lastToken) : lastToken;
1812
+ const prefixes = stemmed !== lastToken ? [lastToken, stemmed] : [lastToken];
1813
+ const partitions = manager.getAllPartitions();
1814
+ const merged = /* @__PURE__ */ new Map();
1815
+ const perPartitionLimit = limit * 2;
1816
+ for (const partition of partitions) {
1817
+ const seen = /* @__PURE__ */ new Set();
1818
+ for (const prefix of prefixes) {
1819
+ const suggestions = partition.suggestTerms(prefix, perPartitionLimit);
1820
+ for (const s of suggestions) {
1821
+ if (seen.has(s.term)) continue;
1822
+ seen.add(s.term);
1823
+ merged.set(s.term, (merged.get(s.term) ?? 0) + s.documentFrequency);
1824
+ }
1825
+ }
1826
+ }
1827
+ const terms = Array.from(merged.entries()).map(([term, documentFrequency]) => ({ term, documentFrequency })).sort((a, b) => b.documentFrequency - a.documentFrequency);
1828
+ if (terms.length > limit) terms.length = limit;
1829
+ return { terms, elapsed: performance.now() - t0 };
1830
+ },
1831
+ async snapshot(indexName) {
1832
+ guardShutdown();
1833
+ const entry = requireIndex(indexName);
1834
+ const manager = requireManager(indexName);
1835
+ const partitionBuffers = [];
1836
+ for (let i = 0; i < manager.partitionCount; i++) {
1837
+ partitionBuffers.push(manager.serializePartitionToBytes(i));
1838
+ }
1839
+ const { encode: encode2 } = await import('@msgpack/msgpack');
1840
+ return encode2({
1841
+ version: 2,
1842
+ schema: entry.config.schema,
1843
+ language: entry.language.name,
1844
+ partitions: partitionBuffers
1845
+ });
1846
+ },
1847
+ async restore(indexName, data) {
1848
+ guardShutdown();
1849
+ if (!(data instanceof Uint8Array)) {
1850
+ throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Snapshot data must be a Uint8Array");
1851
+ }
1852
+ const { decode: decode2 } = await import('@msgpack/msgpack');
1853
+ const envelope = decode2(data);
1854
+ if (envelope.version !== 1 && envelope.version !== 2) {
1855
+ throw new NarsilError(
1856
+ ErrorCodes.DOC_VALIDATION_FAILED,
1857
+ `Unsupported snapshot version: ${envelope.version}. Expected version 1 or 2`,
1858
+ { version: envelope.version }
1859
+ );
1860
+ }
1861
+ if (!envelope.schema || typeof envelope.schema !== "object") {
1862
+ throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Invalid snapshot: missing or invalid schema");
1863
+ }
1864
+ if (!envelope.language || typeof envelope.language !== "string") {
1865
+ throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Invalid snapshot: missing or invalid language");
1866
+ }
1867
+ if (!Array.isArray(envelope.partitions)) {
1868
+ throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Invalid snapshot: missing partitions");
1869
+ }
1870
+ const language = getLanguage(envelope.language);
1871
+ const schema = envelope.schema;
1872
+ validateSchema(schema);
1873
+ if (indexRegistry.has(indexName)) {
1874
+ await narsil.dropIndex(indexName);
1875
+ }
1876
+ const indexConfig = { schema, language: envelope.language };
1877
+ executor.createIndex(indexName, indexConfig, language);
1878
+ indexRegistry.set(indexName, { config: indexConfig, language });
1879
+ try {
1880
+ const manager = requireManager(indexName);
1881
+ while (manager.partitionCount < envelope.partitions.length) {
1882
+ manager.addPartition();
1883
+ }
1884
+ const deserializePartitionPayload = envelope.version === 2 ? deserializePayloadV2 : deserializePayloadV1;
1885
+ for (let i = 0; i < envelope.partitions.length; i++) {
1886
+ const partition = deserializePartitionPayload(envelope.partitions[i]);
1887
+ manager.deserializePartition(i, partition);
1888
+ }
1889
+ } catch (err) {
1890
+ try {
1891
+ executor.dropIndex(indexName);
1892
+ indexRegistry.delete(indexName);
1893
+ } catch (_) {
1894
+ }
1895
+ throw err;
1896
+ }
1054
1897
  },
1055
1898
  async clear(indexName) {
1056
1899
  guardShutdown();
1057
1900
  requireIndex(indexName);
1058
1901
  await executor.execute({ type: "clear", indexName, requestId: indexName });
1902
+ await orchestrator.replicateToWorkers({ type: "clear", indexName, requestId: `replicate-clear-${indexName}` });
1903
+ },
1904
+ async rebalance(indexName, targetPartitionCount) {
1905
+ guardShutdown();
1906
+ requireIndex(indexName);
1907
+ const manager = requireManager(indexName);
1908
+ if (targetPartitionCount <= 0 || !Number.isInteger(targetPartitionCount)) {
1909
+ throw new NarsilError(
1910
+ ErrorCodes.PARTITION_CAPACITY_EXCEEDED,
1911
+ `Target partition count must be a positive integer, got ${targetPartitionCount}`,
1912
+ { targetPartitionCount }
1913
+ );
1914
+ }
1915
+ if (rebalancingIndexes.has(indexName)) {
1916
+ throw new NarsilError(
1917
+ ErrorCodes.PARTITION_REBALANCING_BACKPRESSURE,
1918
+ `Index "${indexName}" is already being rebalanced`
1919
+ );
1920
+ }
1921
+ if (targetPartitionCount === manager.partitionCount) {
1922
+ return;
1923
+ }
1924
+ const waq = createWriteAheadQueue();
1925
+ waqMap.set(indexName, waq);
1926
+ rebalancingIndexes.add(indexName);
1927
+ try {
1928
+ const oldCount = manager.partitionCount;
1929
+ await rebalancer.rebalance(manager, targetPartitionCount, rebalanceRouter, (progress) => {
1930
+ if (progress.phase === "complete") {
1931
+ const handlers = eventHandlers.get("partitionRebalance");
1932
+ if (handlers) {
1933
+ for (const handler of handlers) {
1934
+ handler({ indexName, oldCount, newCount: targetPartitionCount });
1935
+ }
1936
+ }
1937
+ }
1938
+ });
1939
+ const entries = waq.drain();
1940
+ const appliedSeqs = lastAppliedSeqMap.get(indexName) ?? /* @__PURE__ */ new Map();
1941
+ for (const entry of entries) {
1942
+ const partitionLastSeq = appliedSeqs.get(0) ?? 0;
1943
+ if (entry.sequenceNumber <= partitionLastSeq) continue;
1944
+ try {
1945
+ if (entry.action === "insert" && entry.document) {
1946
+ manager.insert(entry.docId, entry.document);
1947
+ } else if (entry.action === "remove") {
1948
+ manager.remove(entry.docId);
1949
+ } else if (entry.action === "update" && entry.document) {
1950
+ manager.update(entry.docId, entry.document);
1951
+ }
1952
+ } catch (replayErr) {
1953
+ const isDuplicate = replayErr instanceof NarsilError && replayErr.code === ErrorCodes.DOC_ALREADY_EXISTS;
1954
+ const isMissing = replayErr instanceof NarsilError && replayErr.code === ErrorCodes.DOC_NOT_FOUND;
1955
+ if (!isDuplicate && !isMissing) {
1956
+ console.warn(`WAQ replay failed for ${entry.action} on doc "${entry.docId}":`, replayErr);
1957
+ }
1958
+ }
1959
+ appliedSeqs.set(0, entry.sequenceNumber);
1960
+ }
1961
+ lastAppliedSeqMap.set(indexName, appliedSeqs);
1962
+ } finally {
1963
+ rebalancingIndexes.delete(indexName);
1964
+ waqMap.delete(indexName);
1965
+ }
1966
+ },
1967
+ async updatePartitionConfig(indexName, partitionConfig) {
1968
+ guardShutdown();
1969
+ const entry = requireIndex(indexName);
1970
+ const manager = requireManager(indexName);
1971
+ if (rebalancingIndexes.has(indexName)) {
1972
+ throw new NarsilError(
1973
+ ErrorCodes.PARTITION_REBALANCING_BACKPRESSURE,
1974
+ `Index "${indexName}" is currently being rebalanced`
1975
+ );
1976
+ }
1977
+ const currentDocCount = manager.countDocuments();
1978
+ const newMaxDocs = partitionConfig.maxDocsPerPartition ?? entry.config.partitions?.maxDocsPerPartition;
1979
+ const newMaxPartitions = partitionConfig.maxPartitions ?? entry.config.partitions?.maxPartitions ?? manager.partitionCount;
1980
+ if (newMaxDocs !== void 0) {
1981
+ const newTotalCapacity = newMaxDocs * newMaxPartitions;
1982
+ if (newTotalCapacity < currentDocCount) {
1983
+ throw new NarsilError(
1984
+ ErrorCodes.PARTITION_CAPACITY_EXCEEDED,
1985
+ `New capacity (${newTotalCapacity}) is less than current document count (${currentDocCount})`,
1986
+ { newTotalCapacity, currentDocCount }
1987
+ );
1988
+ }
1989
+ }
1990
+ if (!entry.config.partitions) {
1991
+ entry.config.partitions = {};
1992
+ }
1993
+ if (partitionConfig.maxDocsPerPartition !== void 0) {
1994
+ entry.config.partitions.maxDocsPerPartition = partitionConfig.maxDocsPerPartition;
1995
+ }
1996
+ if (partitionConfig.maxPartitions !== void 0) {
1997
+ entry.config.partitions.maxPartitions = partitionConfig.maxPartitions;
1998
+ }
1059
1999
  },
1060
2000
  getMemoryStats() {
1061
- return { totalBytes: 0, workers: [] };
2001
+ const workerStats = orchestrator.getMemoryStats();
2002
+ let mainThreadBytes = 0;
2003
+ for (const [name] of indexRegistry) {
2004
+ const manager = executor.getManager(name);
2005
+ if (manager) {
2006
+ mainThreadBytes += manager.estimateMemoryBytes();
2007
+ }
2008
+ }
2009
+ return {
2010
+ totalBytes: mainThreadBytes + workerStats.totalBytes,
2011
+ workers: workerStats.workers
2012
+ };
1062
2013
  },
1063
2014
  on(event, handler) {
1064
2015
  const key = event;
@@ -1085,6 +2036,7 @@ async function createNarsil(config) {
1085
2036
  if (flushManager) {
1086
2037
  await flushManager.shutdown();
1087
2038
  }
2039
+ await orchestrator.shutdown();
1088
2040
  await executor.shutdown();
1089
2041
  eventHandlers.clear();
1090
2042
  indexRegistry.clear();