@delali/narsil 0.1.1 → 0.1.3

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