@delali/narsil 0.1.14 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/NOTICE +54 -0
  2. package/README.md +38 -972
  3. package/dist/adapters/filesystem.mjs +1 -1
  4. package/dist/adapters/indexeddb.mjs +1 -1
  5. package/dist/{chunk-WISGW24C.mjs → chunk-AUMKFYTG.mjs} +1 -1
  6. package/dist/{chunk-HRABJLPB.mjs → chunk-BKAI2WXI.mjs} +1 -0
  7. package/dist/chunk-GHJWC2E5.mjs +9 -0
  8. package/dist/chunk-KH3YGMVT.mjs +15 -0
  9. package/dist/{chunk-ALB35PRQ.mjs → chunk-M2EDG55Y.mjs} +8 -0
  10. package/dist/{chunk-HMO6OMZ3.mjs → chunk-S6IAXOBA.mjs} +1073 -506
  11. package/dist/chunk-TGP5M5R7.mjs +880 -0
  12. package/dist/chunk-U2BKYEC7.mjs +254 -0
  13. package/dist/{chunk-MEW7HD75.mjs → chunk-XAKNSWIK.mjs} +2543 -1154
  14. package/dist/{config-CojEPOgS.d.ts → config-P27AtSn4.d.ts} +28 -7
  15. package/dist/direct-executor-2FV3MEDD.mjs +5 -0
  16. package/dist/distribution/coordinator/etcd.d.ts +2 -2
  17. package/dist/distribution/coordinator/etcd.mjs +2 -2
  18. package/dist/distribution/coordinator/in-memory.d.ts +2 -2
  19. package/dist/distribution.d.ts +10 -4
  20. package/dist/distribution.mjs +1875 -1772
  21. package/dist/embeddings/openai.mjs +1 -1
  22. package/dist/index.browser.mjs +4566 -1783
  23. package/dist/index.d.ts +14 -5
  24. package/dist/index.mjs +6 -5
  25. package/dist/{language-ONQ3iVDf.d.ts → language-CmJBHf1j.d.ts} +4 -1
  26. package/dist/languages/albanian.d.ts +5 -0
  27. package/dist/languages/albanian.mjs +103 -0
  28. package/dist/languages/amharic.d.ts +5 -0
  29. package/dist/languages/amharic.mjs +240 -0
  30. package/dist/languages/arabic.d.ts +1 -1
  31. package/dist/languages/arabic.mjs +939 -154
  32. package/dist/languages/armenian.d.ts +1 -1
  33. package/dist/languages/armenian.mjs +275 -243
  34. package/dist/languages/azerbaijani.d.ts +5 -0
  35. package/dist/languages/azerbaijani.mjs +152 -0
  36. package/dist/languages/bambara.d.ts +5 -0
  37. package/dist/languages/bambara.mjs +60 -0
  38. package/dist/languages/basque.d.ts +5 -0
  39. package/dist/languages/basque.mjs +726 -0
  40. package/dist/languages/belarusian.d.ts +5 -0
  41. package/dist/languages/belarusian.mjs +100 -0
  42. package/dist/languages/bengali.d.ts +5 -0
  43. package/dist/languages/bengali.mjs +128 -0
  44. package/dist/languages/bosnian.d.ts +5 -0
  45. package/dist/languages/bosnian.mjs +197 -0
  46. package/dist/languages/breton.d.ts +5 -0
  47. package/dist/languages/breton.mjs +96 -0
  48. package/dist/languages/bulgarian.d.ts +1 -1
  49. package/dist/languages/bulgarian.mjs +11 -2
  50. package/dist/languages/burmese.d.ts +5 -0
  51. package/dist/languages/burmese.mjs +57 -0
  52. package/dist/languages/catalan.d.ts +5 -0
  53. package/dist/languages/catalan.mjs +987 -0
  54. package/dist/languages/chinese.d.ts +1 -1
  55. package/dist/languages/chinese.mjs +4 -3
  56. package/dist/languages/croatian.d.ts +5 -0
  57. package/dist/languages/croatian.mjs +191 -0
  58. package/dist/languages/czech.d.ts +5 -0
  59. package/dist/languages/czech.mjs +628 -0
  60. package/dist/languages/dagbani.d.ts +1 -1
  61. package/dist/languages/dagbani.mjs +11 -2
  62. package/dist/languages/danish.d.ts +1 -1
  63. package/dist/languages/danish.mjs +215 -110
  64. package/dist/languages/dutch.d.ts +1 -1
  65. package/dist/languages/dutch.mjs +1248 -163
  66. package/dist/languages/english.d.ts +1 -1
  67. package/dist/languages/english.mjs +2 -1
  68. package/dist/languages/esperanto.d.ts +5 -0
  69. package/dist/languages/esperanto.mjs +689 -0
  70. package/dist/languages/estonian.d.ts +5 -0
  71. package/dist/languages/estonian.mjs +2160 -0
  72. package/dist/languages/ewe.d.ts +1 -1
  73. package/dist/languages/ewe.mjs +11 -2
  74. package/dist/languages/faroese.d.ts +5 -0
  75. package/dist/languages/faroese.mjs +93 -0
  76. package/dist/languages/fijian.d.ts +5 -0
  77. package/dist/languages/fijian.mjs +55 -0
  78. package/dist/languages/finnish.d.ts +1 -1
  79. package/dist/languages/finnish.mjs +528 -263
  80. package/dist/languages/french.d.ts +1 -1
  81. package/dist/languages/french.mjs +950 -437
  82. package/dist/languages/ga.d.ts +1 -1
  83. package/dist/languages/ga.mjs +2 -1
  84. package/dist/languages/galician.d.ts +5 -0
  85. package/dist/languages/galician.mjs +172 -0
  86. package/dist/languages/georgian.d.ts +5 -0
  87. package/dist/languages/georgian.mjs +91 -0
  88. package/dist/languages/german.d.ts +1 -1
  89. package/dist/languages/german.mjs +432 -162
  90. package/dist/languages/greek.d.ts +1 -1
  91. package/dist/languages/greek.mjs +1970 -281
  92. package/dist/languages/guarani.d.ts +5 -0
  93. package/dist/languages/guarani.mjs +132 -0
  94. package/dist/languages/gujarati.d.ts +5 -0
  95. package/dist/languages/gujarati.mjs +234 -0
  96. package/dist/languages/haitian-creole.d.ts +5 -0
  97. package/dist/languages/haitian-creole.mjs +106 -0
  98. package/dist/languages/hausa.d.ts +1 -1
  99. package/dist/languages/hausa.mjs +2 -1
  100. package/dist/languages/hawaiian.d.ts +5 -0
  101. package/dist/languages/hawaiian.mjs +69 -0
  102. package/dist/languages/hebrew.d.ts +5 -0
  103. package/dist/languages/hebrew.mjs +213 -0
  104. package/dist/languages/hindi.d.ts +1 -1
  105. package/dist/languages/hindi.mjs +175 -97
  106. package/dist/languages/hungarian.d.ts +1 -1
  107. package/dist/languages/hungarian.mjs +412 -326
  108. package/dist/languages/icelandic.d.ts +5 -0
  109. package/dist/languages/icelandic.mjs +161 -0
  110. package/dist/languages/igbo.d.ts +1 -1
  111. package/dist/languages/igbo.mjs +2 -1
  112. package/dist/languages/indonesian.d.ts +1 -1
  113. package/dist/languages/indonesian.mjs +329 -137
  114. package/dist/languages/irish.d.ts +1 -1
  115. package/dist/languages/irish.mjs +223 -139
  116. package/dist/languages/italian.d.ts +1 -1
  117. package/dist/languages/italian.mjs +703 -406
  118. package/dist/languages/japanese.d.ts +1 -1
  119. package/dist/languages/japanese.mjs +5 -108
  120. package/dist/languages/kannada.d.ts +5 -0
  121. package/dist/languages/kannada.mjs +94 -0
  122. package/dist/languages/kazakh.d.ts +5 -0
  123. package/dist/languages/kazakh.mjs +90 -0
  124. package/dist/languages/khmer.d.ts +5 -0
  125. package/dist/languages/khmer.mjs +181 -0
  126. package/dist/languages/kinyarwanda.d.ts +5 -0
  127. package/dist/languages/kinyarwanda.mjs +99 -0
  128. package/dist/languages/kirundi.d.ts +5 -0
  129. package/dist/languages/kirundi.mjs +92 -0
  130. package/dist/languages/korean.d.ts +5 -0
  131. package/dist/languages/korean.mjs +252 -0
  132. package/dist/languages/kurmanji.d.ts +5 -0
  133. package/dist/languages/kurmanji.mjs +50 -0
  134. package/dist/languages/kyrgyz.d.ts +5 -0
  135. package/dist/languages/kyrgyz.mjs +108 -0
  136. package/dist/languages/lao.d.ts +5 -0
  137. package/dist/languages/lao.mjs +63 -0
  138. package/dist/languages/latin.d.ts +5 -0
  139. package/dist/languages/latin.mjs +69 -0
  140. package/dist/languages/latvian.d.ts +5 -0
  141. package/dist/languages/latvian.mjs +173 -0
  142. package/dist/languages/lingala.d.ts +5 -0
  143. package/dist/languages/lingala.mjs +61 -0
  144. package/dist/languages/lithuanian.d.ts +5 -0
  145. package/dist/languages/lithuanian.mjs +571 -0
  146. package/dist/languages/luxembourgish.d.ts +5 -0
  147. package/dist/languages/luxembourgish.mjs +214 -0
  148. package/dist/languages/macedonian.d.ts +5 -0
  149. package/dist/languages/macedonian.mjs +808 -0
  150. package/dist/languages/malagasy.d.ts +5 -0
  151. package/dist/languages/malagasy.mjs +67 -0
  152. package/dist/languages/malay.d.ts +5 -0
  153. package/dist/languages/malay.mjs +482 -0
  154. package/dist/languages/malayalam.d.ts +5 -0
  155. package/dist/languages/malayalam.mjs +122 -0
  156. package/dist/languages/maltese.d.ts +5 -0
  157. package/dist/languages/maltese.mjs +139 -0
  158. package/dist/languages/maori.d.ts +5 -0
  159. package/dist/languages/maori.mjs +79 -0
  160. package/dist/languages/marathi.d.ts +5 -0
  161. package/dist/languages/marathi.mjs +111 -0
  162. package/dist/languages/nepali.d.ts +1 -1
  163. package/dist/languages/nepali.mjs +209 -99
  164. package/dist/languages/norwegian.d.ts +1 -1
  165. package/dist/languages/norwegian.mjs +218 -85
  166. package/dist/languages/oromo.d.ts +5 -0
  167. package/dist/languages/oromo.mjs +78 -0
  168. package/dist/languages/persian.d.ts +5 -0
  169. package/dist/languages/persian.mjs +710 -0
  170. package/dist/languages/polish.d.ts +5 -0
  171. package/dist/languages/polish.mjs +614 -0
  172. package/dist/languages/portuguese.d.ts +1 -1
  173. package/dist/languages/portuguese.mjs +613 -380
  174. package/dist/languages/punjabi.d.ts +5 -0
  175. package/dist/languages/punjabi.mjs +197 -0
  176. package/dist/languages/romanian.d.ts +1 -1
  177. package/dist/languages/romanian.mjs +561 -382
  178. package/dist/languages/russian.d.ts +1 -1
  179. package/dist/languages/russian.mjs +373 -244
  180. package/dist/languages/samoan.d.ts +5 -0
  181. package/dist/languages/samoan.mjs +63 -0
  182. package/dist/languages/sanskrit.d.ts +1 -1
  183. package/dist/languages/sanskrit.mjs +2 -2
  184. package/dist/languages/scottish-gaelic.d.ts +5 -0
  185. package/dist/languages/scottish-gaelic.mjs +370 -0
  186. package/dist/languages/serbian.d.ts +1 -1
  187. package/dist/languages/serbian.mjs +3635 -155
  188. package/dist/languages/shona.d.ts +5 -0
  189. package/dist/languages/shona.mjs +91 -0
  190. package/dist/languages/sinhala.d.ts +5 -0
  191. package/dist/languages/sinhala.mjs +182 -0
  192. package/dist/languages/slovak.d.ts +5 -0
  193. package/dist/languages/slovak.mjs +430 -0
  194. package/dist/languages/slovenian.d.ts +1 -1
  195. package/dist/languages/slovenian.mjs +11 -2
  196. package/dist/languages/sorani.d.ts +5 -0
  197. package/dist/languages/sorani.mjs +156 -0
  198. package/dist/languages/spanish.d.ts +1 -1
  199. package/dist/languages/spanish.mjs +676 -374
  200. package/dist/languages/swahili.d.ts +1 -1
  201. package/dist/languages/swahili.mjs +2 -1
  202. package/dist/languages/swedish.d.ts +1 -1
  203. package/dist/languages/swedish.mjs +237 -95
  204. package/dist/languages/tagalog.d.ts +5 -0
  205. package/dist/languages/tagalog.mjs +159 -0
  206. package/dist/languages/tamil.d.ts +1 -1
  207. package/dist/languages/tamil.mjs +836 -64
  208. package/dist/languages/tatar.d.ts +5 -0
  209. package/dist/languages/tatar.mjs +1017 -0
  210. package/dist/languages/telugu.d.ts +5 -0
  211. package/dist/languages/telugu.mjs +62 -0
  212. package/dist/languages/thai.d.ts +5 -0
  213. package/dist/languages/thai.mjs +65 -0
  214. package/dist/languages/tibetan.d.ts +5 -0
  215. package/dist/languages/tibetan.mjs +119 -0
  216. package/dist/languages/tigrinya.d.ts +5 -0
  217. package/dist/languages/tigrinya.mjs +194 -0
  218. package/dist/languages/tongan.d.ts +5 -0
  219. package/dist/languages/tongan.mjs +56 -0
  220. package/dist/languages/turkish.d.ts +1 -1
  221. package/dist/languages/turkish.mjs +1246 -393
  222. package/dist/languages/twi.d.ts +1 -1
  223. package/dist/languages/twi.mjs +13 -2
  224. package/dist/languages/ukrainian.d.ts +1 -1
  225. package/dist/languages/ukrainian.mjs +125 -47
  226. package/dist/languages/urdu.d.ts +5 -0
  227. package/dist/languages/urdu.mjs +528 -0
  228. package/dist/languages/vietnamese.d.ts +5 -0
  229. package/dist/languages/vietnamese.mjs +277 -0
  230. package/dist/languages/welsh.d.ts +5 -0
  231. package/dist/languages/welsh.mjs +446 -0
  232. package/dist/languages/wolof.d.ts +5 -0
  233. package/dist/languages/wolof.mjs +79 -0
  234. package/dist/languages/xhosa.d.ts +5 -0
  235. package/dist/languages/xhosa.mjs +95 -0
  236. package/dist/languages/yoruba.d.ts +1 -1
  237. package/dist/languages/yoruba.mjs +1 -0
  238. package/dist/languages/zulu.d.ts +1 -1
  239. package/dist/languages/zulu.mjs +2 -1
  240. package/dist/narsil-CMZRPDlQ.d.ts +159 -0
  241. package/dist/persistence/durability/checkpoint-worker.d.ts +13 -0
  242. package/dist/persistence/durability/checkpoint-worker.mjs +6 -5
  243. package/dist/{schema-DCgkO1j4.d.ts → schema-BnvLvjRp.d.ts} +5 -3
  244. package/dist/server.d.ts +12 -3
  245. package/dist/server.mjs +183 -64
  246. package/dist/{types-DHX6zGeD.d.ts → types-BRTcBgAp.d.ts} +1 -1
  247. package/dist/workers/entry.mjs +2 -2
  248. package/package.json +10 -3
  249. package/dist/chunk-TW6BXTOL.mjs +0 -338
  250. package/dist/direct-executor-E52FFGDE.mjs +0 -4
  251. package/dist/narsil-D-84Nvm4.d.ts +0 -72
@@ -1,15 +1,16 @@
1
- import { getNestedValue, getLanguage, extractVectorFieldsFromSchema, createDirectExecutor, createPartitionRouter, createPartitionManager, createVectorIndex, validateSchema, deserializePayloadV2, deserializePayloadV1, tokenize, fanOutQuery, validateRequiredFields, validateVectorPromotion, createPartitionIndex, flattenSchema, fnv1a, detectRuntime, serializeMetadata, isNodeMainThread, spawnNodeWorker, deserializeMetadata, kWayMerge } from './chunk-HMO6OMZ3.mjs';
1
+ import { getNestedValue, getLanguage, extractVectorFieldsFromSchema, createDirectExecutor, createPartitionRouter, createPartitionManager, createVectorIndex, validateSchema, validateVectorPromotion, resolveIndexAnalysis, deserializePayloadV2, deserializePayloadV1, tokenize, fanOutQuery, validateRequiredFields, mergePartitionStats, createPartitionIndex, flattenSchema, pruneStatsToQueryTerms, fnv1a, detectRuntime, serializeMetadata, isNodeMainThread, spawnNodeWorker, deserializeMetadata, kWayMerge } from './chunk-S6IAXOBA.mjs';
2
2
  import { crc32, crc32Init, crc32Update, crc32Final } from './chunk-KMMJMDUA.mjs';
3
- import { ErrorCodes, NarsilError } from './chunk-ALB35PRQ.mjs';
4
- import { generateId, createRequestId, isValidWorkerResponse } from './chunk-HRABJLPB.mjs';
3
+ import { ErrorCodes, NarsilError } from './chunk-M2EDG55Y.mjs';
4
+ import { generateId, createRequestId, isValidWorkerResponse } from './chunk-BKAI2WXI.mjs';
5
5
  import { encode, decode } from '@msgpack/msgpack';
6
6
 
7
7
  // src/partitioning/rebalancer.ts
8
8
  var CHUNK_SIZE = 1e3;
9
9
  function createRebalancer() {
10
- let rebalancing = false;
10
+ const activeManagers = /* @__PURE__ */ new WeakSet();
11
+ let activeCount = 0;
11
12
  async function rebalance(manager, newPartitionCount, router, onProgress) {
12
- if (rebalancing) {
13
+ if (activeManagers.has(manager)) {
13
14
  throw new NarsilError(
14
15
  ErrorCodes.PARTITION_REBALANCING_BACKPRESSURE,
15
16
  "A rebalance operation is already in progress"
@@ -29,19 +30,14 @@ function createRebalancer() {
29
30
  { newPartitionCount, currentPartitionCount: manager.partitionCount }
30
31
  );
31
32
  }
32
- rebalancing = true;
33
+ activeManagers.add(manager);
34
+ activeCount++;
33
35
  try {
34
- const collectedDocs = [];
35
36
  const currentPartitions = manager.getAllPartitions();
37
+ let documentsTotal = 0;
36
38
  for (const partition of currentPartitions) {
37
- for (const docId of partition.docIds()) {
38
- const document = partition.get(docId);
39
- if (document) {
40
- collectedDocs.push({ docId, document });
41
- }
42
- }
39
+ documentsTotal += partition.count();
43
40
  }
44
- const documentsTotal = collectedDocs.length;
45
41
  onProgress?.({
46
42
  phase: "scanning",
47
43
  documentsProcessed: 0,
@@ -52,24 +48,38 @@ function createRebalancer() {
52
48
  newPartitions.push(createPartitionIndex(i, manager.config.trackPositions ?? true));
53
49
  }
54
50
  let documentsProcessed = 0;
51
+ let chunkFill = 0;
55
52
  const insertOptions = {
56
53
  validate: false,
57
- stopWordOverride: manager.config.stopWords,
58
- customTokenizer: manager.config.tokenizer,
54
+ skipClone: true,
55
+ stopWordOverride: manager.analysis.stopWords,
56
+ customTokenizer: manager.analysis.customTokenizer,
59
57
  collectSurfaces: manager.config.surfaceForms === true
60
58
  };
61
- for (let chunkStart = 0; chunkStart < collectedDocs.length; chunkStart += CHUNK_SIZE) {
62
- const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, collectedDocs.length);
63
- for (let i = chunkStart; i < chunkEnd; i++) {
64
- const { docId, document } = collectedDocs[i];
59
+ for (const partition of currentPartitions) {
60
+ const docIds = [...partition.docIds()];
61
+ for (const docId of docIds) {
62
+ const document = partition.getRef(docId);
63
+ if (!document) continue;
65
64
  const targetPartitionId = router.route(docId, newPartitionCount);
66
65
  const targetPartition = newPartitions[targetPartitionId];
67
- if (targetPartition.has(docId)) {
68
- continue;
66
+ if (!targetPartition.has(docId)) {
67
+ targetPartition.insert(docId, document, manager.schema, manager.language, insertOptions);
68
+ documentsProcessed++;
69
+ }
70
+ chunkFill++;
71
+ if (chunkFill >= CHUNK_SIZE) {
72
+ chunkFill = 0;
73
+ onProgress?.({
74
+ phase: "moving",
75
+ documentsProcessed,
76
+ documentsTotal
77
+ });
78
+ await new Promise((resolve) => setTimeout(resolve, 0));
69
79
  }
70
- targetPartition.insert(docId, document, manager.schema, manager.language, insertOptions);
71
- documentsProcessed++;
72
80
  }
81
+ }
82
+ if (chunkFill > 0) {
73
83
  onProgress?.({
74
84
  phase: "moving",
75
85
  documentsProcessed,
@@ -89,13 +99,14 @@ function createRebalancer() {
89
99
  documentsTotal
90
100
  });
91
101
  } finally {
92
- rebalancing = false;
102
+ activeManagers.delete(manager);
103
+ activeCount--;
93
104
  }
94
105
  }
95
106
  return {
96
107
  rebalance,
97
108
  isRebalancing() {
98
- return rebalancing;
109
+ return activeCount > 0;
99
110
  }
100
111
  };
101
112
  }
@@ -293,6 +304,221 @@ function createExecutionPromoter(config) {
293
304
  return { check, markPromoted, isPromoted };
294
305
  }
295
306
 
307
+ // src/engine/analysis-rebuild.ts
308
+ function yieldToEventLoop() {
309
+ return new Promise((resolve) => {
310
+ setTimeout(resolve, 0);
311
+ });
312
+ }
313
+ function toError(value) {
314
+ return value instanceof Error ? value : new Error(String(value));
315
+ }
316
+ function wireAnalysisRebuild(wiring) {
317
+ return createAnalysisRebuildCoordinator(wiring.config, {
318
+ getManager: wiring.getManager,
319
+ desyncIndex: wiring.desyncIndex,
320
+ resyncIndex: wiring.resyncIndex,
321
+ async persistAnalysisRevision(indexName) {
322
+ const durability = wiring.durabilityManager;
323
+ if (durability === null) return;
324
+ await durability.persistMetadata(indexName);
325
+ await (durability.checkpointFromMemory?.(indexName) ?? durability.checkpoint(indexName));
326
+ },
327
+ emit(payload) {
328
+ const handlers = wiring.eventHandlers.get("analysisRebuild");
329
+ if (!handlers) return;
330
+ for (const handler of handlers) {
331
+ try {
332
+ handler(payload);
333
+ } catch (err) {
334
+ console.warn("analysisRebuild handler error:", err instanceof Error ? err.message : String(err));
335
+ }
336
+ }
337
+ }
338
+ });
339
+ }
340
+ function createAnalysisRebuildCoordinator(config, deps) {
341
+ const stale = /* @__PURE__ */ new Map();
342
+ const rebuildMode = config?.rebuild ?? "auto";
343
+ let queue = Promise.resolve();
344
+ async function rebuildNow(indexName, entry) {
345
+ const manager = deps.getManager(indexName);
346
+ if (manager === void 0) {
347
+ stale.delete(indexName);
348
+ return;
349
+ }
350
+ const partitionCount = manager.partitionCount;
351
+ deps.emit({ indexName, status: "started", partitionsRebuilt: 0, partitionCount });
352
+ const wasPromoted = deps.desyncIndex(indexName);
353
+ try {
354
+ for (let partitionId = 0; partitionId < manager.partitionCount; partitionId += 1) {
355
+ manager.rebuildTextIndex(partitionId);
356
+ entry.partitionsRebuilt = partitionId + 1;
357
+ await yieldToEventLoop();
358
+ }
359
+ stale.delete(indexName);
360
+ await deps.persistAnalysisRevision(indexName);
361
+ await deps.resyncIndex(indexName, wasPromoted);
362
+ deps.emit({
363
+ indexName,
364
+ status: "completed",
365
+ partitionsRebuilt: entry.partitionsRebuilt,
366
+ partitionCount: manager.partitionCount
367
+ });
368
+ } catch (err) {
369
+ const error = toError(err);
370
+ stale.set(indexName, entry);
371
+ entry.run = null;
372
+ await deps.resyncIndex(indexName, wasPromoted).catch(() => void 0);
373
+ deps.emit({
374
+ indexName,
375
+ status: "failed",
376
+ partitionsRebuilt: entry.partitionsRebuilt,
377
+ partitionCount,
378
+ error
379
+ });
380
+ throw error;
381
+ }
382
+ }
383
+ function enqueue(indexName, entry) {
384
+ if (entry.run !== null) {
385
+ return entry.run;
386
+ }
387
+ const run = queue.then(() => rebuildNow(indexName, entry));
388
+ entry.run = run;
389
+ queue = run.then(
390
+ () => void 0,
391
+ () => void 0
392
+ );
393
+ return run;
394
+ }
395
+ async function announce(indexName, entry) {
396
+ const documentCount = deps.getManager(indexName)?.countDocuments() ?? 0;
397
+ await config?.onStaleAnalysis?.({ ...entry.index, documentCount }, () => enqueue(indexName, entry));
398
+ }
399
+ return {
400
+ markStale(index) {
401
+ const existing = stale.get(index.indexName);
402
+ if (existing !== void 0 && existing.run !== null) {
403
+ return;
404
+ }
405
+ stale.set(index.indexName, { index, partitionsRebuilt: 0, run: null });
406
+ },
407
+ clearStale(indexName) {
408
+ stale.delete(indexName);
409
+ },
410
+ isStale(indexName) {
411
+ return stale.has(indexName);
412
+ },
413
+ async reviewStaleIndexes() {
414
+ for (const [indexName, entry] of [...stale]) {
415
+ if (entry.run !== null) continue;
416
+ try {
417
+ await announce(indexName, entry);
418
+ } catch (err) {
419
+ deps.emit({
420
+ indexName,
421
+ status: "failed",
422
+ partitionsRebuilt: 0,
423
+ partitionCount: deps.getManager(indexName)?.partitionCount ?? 0,
424
+ error: toError(err)
425
+ });
426
+ }
427
+ if (rebuildMode === "auto") {
428
+ void enqueue(indexName, entry).catch(() => void 0);
429
+ }
430
+ }
431
+ },
432
+ rebuild(indexName) {
433
+ const entry = stale.get(indexName);
434
+ if (entry === void 0) {
435
+ return Promise.resolve();
436
+ }
437
+ return enqueue(indexName, entry);
438
+ }
439
+ };
440
+ }
441
+
442
+ // src/engine/durability-config.ts
443
+ var WAL_ONLY_FIELDS = ["directory", "mode", "flushIntervalMs", "segmentMaxBytes", "compactionThreshold"];
444
+ function configError(message) {
445
+ return new NarsilError(ErrorCodes.CONFIG_INVALID, message);
446
+ }
447
+ function requireKnob(name, value, minimum) {
448
+ if (value === void 0) {
449
+ return;
450
+ }
451
+ if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) {
452
+ throw configError(`durability.${name} must be a finite number of at least ${minimum}, got ${String(value)}`);
453
+ }
454
+ }
455
+ function requireValidDurabilityConfig(durability) {
456
+ if (durability.tier !== void 0 && durability.tier !== "wal" && durability.tier !== "snapshot") {
457
+ throw configError(`durability.tier must be "wal" or "snapshot", got "${String(durability.tier)}"`);
458
+ }
459
+ if (durability.mode !== void 0 && durability.mode !== "sync" && durability.mode !== "async") {
460
+ throw configError(`durability.mode must be "sync" or "async", got "${String(durability.mode)}"`);
461
+ }
462
+ requireKnob("checkpointIntervalMs", durability.checkpointIntervalMs, 0);
463
+ requireKnob("checkpointMutationThreshold", durability.checkpointMutationThreshold, 1);
464
+ requireKnob("flushIntervalMs", durability.flushIntervalMs, 0);
465
+ requireKnob("segmentMaxBytes", durability.segmentMaxBytes, 1);
466
+ requireKnob("compactionThreshold", durability.compactionThreshold, 1);
467
+ if (durability.tier === "snapshot") {
468
+ for (const field of WAL_ONLY_FIELDS) {
469
+ if (durability[field] !== void 0) {
470
+ throw configError(
471
+ `durability.${field} applies to the write-ahead log tier only and cannot combine with durability.tier "snapshot"`
472
+ );
473
+ }
474
+ }
475
+ }
476
+ }
477
+ function filesystemBackedDirectory(config) {
478
+ const adapterDirectory = config.persistence?.directory;
479
+ if (adapterDirectory !== void 0 && adapterDirectory.trim().length > 0) {
480
+ return adapterDirectory;
481
+ }
482
+ return null;
483
+ }
484
+ function resolveDurabilityTier(config) {
485
+ const filesystemDirectory = filesystemBackedDirectory(config);
486
+ if (config.durability) {
487
+ requireValidDurabilityConfig(config.durability);
488
+ }
489
+ if (config.durability?.tier === "snapshot") {
490
+ if (config.persistence === void 0) {
491
+ throw configError(
492
+ "Snapshot durability persists through a persistence adapter. Configure persistence, or remove durability.tier"
493
+ );
494
+ }
495
+ return { kind: "snapshot", config: { ...config.durability }, adapter: config.persistence };
496
+ }
497
+ if (config.durability) {
498
+ if (config.durability.directory !== void 0 && config.durability.directory.trim().length > 0) {
499
+ return { kind: "wal", config: { ...config.durability } };
500
+ }
501
+ if (filesystemDirectory !== null) {
502
+ return { kind: "wal", config: { ...config.durability, directory: filesystemDirectory } };
503
+ }
504
+ if (config.persistence !== void 0) {
505
+ throw configError(
506
+ "WAL durability requires a filesystem directory, but the configured persistence adapter is not filesystem-backed. Use a filesystem persistence adapter or set durability.directory"
507
+ );
508
+ }
509
+ throw configError(
510
+ "Durability requires a directory. Set durability.directory explicitly, or configure a filesystem persistence adapter"
511
+ );
512
+ }
513
+ if (config.persistence === void 0) {
514
+ return null;
515
+ }
516
+ if (filesystemDirectory !== null) {
517
+ return { kind: "wal", config: { directory: filesystemDirectory } };
518
+ }
519
+ return { kind: "snapshot", config: {}, adapter: config.persistence };
520
+ }
521
+
296
522
  // src/persistence/durability/node-fs.ts
297
523
  var cachedFs = null;
298
524
  var cachedPath = null;
@@ -597,7 +823,7 @@ async function spawnWorker() {
597
823
  return null;
598
824
  }
599
825
  }
600
- function yieldToEventLoop() {
826
+ function yieldToEventLoop2() {
601
827
  if (typeof setImmediate === "function") {
602
828
  return new Promise((resolve) => setImmediate(resolve));
603
829
  }
@@ -609,7 +835,7 @@ async function chunkedChecksum(payload) {
609
835
  const end = Math.min(offset + YIELD_CHUNK_BYTES, payload.length);
610
836
  state = crc32Update(state, payload.subarray(offset, end));
611
837
  if (end < payload.length) {
612
- await yieldToEventLoop();
838
+ await yieldToEventLoop2();
613
839
  }
614
840
  }
615
841
  return crc32Final(state);
@@ -978,6 +1204,10 @@ async function encodeSnapshotBundle(bundle) {
978
1204
  version: 1,
979
1205
  schema: bundle.schema,
980
1206
  language: bundle.language,
1207
+ ...bundle.analysisRevision !== void 0 ? { analysis_revision: bundle.analysisRevision } : {},
1208
+ ...bundle.tokenizer !== void 0 ? { tokenizer: bundle.tokenizer } : {},
1209
+ ...bundle.stopWords !== void 0 ? { stop_words: bundle.stopWords } : {},
1210
+ ...bundle.stopWordList !== void 0 ? { stop_word_list: bundle.stopWordList } : {},
981
1211
  partitions: bundle.partitions,
982
1212
  vectorIndexes: bundle.vectorIndexes,
983
1213
  checkpoint: bundle.checkpoint.map((c) => ({
@@ -1007,15 +1237,38 @@ async function decodeSnapshotBundle(data) {
1007
1237
  if (!Array.isArray(raw.partitions)) {
1008
1238
  throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Snapshot bundle missing partitions");
1009
1239
  }
1240
+ if (raw.analysis_revision !== void 0 && typeof raw.analysis_revision !== "string") {
1241
+ throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Snapshot bundle analysis_revision must be a revision");
1242
+ }
1243
+ if (raw.tokenizer !== void 0 && typeof raw.tokenizer !== "string") {
1244
+ throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Snapshot bundle tokenizer must be a name");
1245
+ }
1246
+ if (raw.stop_words !== void 0 && typeof raw.stop_words !== "string") {
1247
+ throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Snapshot bundle stop_words must be a name");
1248
+ }
1249
+ const stopWordList = normalizeStopWordList(raw.stop_word_list);
1010
1250
  return {
1011
1251
  version: 1,
1012
1252
  schema: raw.schema,
1013
1253
  language: raw.language,
1254
+ ...typeof raw.analysis_revision === "string" ? { analysisRevision: raw.analysis_revision } : {},
1255
+ ...typeof raw.tokenizer === "string" ? { tokenizer: raw.tokenizer } : {},
1256
+ ...typeof raw.stop_words === "string" ? { stopWords: raw.stop_words } : {},
1257
+ ...stopWordList !== void 0 ? { stopWordList } : {},
1014
1258
  partitions: raw.partitions,
1015
1259
  vectorIndexes: raw.vectorIndexes ?? {},
1016
1260
  checkpoint: normalizeCheckpoint(raw.checkpoint)
1017
1261
  };
1018
1262
  }
1263
+ function normalizeStopWordList(raw) {
1264
+ if (raw === void 0) {
1265
+ return void 0;
1266
+ }
1267
+ if (!Array.isArray(raw) || !raw.every((word) => typeof word === "string")) {
1268
+ throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Snapshot bundle stop_word_list must be a list of words");
1269
+ }
1270
+ return raw;
1271
+ }
1019
1272
  function normalizeCheckpoint(raw) {
1020
1273
  if (!Array.isArray(raw)) {
1021
1274
  return [];
@@ -1064,6 +1317,10 @@ async function buildSnapshotBundleBytes(input) {
1064
1317
  version: 1,
1065
1318
  schema: input.schema,
1066
1319
  language: input.language,
1320
+ ...input.analysisRevision !== void 0 ? { analysisRevision: input.analysisRevision } : {},
1321
+ ...input.tokenizer !== void 0 ? { tokenizer: input.tokenizer } : {},
1322
+ ...input.stopWords !== void 0 ? { stopWords: input.stopWords } : {},
1323
+ ...input.stopWordList !== void 0 ? { stopWordList: input.stopWordList } : {},
1067
1324
  partitions: partitionBuffers,
1068
1325
  vectorIndexes: vectorPayloads,
1069
1326
  checkpoint
@@ -1104,6 +1361,36 @@ async function truncateCoveredSegments(directory, indexName, checkpoint) {
1104
1361
  }
1105
1362
  }
1106
1363
  }
1364
+ async function reclaimWalBeyondCount(directory, indexName, partitionCount, partitions, onCloseError) {
1365
+ for (const [partitionId, partition] of [...partitions]) {
1366
+ if (partitionId < partitionCount) continue;
1367
+ try {
1368
+ await partition.walWriter.close();
1369
+ } catch (err) {
1370
+ onCloseError(err instanceof Error ? err : new Error(String(err)));
1371
+ }
1372
+ partitions.delete(partitionId);
1373
+ }
1374
+ const walRoot = `${indexName}/wal/`;
1375
+ for (const key of await directory.list(walRoot)) {
1376
+ const partitionId = walKeyPartitionId(key, walRoot);
1377
+ if (partitionId === null || partitionId < partitionCount) continue;
1378
+ await directory.remove(key);
1379
+ }
1380
+ }
1381
+ function walKeyPartitionId(key, walRoot) {
1382
+ const tail = key.slice(walRoot.length);
1383
+ const separator = tail.indexOf("/");
1384
+ if (separator <= 0) {
1385
+ return null;
1386
+ }
1387
+ const segment = tail.slice(0, separator);
1388
+ if (!/^\d+$/.test(segment)) {
1389
+ return null;
1390
+ }
1391
+ const value = Number.parseInt(segment, 10);
1392
+ return Number.isSafeInteger(value) ? value : null;
1393
+ }
1107
1394
  function parseSegmentStartSeqNo(key, prefix) {
1108
1395
  if (!key.startsWith(prefix)) {
1109
1396
  return null;
@@ -1253,454 +1540,198 @@ function terminateCheckpointWorker() {
1253
1540
  }
1254
1541
  }
1255
1542
 
1256
- // src/engine/vector-coordinator.ts
1257
- function extractVectorFromDoc(document, fieldPath) {
1258
- const value = getNestedValue(document, fieldPath);
1259
- if (value === void 0 || value === null) return null;
1260
- if (value instanceof Float32Array) return value;
1261
- if (!Array.isArray(value)) return null;
1262
- const arr = new Float32Array(value.length);
1263
- for (let i = 0; i < value.length; i++) {
1264
- const n = value[i];
1265
- if (typeof n !== "number" || !Number.isFinite(n)) return null;
1266
- arr[i] = n;
1267
- }
1268
- return arr;
1543
+ // src/persistence/durability/segment/layout.ts
1544
+ var DEFAULT_COMPACTION_THRESHOLD = 12;
1545
+ var SEGMENT_ID_WIDTH = 16;
1546
+ function manifestKey(indexName) {
1547
+ return `${indexName}/manifest`;
1269
1548
  }
1270
- function deleteNestedValue(obj, path) {
1271
- if (!path.includes(".")) {
1272
- delete obj[path];
1273
- return;
1549
+ function legacySnapshotKey(indexName) {
1550
+ return `${indexName}/snapshot`;
1551
+ }
1552
+ function segmentPrefix(indexName, partitionId) {
1553
+ return `${indexName}/segments/${partitionId}/`;
1554
+ }
1555
+ function segmentKey(indexName, partitionId, segmentId) {
1556
+ return `${segmentPrefix(indexName, partitionId)}s${formatSegmentId(segmentId)}`;
1557
+ }
1558
+ function vectorSegmentKey(indexName, partitionId, fieldPath, generation) {
1559
+ return `${segmentPrefix(indexName, partitionId)}vec-${encodeFieldPath(fieldPath)}-g${generation}`;
1560
+ }
1561
+ function formatSegmentId(segmentId) {
1562
+ if (!Number.isInteger(segmentId) || segmentId < 0) {
1563
+ throw new NarsilError(
1564
+ ErrorCodes.PERSISTENCE_SAVE_FAILED,
1565
+ `Segment id ${segmentId} must be a non-negative integer`,
1566
+ { segmentId }
1567
+ );
1274
1568
  }
1275
- const segments = path.split(".");
1276
- let current = obj;
1277
- for (let i = 0; i < segments.length - 1; i++) {
1278
- const next = current[segments[i]];
1279
- if (next === null || next === void 0 || typeof next !== "object") return;
1280
- current = next;
1569
+ return segmentId.toString().padStart(SEGMENT_ID_WIDTH, "0");
1570
+ }
1571
+ var FIELD_PATH_PATTERN = /^[A-Za-z0-9_.]+$/;
1572
+ function encodeFieldPath(fieldPath) {
1573
+ if (!FIELD_PATH_PATTERN.test(fieldPath)) {
1574
+ throw new NarsilError(
1575
+ ErrorCodes.PERSISTENCE_SAVE_FAILED,
1576
+ `Vector field path "${fieldPath}" contains characters that cannot be encoded in a segment key`,
1577
+ { fieldPath }
1578
+ );
1281
1579
  }
1282
- delete current[segments[segments.length - 1]];
1580
+ const fingerprint = fnv1a(fieldPath).toString(36);
1581
+ return `${fieldPath.replace(/\./g, "_")}-${fingerprint}`;
1283
1582
  }
1284
- function vectorsEqual(a, b) {
1285
- if (!a) return false;
1286
- if (a.length !== b.length) return false;
1287
- for (let i = 0; i < a.length; i++) {
1288
- if (a[i] !== b[i]) return false;
1583
+ async function decodeSegmentManifest(data) {
1584
+ const { payloadBytes } = await unpackEnvelopeBytes(data);
1585
+ const raw = decode(payloadBytes);
1586
+ if (raw.version !== SEGMENT_MANIFEST_VERSION) {
1587
+ throw new NarsilError(
1588
+ ErrorCodes.PERSISTENCE_LOAD_FAILED,
1589
+ `Unsupported segment manifest version ${raw.version}; expected ${SEGMENT_MANIFEST_VERSION}`,
1590
+ { version: raw.version }
1591
+ );
1289
1592
  }
1290
- return true;
1593
+ const base = validateManifestCommon(raw);
1594
+ return {
1595
+ version: SEGMENT_MANIFEST_VERSION,
1596
+ schema: base.schema,
1597
+ language: base.language,
1598
+ checkpoint: normalizeCheckpoint2(raw.checkpoint),
1599
+ partitions: normalizePartitions(raw.partitions)
1600
+ };
1291
1601
  }
1292
- function validateVectorDimensions(vectors, vecIndexes) {
1293
- for (const [fieldPath, vector] of vectors) {
1294
- const vecIndex = vecIndexes.get(fieldPath);
1295
- if (!vecIndex) continue;
1296
- if (vector.length !== vecIndex.dimension) {
1297
- throw new NarsilError(
1298
- ErrorCodes.VECTOR_DIMENSION_MISMATCH,
1299
- `Vector dimension mismatch for field "${fieldPath}": expected ${vecIndex.dimension}, got ${vector.length}`,
1300
- { field: fieldPath, expected: vecIndex.dimension, received: vector.length }
1301
- );
1302
- }
1602
+ function validateManifestCommon(raw) {
1603
+ if (raw.schema === void 0 || typeof raw.schema !== "object") {
1604
+ throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest is missing schema");
1605
+ }
1606
+ if (typeof raw.language !== "string") {
1607
+ throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest is missing language");
1303
1608
  }
1609
+ return { schema: raw.schema, language: raw.language };
1304
1610
  }
1305
- function prepareDocumentVectors(document, vectorFieldPaths, vecIndexes) {
1306
- if (vecIndexes.size === 0) {
1307
- return { partitionDoc: document, extractedVectors: /* @__PURE__ */ new Map() };
1611
+ function normalizeCheckpoint2(raw) {
1612
+ if (!Array.isArray(raw)) {
1613
+ return [];
1308
1614
  }
1309
- const extractedVectors = /* @__PURE__ */ new Map();
1310
- for (const fieldPath of vectorFieldPaths) {
1311
- const vec = extractVectorFromDoc(document, fieldPath);
1312
- if (vec) {
1313
- extractedVectors.set(fieldPath, vec);
1615
+ const result = [];
1616
+ for (const entry of raw) {
1617
+ if (typeof entry?.partitionId !== "number" || typeof entry.lastSeqNo !== "number" || typeof entry.primaryTerm !== "number") {
1618
+ continue;
1314
1619
  }
1620
+ result.push({ partitionId: entry.partitionId, lastSeqNo: entry.lastSeqNo, primaryTerm: entry.primaryTerm });
1315
1621
  }
1316
- if (extractedVectors.size === 0) {
1317
- return { partitionDoc: document, extractedVectors };
1318
- }
1319
- const partitionDoc = shallowCopyExcluding(document, extractedVectors.keys());
1320
- return { partitionDoc, extractedVectors };
1622
+ return result;
1321
1623
  }
1322
- function shallowCopyExcluding(document, fieldPaths) {
1323
- const topLevelExcludes = /* @__PURE__ */ new Set();
1324
- const nestedPaths = [];
1325
- for (const path of fieldPaths) {
1326
- if (!path.includes(".")) {
1327
- topLevelExcludes.add(path);
1328
- } else {
1329
- nestedPaths.push(path);
1330
- }
1331
- }
1332
- const result = {};
1333
- for (const key of Object.keys(document)) {
1334
- if (topLevelExcludes.has(key)) continue;
1335
- result[key] = document[key];
1624
+ function normalizePartitions(raw) {
1625
+ if (!Array.isArray(raw)) {
1626
+ throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest is missing partition entries");
1336
1627
  }
1337
- for (const path of nestedPaths) {
1338
- const segments = path.split(".");
1339
- const topKey = segments[0];
1340
- if (!(topKey in result) || result[topKey] === null || typeof result[topKey] !== "object") continue;
1341
- const source = document[topKey];
1342
- let target = { ...source };
1343
- result[topKey] = target;
1344
- for (let i = 1; i < segments.length - 1; i++) {
1345
- const seg = segments[i];
1346
- const next = target[seg];
1347
- if (next === null || next === void 0 || typeof next !== "object") break;
1348
- const copy = { ...next };
1349
- target[seg] = copy;
1350
- target = copy;
1628
+ const result = [];
1629
+ for (const entry of raw) {
1630
+ if (!isNonNegativeInteger(entry?.partitionId)) {
1631
+ throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest has a partition without a valid id", {
1632
+ partitionId: entry?.partitionId
1633
+ });
1351
1634
  }
1352
- delete target[segments[segments.length - 1]];
1635
+ const segments = normalizeSegments(entry.segments, entry.partitionId);
1636
+ const nextSegmentId = readNextSegmentId(entry.nextSegmentId, segments, entry.partitionId);
1637
+ result.push({
1638
+ partitionId: entry.partitionId,
1639
+ nextSegmentId,
1640
+ segments,
1641
+ vectors: normalizeVectors(entry.vectors, entry.partitionId)
1642
+ });
1353
1643
  }
1354
1644
  return result;
1355
1645
  }
1356
- function insertDocumentVectors(docId, vectors, vecIndexes) {
1357
- const insertedFields = [];
1358
- try {
1359
- for (const [fieldPath, vector] of vectors) {
1360
- const vecIndex = vecIndexes.get(fieldPath);
1361
- if (!vecIndex) continue;
1362
- vecIndex.insert(docId, vector);
1363
- insertedFields.push(fieldPath);
1364
- }
1365
- } catch (err) {
1366
- for (const fieldPath of insertedFields) {
1367
- const vecIndex = vecIndexes.get(fieldPath);
1368
- if (vecIndex) {
1369
- vecIndex.remove(docId);
1370
- }
1371
- }
1372
- throw err;
1646
+ function normalizeSegments(raw, partitionId) {
1647
+ if (raw === void 0) {
1648
+ return [];
1373
1649
  }
1374
- return insertedFields;
1375
- }
1376
- function removeDocumentVectors(docId, vecIndexes) {
1377
- for (const [, vecIndex] of vecIndexes) {
1378
- vecIndex.remove(docId);
1650
+ if (!Array.isArray(raw)) {
1651
+ throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest has malformed segment references", {
1652
+ partitionId
1653
+ });
1379
1654
  }
1380
- }
1381
- function updateDocumentVectors(docId, vectors, vecIndexes) {
1382
- const updatedFields = [];
1383
- try {
1384
- for (const [fieldPath, newVec] of vectors) {
1385
- const vecIndex = vecIndexes.get(fieldPath);
1386
- if (!vecIndex) continue;
1387
- if (newVec === null) {
1388
- if (vecIndex.has(docId)) {
1389
- const oldVec2 = vecIndex.getVector(docId);
1390
- vecIndex.remove(docId);
1391
- updatedFields.push({ fieldPath, oldVec: oldVec2 });
1392
- }
1393
- continue;
1394
- }
1395
- const oldVec = vecIndex.getVector(docId);
1396
- if (vectorsEqual(oldVec, newVec)) continue;
1397
- vecIndex.remove(docId);
1398
- try {
1399
- vecIndex.insert(docId, newVec);
1400
- updatedFields.push({ fieldPath, oldVec });
1401
- } catch (err) {
1402
- if (oldVec) {
1403
- vecIndex.insert(docId, oldVec);
1404
- }
1405
- throw err;
1406
- }
1655
+ if (raw.length > MAX_SEGMENTS_PER_PARTITION) {
1656
+ throw new NarsilError(
1657
+ ErrorCodes.PERSISTENCE_LOAD_FAILED,
1658
+ `Segment manifest lists ${raw.length} segments for partition ${partitionId}, exceeding the maximum of ${MAX_SEGMENTS_PER_PARTITION}`,
1659
+ { partitionId, segmentCount: raw.length, maximum: MAX_SEGMENTS_PER_PARTITION }
1660
+ );
1661
+ }
1662
+ const seenIds = /* @__PURE__ */ new Set();
1663
+ const result = [];
1664
+ for (const segment of raw) {
1665
+ if (!isSegmentId(segment?.id) || !isNonNegativeInteger(segment.docCount) || !isNonNegativeInteger(segment.tombstoneCount) || !isKey(segment.key)) {
1666
+ throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest has an invalid segment reference", {
1667
+ partitionId,
1668
+ segment
1669
+ });
1407
1670
  }
1408
- } catch (err) {
1409
- for (const { fieldPath, oldVec } of updatedFields) {
1410
- const vecIndex = vecIndexes.get(fieldPath);
1411
- if (!vecIndex) continue;
1412
- vecIndex.remove(docId);
1413
- if (oldVec) {
1414
- vecIndex.insert(docId, oldVec);
1415
- }
1671
+ if (seenIds.has(segment.id)) {
1672
+ throw new NarsilError(
1673
+ ErrorCodes.PERSISTENCE_LOAD_FAILED,
1674
+ `Segment manifest reuses segment id ${segment.id} in partition ${partitionId}`,
1675
+ { partitionId, segmentId: segment.id }
1676
+ );
1416
1677
  }
1417
- throw err;
1678
+ seenIds.add(segment.id);
1679
+ result.push({
1680
+ id: segment.id,
1681
+ key: segment.key,
1682
+ docCount: segment.docCount,
1683
+ tombstoneCount: segment.tombstoneCount
1684
+ });
1418
1685
  }
1686
+ return result;
1419
1687
  }
1420
-
1421
- // src/distribution/replication/replica.ts
1422
- function validateReplicationEntry(entry, localPrimaryTerm, log) {
1423
- if (!log.verifyChecksum(entry)) {
1424
- return { valid: false, error: "REPLICATION_ENTRY_CORRUPT" };
1688
+ function readNextSegmentId(value, segments, partitionId) {
1689
+ if (!isSegmentId(value)) {
1690
+ throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest has an invalid next segment id", {
1691
+ partitionId,
1692
+ nextSegmentId: value
1693
+ });
1425
1694
  }
1426
- if (entry.primaryTerm < localPrimaryTerm) {
1427
- return { valid: false, error: "REPLICATION_TERM_MISMATCH" };
1695
+ for (const segment of segments) {
1696
+ if (segment.id >= value) {
1697
+ throw new NarsilError(
1698
+ ErrorCodes.PERSISTENCE_LOAD_FAILED,
1699
+ `Segment manifest next segment id ${value} does not exceed an existing segment id ${segment.id}`,
1700
+ { partitionId, nextSegmentId: value, segmentId: segment.id }
1701
+ );
1702
+ }
1428
1703
  }
1429
- return { valid: true };
1704
+ return value;
1430
1705
  }
1431
- function applyIndexEntry(entry, manager, vectorFieldPaths, vecIndexes) {
1432
- if (entry.document === null) {
1433
- return;
1434
- }
1435
- const document = decode(entry.document);
1436
- restoreVectorFields(document, vectorFieldPaths);
1437
- if (manager.has(entry.documentId)) {
1438
- removeDocumentVectors(entry.documentId, vecIndexes);
1439
- manager.remove(entry.documentId);
1706
+ function normalizeVectors(raw, partitionId) {
1707
+ if (raw === void 0) {
1708
+ return [];
1440
1709
  }
1441
- const { partitionDoc, extractedVectors } = prepareDocumentVectors(document, vectorFieldPaths, vecIndexes);
1442
- manager.insert(entry.documentId, partitionDoc);
1443
- try {
1444
- insertDocumentVectors(entry.documentId, extractedVectors, vecIndexes);
1445
- } catch (err) {
1446
- manager.remove(entry.documentId);
1447
- throw err;
1710
+ if (!Array.isArray(raw)) {
1711
+ throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest has malformed vector references", {
1712
+ partitionId
1713
+ });
1448
1714
  }
1449
- for (const fieldPath of extractedVectors.keys()) {
1450
- const vecIndex = vecIndexes.get(fieldPath);
1451
- if (vecIndex) {
1452
- vecIndex.scheduleBuild();
1715
+ const result = [];
1716
+ for (const vector of raw) {
1717
+ if (typeof vector?.fieldPath !== "string" || !isNonNegativeInteger(vector.generation) || !isKey(vector.key)) {
1718
+ throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest has an invalid vector reference", {
1719
+ partitionId,
1720
+ vector
1721
+ });
1453
1722
  }
1723
+ result.push({ fieldPath: vector.fieldPath, generation: vector.generation, key: vector.key });
1454
1724
  }
1725
+ return result;
1455
1726
  }
1456
- function applyDeleteEntry(entry, manager, vecIndexes) {
1457
- if (!manager.has(entry.documentId)) {
1458
- return;
1459
- }
1460
- removeDocumentVectors(entry.documentId, vecIndexes);
1461
- manager.remove(entry.documentId);
1727
+ function isNonNegativeInteger(value) {
1728
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
1462
1729
  }
1463
- function restoreVectorFields(document, vectorFieldPaths) {
1464
- for (const fieldPath of vectorFieldPaths) {
1465
- const value = getNestedValue(document, fieldPath);
1466
- if (value instanceof Uint8Array && !(value instanceof Float32Array)) {
1467
- if (value.byteLength % 4 !== 0) {
1468
- throw new Error(
1469
- `Vector field "${fieldPath}" has invalid byte length ${value.byteLength} (must be divisible by 4)`
1470
- );
1471
- }
1472
- const elementCount = value.byteLength / 4;
1473
- const float32 = new Float32Array(elementCount);
1474
- const dataView = new DataView(value.buffer, value.byteOffset, value.byteLength);
1475
- for (let i = 0; i < elementCount; i += 1) {
1476
- float32[i] = dataView.getFloat32(i * 4, true);
1477
- }
1478
- setNestedValue(document, fieldPath, float32);
1479
- }
1480
- }
1730
+ function isSegmentId(value) {
1731
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= MAX_SEGMENT_ID;
1481
1732
  }
1482
- var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1483
- function setNestedValue(obj, path, value) {
1484
- if (!path.includes(".")) {
1485
- if (FORBIDDEN_KEYS.has(path)) {
1486
- return;
1487
- }
1488
- obj[path] = value;
1489
- return;
1490
- }
1491
- const segments = path.split(".");
1492
- let current = obj;
1493
- for (let i = 0; i < segments.length - 1; i += 1) {
1494
- const segment = segments[i];
1495
- if (segment === void 0 || FORBIDDEN_KEYS.has(segment)) {
1496
- return;
1497
- }
1498
- let next = current[segment];
1499
- if (next === null || next === void 0 || typeof next !== "object") {
1500
- next = {};
1501
- current[segment] = next;
1502
- }
1503
- current = next;
1504
- }
1505
- const finalSegment = segments.at(-1);
1506
- if (finalSegment === void 0 || FORBIDDEN_KEYS.has(finalSegment)) {
1507
- return;
1508
- }
1509
- current[finalSegment] = value;
1510
- }
1511
-
1512
- // src/persistence/durability/segment/layout.ts
1513
- var DEFAULT_COMPACTION_THRESHOLD = 12;
1514
- var SEGMENT_ID_WIDTH = 16;
1515
- function manifestKey(indexName) {
1516
- return `${indexName}/manifest`;
1517
- }
1518
- function legacySnapshotKey(indexName) {
1519
- return `${indexName}/snapshot`;
1520
- }
1521
- function segmentPrefix(indexName, partitionId) {
1522
- return `${indexName}/segments/${partitionId}/`;
1523
- }
1524
- function segmentKey(indexName, partitionId, segmentId) {
1525
- return `${segmentPrefix(indexName, partitionId)}s${formatSegmentId(segmentId)}`;
1526
- }
1527
- function vectorSegmentKey(indexName, partitionId, fieldPath, generation) {
1528
- return `${segmentPrefix(indexName, partitionId)}vec-${encodeFieldPath(fieldPath)}-g${generation}`;
1529
- }
1530
- function formatSegmentId(segmentId) {
1531
- if (!Number.isInteger(segmentId) || segmentId < 0) {
1532
- throw new NarsilError(
1533
- ErrorCodes.PERSISTENCE_SAVE_FAILED,
1534
- `Segment id ${segmentId} must be a non-negative integer`,
1535
- { segmentId }
1536
- );
1537
- }
1538
- return segmentId.toString().padStart(SEGMENT_ID_WIDTH, "0");
1539
- }
1540
- var FIELD_PATH_PATTERN = /^[A-Za-z0-9_.]+$/;
1541
- function encodeFieldPath(fieldPath) {
1542
- if (!FIELD_PATH_PATTERN.test(fieldPath)) {
1543
- throw new NarsilError(
1544
- ErrorCodes.PERSISTENCE_SAVE_FAILED,
1545
- `Vector field path "${fieldPath}" contains characters that cannot be encoded in a segment key`,
1546
- { fieldPath }
1547
- );
1548
- }
1549
- const fingerprint = fnv1a(fieldPath).toString(36);
1550
- return `${fieldPath.replace(/\./g, "_")}-${fingerprint}`;
1551
- }
1552
- async function decodeSegmentManifest(data) {
1553
- const { payloadBytes } = await unpackEnvelopeBytes(data);
1554
- const raw = decode(payloadBytes);
1555
- if (raw.version !== SEGMENT_MANIFEST_VERSION) {
1556
- throw new NarsilError(
1557
- ErrorCodes.PERSISTENCE_LOAD_FAILED,
1558
- `Unsupported segment manifest version ${raw.version}; expected ${SEGMENT_MANIFEST_VERSION}`,
1559
- { version: raw.version }
1560
- );
1561
- }
1562
- const base = validateManifestCommon(raw);
1563
- return {
1564
- version: SEGMENT_MANIFEST_VERSION,
1565
- schema: base.schema,
1566
- language: base.language,
1567
- checkpoint: normalizeCheckpoint2(raw.checkpoint),
1568
- partitions: normalizePartitions(raw.partitions)
1569
- };
1570
- }
1571
- function validateManifestCommon(raw) {
1572
- if (raw.schema === void 0 || typeof raw.schema !== "object") {
1573
- throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest is missing schema");
1574
- }
1575
- if (typeof raw.language !== "string") {
1576
- throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest is missing language");
1577
- }
1578
- return { schema: raw.schema, language: raw.language };
1579
- }
1580
- function normalizeCheckpoint2(raw) {
1581
- if (!Array.isArray(raw)) {
1582
- return [];
1583
- }
1584
- const result = [];
1585
- for (const entry of raw) {
1586
- if (typeof entry?.partitionId !== "number" || typeof entry.lastSeqNo !== "number" || typeof entry.primaryTerm !== "number") {
1587
- continue;
1588
- }
1589
- result.push({ partitionId: entry.partitionId, lastSeqNo: entry.lastSeqNo, primaryTerm: entry.primaryTerm });
1590
- }
1591
- return result;
1592
- }
1593
- function normalizePartitions(raw) {
1594
- if (!Array.isArray(raw)) {
1595
- throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest is missing partition entries");
1596
- }
1597
- const result = [];
1598
- for (const entry of raw) {
1599
- if (!isNonNegativeInteger(entry?.partitionId)) {
1600
- throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest has a partition without a valid id", {
1601
- partitionId: entry?.partitionId
1602
- });
1603
- }
1604
- const segments = normalizeSegments(entry.segments, entry.partitionId);
1605
- const nextSegmentId = readNextSegmentId(entry.nextSegmentId, segments, entry.partitionId);
1606
- result.push({
1607
- partitionId: entry.partitionId,
1608
- nextSegmentId,
1609
- segments,
1610
- vectors: normalizeVectors(entry.vectors, entry.partitionId)
1611
- });
1612
- }
1613
- return result;
1614
- }
1615
- function normalizeSegments(raw, partitionId) {
1616
- if (raw === void 0) {
1617
- return [];
1618
- }
1619
- if (!Array.isArray(raw)) {
1620
- throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest has malformed segment references", {
1621
- partitionId
1622
- });
1623
- }
1624
- if (raw.length > MAX_SEGMENTS_PER_PARTITION) {
1625
- throw new NarsilError(
1626
- ErrorCodes.PERSISTENCE_LOAD_FAILED,
1627
- `Segment manifest lists ${raw.length} segments for partition ${partitionId}, exceeding the maximum of ${MAX_SEGMENTS_PER_PARTITION}`,
1628
- { partitionId, segmentCount: raw.length, maximum: MAX_SEGMENTS_PER_PARTITION }
1629
- );
1630
- }
1631
- const seenIds = /* @__PURE__ */ new Set();
1632
- const result = [];
1633
- for (const segment of raw) {
1634
- if (!isSegmentId(segment?.id) || !isNonNegativeInteger(segment.docCount) || !isNonNegativeInteger(segment.tombstoneCount) || !isKey(segment.key)) {
1635
- throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest has an invalid segment reference", {
1636
- partitionId,
1637
- segment
1638
- });
1639
- }
1640
- if (seenIds.has(segment.id)) {
1641
- throw new NarsilError(
1642
- ErrorCodes.PERSISTENCE_LOAD_FAILED,
1643
- `Segment manifest reuses segment id ${segment.id} in partition ${partitionId}`,
1644
- { partitionId, segmentId: segment.id }
1645
- );
1646
- }
1647
- seenIds.add(segment.id);
1648
- result.push({
1649
- id: segment.id,
1650
- key: segment.key,
1651
- docCount: segment.docCount,
1652
- tombstoneCount: segment.tombstoneCount
1653
- });
1654
- }
1655
- return result;
1656
- }
1657
- function readNextSegmentId(value, segments, partitionId) {
1658
- if (!isSegmentId(value)) {
1659
- throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest has an invalid next segment id", {
1660
- partitionId,
1661
- nextSegmentId: value
1662
- });
1663
- }
1664
- for (const segment of segments) {
1665
- if (segment.id >= value) {
1666
- throw new NarsilError(
1667
- ErrorCodes.PERSISTENCE_LOAD_FAILED,
1668
- `Segment manifest next segment id ${value} does not exceed an existing segment id ${segment.id}`,
1669
- { partitionId, nextSegmentId: value, segmentId: segment.id }
1670
- );
1671
- }
1672
- }
1673
- return value;
1674
- }
1675
- function normalizeVectors(raw, partitionId) {
1676
- if (raw === void 0) {
1677
- return [];
1678
- }
1679
- if (!Array.isArray(raw)) {
1680
- throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest has malformed vector references", {
1681
- partitionId
1682
- });
1683
- }
1684
- const result = [];
1685
- for (const vector of raw) {
1686
- if (typeof vector?.fieldPath !== "string" || !isNonNegativeInteger(vector.generation) || !isKey(vector.key)) {
1687
- throw new NarsilError(ErrorCodes.PERSISTENCE_LOAD_FAILED, "Segment manifest has an invalid vector reference", {
1688
- partitionId,
1689
- vector
1690
- });
1691
- }
1692
- result.push({ fieldPath: vector.fieldPath, generation: vector.generation, key: vector.key });
1693
- }
1694
- return result;
1695
- }
1696
- function isNonNegativeInteger(value) {
1697
- return typeof value === "number" && Number.isInteger(value) && value >= 0;
1698
- }
1699
- function isSegmentId(value) {
1700
- return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= MAX_SEGMENT_ID;
1701
- }
1702
- function isKey(value) {
1703
- return typeof value === "string" && value.length > 0 && !value.includes("\0");
1733
+ function isKey(value) {
1734
+ return typeof value === "string" && value.length > 0 && !value.includes("\0");
1704
1735
  }
1705
1736
 
1706
1737
  // src/persistence/durability/segment/manifest.ts
@@ -2045,6 +2076,7 @@ async function loadSegmentedSnapshot(directory, indexName, manifest, deps) {
2045
2076
  break;
2046
2077
  }
2047
2078
  }
2079
+ const payloadsByField = /* @__PURE__ */ new Map();
2048
2080
  for (const partition of manifest.partitions) {
2049
2081
  if (partition.partitionId >= deps.manager.partitionCount) {
2050
2082
  throw new NarsilError(
@@ -2053,11 +2085,12 @@ async function loadSegmentedSnapshot(directory, indexName, manifest, deps) {
2053
2085
  { indexName, partitionId: partition.partitionId, partitionCount: deps.manager.partitionCount }
2054
2086
  );
2055
2087
  }
2056
- await loadPartition(directory, indexName, partition, deps);
2088
+ await loadPartition(directory, indexName, partition, deps, payloadsByField);
2057
2089
  }
2090
+ restoreVectorIndexes(payloadsByField, deps);
2058
2091
  return manifest.checkpoint;
2059
2092
  }
2060
- async function loadPartition(directory, indexName, partition, deps) {
2093
+ async function loadPartition(directory, indexName, partition, deps, payloadsByField) {
2061
2094
  const ordered = [];
2062
2095
  for (const segment of partition.segments) {
2063
2096
  ordered.push(await readSegmentContents(directory, segment.key));
@@ -2070,14 +2103,19 @@ async function loadPartition(directory, indexName, partition, deps) {
2070
2103
  });
2071
2104
  deps.manager.deserializePartition(partition.partitionId, merged);
2072
2105
  for (const vector of partition.vectors) {
2073
- await loadVectorSegment(directory, indexName, partition, vector.fieldPath, vector.key, deps);
2106
+ if (!deps.vectorIndexes.has(vector.fieldPath)) {
2107
+ continue;
2108
+ }
2109
+ const payload = await readVectorSegment(directory, indexName, partition, vector.fieldPath, vector.key);
2110
+ const collected = payloadsByField.get(vector.fieldPath);
2111
+ if (collected === void 0) {
2112
+ payloadsByField.set(vector.fieldPath, [payload]);
2113
+ } else {
2114
+ collected.push(payload);
2115
+ }
2074
2116
  }
2075
2117
  }
2076
- async function loadVectorSegment(directory, indexName, partition, fieldPath, key, deps) {
2077
- const vecIndex = deps.vectorIndexes.get(fieldPath);
2078
- if (vecIndex === void 0) {
2079
- return;
2080
- }
2118
+ async function readVectorSegment(directory, indexName, partition, fieldPath, key) {
2081
2119
  const bytes = await directory.read(key);
2082
2120
  if (bytes === null) {
2083
2121
  throw new NarsilError(
@@ -2086,12 +2124,31 @@ async function loadVectorSegment(directory, indexName, partition, fieldPath, key
2086
2124
  { indexName, partitionId: partition.partitionId, fieldPath, key }
2087
2125
  );
2088
2126
  }
2089
- vecIndex.deserialize(await decodeVectorSegment(bytes));
2090
- }
2091
- async function decodeVectorSegment(bytes) {
2092
2127
  const { payloadBytes } = await unpackEnvelopeBytes(bytes);
2093
2128
  return decode(payloadBytes);
2094
2129
  }
2130
+ function restoreVectorIndexes(payloadsByField, deps) {
2131
+ for (const [fieldPath, payloads] of payloadsByField) {
2132
+ const vecIndex = deps.vectorIndexes.get(fieldPath);
2133
+ if (vecIndex === void 0) {
2134
+ continue;
2135
+ }
2136
+ vecIndex.deserialize(payloads.length === 1 ? payloads[0] : mergePartitionPayloads(payloads));
2137
+ }
2138
+ }
2139
+ function mergePartitionPayloads(payloads) {
2140
+ const vectors = [];
2141
+ const graphs = [];
2142
+ for (const payload of payloads) {
2143
+ for (const entry of payload.vectors) {
2144
+ vectors.push(entry);
2145
+ }
2146
+ for (const graph of payload.graphs) {
2147
+ graphs.push(graph);
2148
+ }
2149
+ }
2150
+ return { fieldName: payloads[0].fieldName, dimension: payloads[0].dimension, vectors, graphs, sq8: null };
2151
+ }
2095
2152
  async function reclaimOrphanedSegments(directory, indexName, manifest) {
2096
2153
  const referenced = manifestReferencedKeys(manifest);
2097
2154
  for (const partition of manifest.partitions) {
@@ -2139,298 +2196,289 @@ function reconstructSchemaFromMetadata(metadata) {
2139
2196
  if (metadata.surfaceForms === true) {
2140
2197
  config.surfaceForms = true;
2141
2198
  }
2199
+ if (metadata.tokenizer !== void 0) {
2200
+ config.tokenizer = metadata.tokenizer;
2201
+ }
2202
+ if (metadata.stopWords !== void 0) {
2203
+ config.stopWords = metadata.stopWords;
2204
+ } else if (metadata.stopWordList !== void 0) {
2205
+ config.stopWords = new Set(metadata.stopWordList);
2206
+ }
2207
+ if (metadata.partitionLimits !== void 0) {
2208
+ config.partitions = metadata.partitionLimits;
2209
+ }
2210
+ if (metadata.defaultScoring !== void 0) {
2211
+ config.defaultScoring = metadata.defaultScoring;
2212
+ }
2213
+ if (metadata.trackPositions !== void 0) {
2214
+ config.trackPositions = metadata.trackPositions;
2215
+ }
2216
+ if (metadata.strict !== void 0) {
2217
+ config.strict = metadata.strict;
2218
+ }
2219
+ if (metadata.required !== void 0) {
2220
+ config.required = metadata.required;
2221
+ }
2222
+ if (metadata.vectorPromotion !== void 0) {
2223
+ config.vectorPromotion = metadata.vectorPromotion;
2224
+ }
2142
2225
  return config;
2143
2226
  }
2144
2227
 
2145
- // src/persistence/durability/segment/build-segment.ts
2146
- function buildSegmentFromEntries(input) {
2147
- const router = createPartitionRouter();
2148
- const vectorSink = /* @__PURE__ */ new Map();
2149
- const manager = createPartitionManager(input.indexName, input.config, input.language, router, 1, vectorSink);
2150
- const deleted = /* @__PURE__ */ new Set();
2151
- for (const entry of input.entries) {
2152
- if (entry.operation === "DELETE") {
2153
- deleted.add(entry.documentId);
2154
- applyDeleteEntry(entry, manager, vectorSink);
2155
- } else {
2156
- applyIndexEntry(entry, manager, input.vectorFieldPaths, vectorSink);
2157
- }
2228
+ // src/engine/vector-coordinator.ts
2229
+ function extractVectorFromDoc(document, fieldPath) {
2230
+ const value = getNestedValue(document, fieldPath);
2231
+ if (value === void 0 || value === null) return null;
2232
+ if (value instanceof Float32Array) return value;
2233
+ if (!Array.isArray(value)) return null;
2234
+ const arr = new Float32Array(value.length);
2235
+ for (let i = 0; i < value.length; i++) {
2236
+ const n = value[i];
2237
+ if (typeof n !== "number" || !Number.isFinite(n)) return null;
2238
+ arr[i] = n;
2158
2239
  }
2159
- const liveDocIds = collectLiveDocIds(manager);
2160
- const tombstones = [];
2161
- for (const docId of deleted) {
2162
- if (!liveDocIds.has(docId)) {
2163
- tombstones.push(docId);
2164
- }
2240
+ return arr;
2241
+ }
2242
+ function deleteNestedValue(obj, path) {
2243
+ if (!path.includes(".")) {
2244
+ delete obj[path];
2245
+ return;
2165
2246
  }
2166
- if (liveDocIds.size === 0 && tombstones.length === 0) {
2167
- return null;
2247
+ const segments = path.split(".");
2248
+ let current = obj;
2249
+ for (let i = 0; i < segments.length - 1; i++) {
2250
+ const next = current[segments[i]];
2251
+ if (next === null || next === void 0 || typeof next !== "object") return;
2252
+ current = next;
2168
2253
  }
2169
- return {
2170
- payload: manager.serializePartitionToBytes(0),
2171
- tombstones,
2172
- docCount: liveDocIds.size
2173
- };
2174
- }
2175
- function collectLiveDocIds(manager) {
2176
- return new Set(manager.getPartition(0).docIds());
2254
+ delete current[segments[segments.length - 1]];
2177
2255
  }
2178
-
2179
- // src/persistence/durability/segment/compaction.ts
2180
- async function compactPartitionSegments(input) {
2181
- const { segments, compactionThreshold } = input;
2182
- if (segments.length <= compactionThreshold) {
2183
- return { segments, nextSegmentId: input.nextSegmentId };
2184
- }
2185
- const windowLength = segments.length - compactionThreshold + 1;
2186
- const start = chooseCheapestWindow(segments, windowLength);
2187
- const window = segments.slice(start, start + windowLength);
2188
- const contents = [];
2189
- for (const ref of window) {
2190
- contents.push(await readSegmentContents(input.directory, ref.key));
2256
+ function vectorsEqual(a, b) {
2257
+ if (!a) return false;
2258
+ if (a.length !== b.length) return false;
2259
+ for (let i = 0; i < a.length; i++) {
2260
+ if (a[i] !== b[i]) return false;
2191
2261
  }
2192
- const merged = mergeTimeOrderedSegments(contents, {
2193
- indexName: input.indexName,
2194
- partitionId: input.partitionId,
2195
- totalPartitions: 1,
2196
- language: input.language.name
2197
- });
2198
- const liveDocIds = new Set(Object.keys(merged.documents));
2199
- const retainedTombstones = collectRetainedTombstones(contents, liveDocIds);
2200
- const payload = reserialize(input, merged);
2201
- const id = input.nextSegmentId;
2202
- const key = segmentKey(input.indexName, input.partitionId, id);
2203
- await persistSegmentFile(input.directory, key, payload, retainedTombstones);
2204
- const mergedRef = {
2205
- id,
2206
- key,
2207
- docCount: liveDocIds.size,
2208
- tombstoneCount: retainedTombstones.length
2209
- };
2210
- const nextSegments = [...segments.slice(0, start), mergedRef, ...segments.slice(start + windowLength)];
2211
- return { segments: nextSegments, nextSegmentId: id + 1 };
2262
+ return true;
2212
2263
  }
2213
- function chooseCheapestWindow(segments, windowLength) {
2214
- let prefix = 0;
2215
- for (let i = 0; i < windowLength; i += 1) {
2216
- prefix += segments[i].docCount;
2217
- }
2218
- let bestStart = 0;
2219
- let bestCost = prefix;
2220
- for (let start = 1; start + windowLength <= segments.length; start += 1) {
2221
- prefix += segments[start + windowLength - 1].docCount - segments[start - 1].docCount;
2222
- if (prefix < bestCost) {
2223
- bestCost = prefix;
2224
- bestStart = start;
2264
+ function validateVectorDimensions(vectors, vecIndexes) {
2265
+ for (const [fieldPath, vector] of vectors) {
2266
+ const vecIndex = vecIndexes.get(fieldPath);
2267
+ if (!vecIndex) continue;
2268
+ if (vector.length !== vecIndex.dimension) {
2269
+ throw new NarsilError(
2270
+ ErrorCodes.VECTOR_DIMENSION_MISMATCH,
2271
+ `Vector dimension mismatch for field "${fieldPath}": expected ${vecIndex.dimension}, got ${vector.length}`,
2272
+ { field: fieldPath, expected: vecIndex.dimension, received: vector.length }
2273
+ );
2225
2274
  }
2226
2275
  }
2227
- return bestStart;
2228
2276
  }
2229
- function collectRetainedTombstones(contents, liveDocIds) {
2230
- const retained = /* @__PURE__ */ new Set();
2231
- for (const segment of contents) {
2232
- for (const docId of segment.tombstones) {
2233
- if (!liveDocIds.has(docId)) {
2234
- retained.add(docId);
2235
- }
2277
+ function prepareDocumentVectors(document, vectorFieldPaths, vecIndexes) {
2278
+ if (vecIndexes.size === 0) {
2279
+ return { partitionDoc: document, extractedVectors: /* @__PURE__ */ new Map() };
2280
+ }
2281
+ const extractedVectors = /* @__PURE__ */ new Map();
2282
+ for (const fieldPath of vectorFieldPaths) {
2283
+ const vec = extractVectorFromDoc(document, fieldPath);
2284
+ if (vec) {
2285
+ extractedVectors.set(fieldPath, vec);
2236
2286
  }
2237
2287
  }
2238
- return [...retained];
2239
- }
2240
- function reserialize(input, merged) {
2241
- const router = createPartitionRouter();
2242
- const vectorSink = /* @__PURE__ */ new Map();
2243
- const manager = createPartitionManager(input.indexName, input.config, input.language, router, 1, vectorSink);
2244
- manager.deserializePartition(0, merged);
2245
- return manager.serializePartitionToBytes(0);
2246
- }
2247
- async function writePartitionVectors(input) {
2248
- if (input.vectorFields.size === 0) {
2249
- return [];
2288
+ if (extractedVectors.size === 0) {
2289
+ return { partitionDoc: document, extractedVectors };
2250
2290
  }
2251
- if (input.entries.length === 0) {
2252
- return input.priorVectors;
2291
+ const partitionDoc = shallowCopyExcluding(document, extractedVectors.keys());
2292
+ return { partitionDoc, extractedVectors };
2293
+ }
2294
+ function shallowCopyExcluding(document, fieldPaths) {
2295
+ const topLevelExcludes = /* @__PURE__ */ new Set();
2296
+ const nestedPaths = [];
2297
+ for (const path of fieldPaths) {
2298
+ if (!path.includes(".")) {
2299
+ topLevelExcludes.add(path);
2300
+ } else {
2301
+ nestedPaths.push(path);
2302
+ }
2253
2303
  }
2254
- const priorByField = /* @__PURE__ */ new Map();
2255
- for (const ref of input.priorVectors) {
2256
- priorByField.set(ref.fieldPath, ref);
2304
+ const result = {};
2305
+ for (const key of Object.keys(document)) {
2306
+ if (topLevelExcludes.has(key)) continue;
2307
+ result[key] = document[key];
2257
2308
  }
2258
- const vectorIndexes = /* @__PURE__ */ new Map();
2259
- for (const [fieldPath, dimension] of input.vectorFields) {
2260
- vectorIndexes.set(fieldPath, createVectorIndex(fieldPath, dimension, input.config.vectorPromotion));
2309
+ for (const path of nestedPaths) {
2310
+ const segments = path.split(".");
2311
+ const topKey = segments[0];
2312
+ if (!(topKey in result) || result[topKey] === null || typeof result[topKey] !== "object") continue;
2313
+ const source = document[topKey];
2314
+ let target = { ...source };
2315
+ result[topKey] = target;
2316
+ for (let i = 1; i < segments.length - 1; i++) {
2317
+ const seg = segments[i];
2318
+ const next = target[seg];
2319
+ if (next === null || next === void 0 || typeof next !== "object") break;
2320
+ const copy = { ...next };
2321
+ target[seg] = copy;
2322
+ target = copy;
2323
+ }
2324
+ delete target[segments[segments.length - 1]];
2261
2325
  }
2262
- const router = createPartitionRouter();
2263
- const manager = createPartitionManager(input.indexName, input.config, input.language, router, 1, vectorIndexes);
2264
- for (const ref of input.priorVectors) {
2265
- const vecIndex = vectorIndexes.get(ref.fieldPath);
2266
- if (vecIndex === void 0) {
2267
- continue;
2326
+ return result;
2327
+ }
2328
+ function insertDocumentVectors(docId, vectors, vecIndexes) {
2329
+ const insertedFields = [];
2330
+ try {
2331
+ for (const [fieldPath, vector] of vectors) {
2332
+ const vecIndex = vecIndexes.get(fieldPath);
2333
+ if (!vecIndex) continue;
2334
+ vecIndex.insert(docId, vector);
2335
+ insertedFields.push(fieldPath);
2268
2336
  }
2269
- const bytes = await input.directory.read(ref.key);
2270
- if (bytes !== null) {
2271
- const { payloadBytes } = await unpackEnvelopeBytes(bytes);
2272
- vecIndex.deserialize(decode(payloadBytes));
2337
+ } catch (err) {
2338
+ for (const fieldPath of insertedFields) {
2339
+ const vecIndex = vecIndexes.get(fieldPath);
2340
+ if (vecIndex) {
2341
+ vecIndex.remove(docId);
2342
+ }
2273
2343
  }
2344
+ throw err;
2274
2345
  }
2275
- for (const entry of input.entries) {
2276
- if (entry.operation === "DELETE") {
2277
- applyDeleteEntry(entry, manager, vectorIndexes);
2278
- } else {
2279
- applyIndexEntry(entry, manager, input.vectorFieldPaths, vectorIndexes);
2280
- }
2346
+ return insertedFields;
2347
+ }
2348
+ function removeDocumentVectors(docId, vecIndexes) {
2349
+ for (const [, vecIndex] of vecIndexes) {
2350
+ vecIndex.remove(docId);
2281
2351
  }
2282
- const result = [];
2283
- for (const [fieldPath, vecIndex] of vectorIndexes) {
2284
- const generation = (priorByField.get(fieldPath)?.generation ?? 0) + 1;
2285
- const key = vectorSegmentKey(input.indexName, input.partitionId, fieldPath, generation);
2286
- const parts = await packSnapshotEnvelopePartsRetrying(() => encode(vecIndex.serialize()));
2287
- await input.directory.atomicWrite(key, [parts.header, parts.payload]);
2288
- result.push({ fieldPath, generation, key });
2352
+ }
2353
+ function updateDocumentVectors(docId, vectors, vecIndexes) {
2354
+ const updatedFields = [];
2355
+ try {
2356
+ for (const [fieldPath, newVec] of vectors) {
2357
+ const vecIndex = vecIndexes.get(fieldPath);
2358
+ if (!vecIndex) continue;
2359
+ if (newVec === null) {
2360
+ if (vecIndex.has(docId)) {
2361
+ const oldVec2 = vecIndex.getVector(docId);
2362
+ vecIndex.remove(docId);
2363
+ updatedFields.push({ fieldPath, oldVec: oldVec2 });
2364
+ }
2365
+ continue;
2366
+ }
2367
+ const oldVec = vecIndex.getVector(docId);
2368
+ if (vectorsEqual(oldVec, newVec)) continue;
2369
+ vecIndex.remove(docId);
2370
+ try {
2371
+ vecIndex.insert(docId, newVec);
2372
+ updatedFields.push({ fieldPath, oldVec });
2373
+ } catch (err) {
2374
+ if (oldVec) {
2375
+ vecIndex.insert(docId, oldVec);
2376
+ }
2377
+ throw err;
2378
+ }
2379
+ }
2380
+ } catch (err) {
2381
+ for (const { fieldPath, oldVec } of updatedFields) {
2382
+ const vecIndex = vecIndexes.get(fieldPath);
2383
+ if (!vecIndex) continue;
2384
+ vecIndex.remove(docId);
2385
+ if (oldVec) {
2386
+ vecIndex.insert(docId, oldVec);
2387
+ }
2388
+ }
2389
+ throw err;
2289
2390
  }
2290
- return result;
2291
2391
  }
2292
2392
 
2293
- // src/persistence/durability/segment/write.ts
2294
- async function writeSegmentedCheckpoint(input) {
2295
- const { directory, metadata } = input;
2296
- const indexName = metadata.indexName;
2297
- const config = reconstructSchemaFromMetadata(metadata);
2298
- const language = getLanguage(config.language ?? "english");
2299
- const vectorFields = extractVectorFieldsFromSchema(config.schema);
2300
- const compactionThreshold = resolveCompactionThreshold(input.compactionThreshold);
2301
- const context = {
2302
- directory,
2303
- indexName,
2304
- config,
2305
- language,
2306
- vectorFields,
2307
- vectorFieldPaths: new Set(vectorFields.keys()),
2308
- compactionThreshold
2309
- };
2310
- const priorManifest = await readSegmentManifest(directory, indexName);
2311
- const checkpointByPartition = /* @__PURE__ */ new Map();
2312
- for (const target of input.targets) {
2313
- checkpointByPartition.set(target.partitionId, {
2314
- partitionId: target.partitionId,
2315
- lastSeqNo: target.lastSeqNo,
2316
- primaryTerm: target.primaryTerm
2317
- });
2393
+ // src/distribution/replication/replica.ts
2394
+ function validateReplicationEntry(entry, localPrimaryTerm, log) {
2395
+ if (!log.verifyChecksum(entry)) {
2396
+ return { valid: false, error: ErrorCodes.REPLICATION_ENTRY_CORRUPT };
2318
2397
  }
2319
- const partitions = [];
2320
- for (const target of input.targets) {
2321
- const priorPartition = priorManifest?.partitions.find((p) => p.partitionId === target.partitionId);
2322
- const priorSeqNo = snapshotCheckpointFor(priorManifest?.checkpoint ?? [], target.partitionId);
2323
- const entries = await collectWalEntriesInRange(
2324
- directory,
2325
- indexName,
2326
- target.partitionId,
2327
- priorSeqNo,
2328
- target.lastSeqNo
2329
- );
2330
- partitions.push(await writePartition(context, target.partitionId, priorPartition, entries));
2398
+ if (entry.primaryTerm < localPrimaryTerm) {
2399
+ return { valid: false, error: ErrorCodes.REPLICATION_TERM_MISMATCH };
2331
2400
  }
2332
- carryForwardUncheckpointedPartitions(priorManifest, input.targets, partitions, checkpointByPartition);
2333
- const manifest = {
2334
- schema: metadata.schema,
2335
- language: metadata.language,
2336
- checkpoint: [...checkpointByPartition.values()],
2337
- partitions
2338
- };
2339
- const parts = await encodeSegmentManifest(manifest);
2340
- await directory.atomicWrite(manifestKey(indexName), [parts.header, parts.payload]);
2341
- await collectGarbage(directory, indexName, priorManifest, manifest);
2401
+ return { valid: true };
2342
2402
  }
2343
- async function writePartition(context, partitionId, priorPartition, entries) {
2344
- let segments = priorPartition ? [...priorPartition.segments] : [];
2345
- let nextSegmentId = priorPartition?.nextSegmentId ?? 0;
2346
- const built = buildSegmentFromEntries({
2347
- indexName: context.indexName,
2348
- config: context.config,
2349
- language: context.language,
2350
- vectorFieldPaths: context.vectorFieldPaths,
2351
- entries
2352
- });
2353
- if (built !== null) {
2354
- const id = nextSegmentId;
2355
- const key = segmentKey(context.indexName, partitionId, id);
2356
- await persistSegmentFile(context.directory, key, built.payload, built.tombstones);
2357
- segments.push({ id, key, docCount: built.docCount, tombstoneCount: built.tombstones.length });
2358
- nextSegmentId = id + 1;
2403
+ function applyIndexEntry(entry, manager, vectorFieldPaths, vecIndexes) {
2404
+ if (entry.document === null) {
2405
+ return;
2359
2406
  }
2360
- if (segments.length > MAX_SEGMENTS_PER_PARTITION) {
2361
- throw new NarsilError(
2362
- ErrorCodes.PERSISTENCE_SAVE_FAILED,
2363
- `Partition ${partitionId} would exceed the maximum of ${MAX_SEGMENTS_PER_PARTITION} segments`,
2364
- { partitionId, segmentCount: segments.length, maximum: MAX_SEGMENTS_PER_PARTITION }
2365
- );
2407
+ const document = decode(entry.document);
2408
+ restoreVectorFields(document, vectorFieldPaths);
2409
+ if (manager.has(entry.documentId)) {
2410
+ removeDocumentVectors(entry.documentId, vecIndexes);
2411
+ manager.remove(entry.documentId);
2366
2412
  }
2367
- const compacted = await compactPartitionSegments({
2368
- directory: context.directory,
2369
- indexName: context.indexName,
2370
- partitionId,
2371
- config: context.config,
2372
- language: context.language,
2373
- segments,
2374
- nextSegmentId,
2375
- compactionThreshold: context.compactionThreshold
2376
- });
2377
- segments = compacted.segments;
2378
- nextSegmentId = compacted.nextSegmentId;
2379
- const vectors = await writePartitionVectors({
2380
- directory: context.directory,
2381
- indexName: context.indexName,
2382
- partitionId,
2383
- config: context.config,
2384
- language: context.language,
2385
- vectorFields: context.vectorFields,
2386
- vectorFieldPaths: context.vectorFieldPaths,
2387
- entries,
2388
- priorVectors: priorPartition?.vectors ?? []
2389
- });
2390
- return { partitionId, nextSegmentId, segments, vectors };
2391
- }
2392
- function carryForwardUncheckpointedPartitions(priorManifest, targets, partitions, checkpointByPartition) {
2393
- if (priorManifest === null) {
2394
- return;
2413
+ const { partitionDoc, extractedVectors } = prepareDocumentVectors(document, vectorFieldPaths, vecIndexes);
2414
+ manager.insert(entry.documentId, partitionDoc);
2415
+ try {
2416
+ insertDocumentVectors(entry.documentId, extractedVectors, vecIndexes);
2417
+ } catch (err) {
2418
+ manager.remove(entry.documentId);
2419
+ throw err;
2395
2420
  }
2396
- const covered = new Set(targets.map((t) => t.partitionId));
2397
- for (const priorPartition of priorManifest.partitions) {
2398
- if (covered.has(priorPartition.partitionId)) {
2399
- continue;
2400
- }
2401
- partitions.push(priorPartition);
2402
- const priorCheckpoint = priorManifest.checkpoint.find((c) => c.partitionId === priorPartition.partitionId);
2403
- if (priorCheckpoint !== void 0 && !checkpointByPartition.has(priorPartition.partitionId)) {
2404
- checkpointByPartition.set(priorPartition.partitionId, priorCheckpoint);
2421
+ for (const fieldPath of extractedVectors.keys()) {
2422
+ const vecIndex = vecIndexes.get(fieldPath);
2423
+ if (vecIndex) {
2424
+ vecIndex.scheduleBuild();
2405
2425
  }
2406
2426
  }
2407
2427
  }
2408
- function resolveCompactionThreshold(configured) {
2409
- if (!Number.isInteger(configured) || configured <= 0 || configured > MAX_SEGMENTS_PER_PARTITION) {
2410
- return DEFAULT_COMPACTION_THRESHOLD;
2428
+ function applyDeleteEntry(entry, manager, vecIndexes) {
2429
+ removeDocumentVectors(entry.documentId, vecIndexes);
2430
+ if (!manager.has(entry.documentId)) {
2431
+ return;
2411
2432
  }
2412
- return configured;
2433
+ manager.remove(entry.documentId);
2413
2434
  }
2414
- async function collectGarbage(directory, indexName, priorManifest, manifest) {
2415
- const referenced = manifestReferencedKeys(manifest);
2416
- const partitionIds = /* @__PURE__ */ new Set();
2417
- for (const partition of manifest.partitions) {
2418
- partitionIds.add(partition.partitionId);
2435
+ function restoreVectorFields(document, vectorFieldPaths) {
2436
+ for (const fieldPath of vectorFieldPaths) {
2437
+ const value = getNestedValue(document, fieldPath);
2438
+ if (value instanceof Uint8Array && !(value instanceof Float32Array)) {
2439
+ if (value.byteLength % 4 !== 0) {
2440
+ throw new Error(
2441
+ `Vector field "${fieldPath}" has invalid byte length ${value.byteLength} (must be divisible by 4)`
2442
+ );
2443
+ }
2444
+ const elementCount = value.byteLength / 4;
2445
+ const float32 = new Float32Array(elementCount);
2446
+ const dataView = new DataView(value.buffer, value.byteOffset, value.byteLength);
2447
+ for (let i = 0; i < elementCount; i += 1) {
2448
+ float32[i] = dataView.getFloat32(i * 4, true);
2449
+ }
2450
+ setNestedValue(document, fieldPath, float32);
2451
+ }
2419
2452
  }
2420
- if (priorManifest !== null) {
2421
- for (const partition of priorManifest.partitions) {
2422
- partitionIds.add(partition.partitionId);
2453
+ }
2454
+ var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
2455
+ function setNestedValue(obj, path, value) {
2456
+ if (!path.includes(".")) {
2457
+ if (FORBIDDEN_KEYS.has(path)) {
2458
+ return;
2423
2459
  }
2460
+ obj[path] = value;
2461
+ return;
2424
2462
  }
2425
- for (const partitionId of partitionIds) {
2426
- const prefix = segmentPrefix(indexName, partitionId);
2427
- for (const key of await directory.list(prefix)) {
2428
- if (!referenced.has(key)) {
2429
- await directory.remove(key);
2430
- }
2463
+ const segments = path.split(".");
2464
+ let current = obj;
2465
+ for (let i = 0; i < segments.length - 1; i += 1) {
2466
+ const segment = segments[i];
2467
+ if (segment === void 0 || FORBIDDEN_KEYS.has(segment)) {
2468
+ return;
2469
+ }
2470
+ let next = current[segment];
2471
+ if (next === null || next === void 0 || typeof next !== "object") {
2472
+ next = {};
2473
+ current[segment] = next;
2431
2474
  }
2475
+ current = next;
2432
2476
  }
2433
- await directory.remove(legacySnapshotKey(indexName));
2477
+ const finalSegment = segments.at(-1);
2478
+ if (finalSegment === void 0 || FORBIDDEN_KEYS.has(finalSegment)) {
2479
+ return;
2480
+ }
2481
+ current[finalSegment] = value;
2434
2482
  }
2435
2483
 
2436
2484
  // src/distribution/transport/types.ts
@@ -2742,225 +2790,570 @@ function readTailBeyondFrontier(data, durableByteLength, lastDurableSeqNo, maxRe
2742
2790
  return { entries, cleanEnd };
2743
2791
  }
2744
2792
 
2745
- // src/persistence/durability/recovery.ts
2746
- async function listPersistedIndexes(directory) {
2747
- const metaKeys = await directory.list("");
2748
- const names = [];
2749
- for (const key of metaKeys) {
2750
- if (key.endsWith("/meta")) {
2751
- names.push(key.slice(0, -"/meta".length));
2793
+ // src/persistence/durability/recovery.ts
2794
+ async function listPersistedIndexes(directory) {
2795
+ const metaKeys = await directory.list("");
2796
+ const names = [];
2797
+ for (const key of metaKeys) {
2798
+ if (key.endsWith("/meta")) {
2799
+ names.push(key.slice(0, -"/meta".length));
2800
+ }
2801
+ }
2802
+ return names;
2803
+ }
2804
+ async function loadMetadata(directory, indexName) {
2805
+ const bytes = await directory.read(`${indexName}/meta`);
2806
+ if (bytes === null) {
2807
+ return null;
2808
+ }
2809
+ const { metadata } = await readMetadataEnvelope(bytes);
2810
+ return metadata;
2811
+ }
2812
+ async function loadSnapshotBundleBytes(bytes, deps) {
2813
+ const bundle = await decodeSnapshotBundle(bytes);
2814
+ while (deps.manager.partitionCount < bundle.partitions.length) {
2815
+ deps.manager.addPartition();
2816
+ }
2817
+ if (bundle.partitions.length > 0) {
2818
+ deps.manager.trimPartitions(bundle.partitions.length);
2819
+ }
2820
+ for (let i = 0; i < bundle.partitions.length; i += 1) {
2821
+ deps.manager.deserializePartition(i, deserializePayloadV2(bundle.partitions[i]));
2822
+ }
2823
+ for (const [fieldPath, payload] of Object.entries(bundle.vectorIndexes)) {
2824
+ const vecIndex = deps.vectorIndexes.get(fieldPath);
2825
+ if (vecIndex) {
2826
+ vecIndex.deserialize(payload);
2827
+ }
2828
+ }
2829
+ return bundle.checkpoint;
2830
+ }
2831
+ async function loadSnapshot(directory, indexName, deps) {
2832
+ const manifest = await readSegmentManifest(directory, indexName);
2833
+ if (manifest !== null) {
2834
+ return loadSegmentedSnapshot(directory, indexName, manifest, deps);
2835
+ }
2836
+ const bytes = await directory.read(`${indexName}/snapshot`);
2837
+ if (bytes === null) {
2838
+ return [];
2839
+ }
2840
+ return loadSnapshotBundleBytes(bytes, deps);
2841
+ }
2842
+ function segmentStartSeqNo(key, prefix) {
2843
+ if (!key.startsWith(prefix)) {
2844
+ return null;
2845
+ }
2846
+ const tail = key.slice(prefix.length);
2847
+ if (!/^\d{16}$/.test(tail)) {
2848
+ return null;
2849
+ }
2850
+ const value = Number.parseInt(tail, 10);
2851
+ return Number.isSafeInteger(value) ? value : null;
2852
+ }
2853
+ async function readWalSegments(directory, indexName, partitionId) {
2854
+ const prefix = `${indexName}/wal/${partitionId}/`;
2855
+ const markerBytes = await directory.read(`${prefix}commit`);
2856
+ const marker = markerBytes === null ? null : readCommitMarker(markerBytes);
2857
+ if (marker === null) {
2858
+ return null;
2859
+ }
2860
+ const segments = await collectSegments(directory, prefix);
2861
+ const durableEntries = [];
2862
+ let highestReadFromWal = 0;
2863
+ let activeTail = null;
2864
+ for (const { key, startSeqNo } of segments) {
2865
+ if (startSeqNo > marker.state.activeSegmentSeqNo) {
2866
+ continue;
2867
+ }
2868
+ const bytes = await directory.read(key);
2869
+ if (bytes === null) {
2870
+ continue;
2871
+ }
2872
+ if (startSeqNo < marker.state.activeSegmentSeqNo) {
2873
+ const header = checkSegmentHeader(bytes);
2874
+ if (!header.ok) {
2875
+ throw new NarsilError(
2876
+ ErrorCodes.PERSISTENCE_WAL_CORRUPT,
2877
+ `Sealed WAL segment header invalid: ${header.reason}`,
2878
+ {
2879
+ key,
2880
+ reason: header.reason
2881
+ }
2882
+ );
2883
+ }
2884
+ for (const entry of readDurableRegion(bytes, bytes.length)) {
2885
+ durableEntries.push(entry);
2886
+ if (entry.seqNo > highestReadFromWal) {
2887
+ highestReadFromWal = entry.seqNo;
2888
+ }
2889
+ }
2890
+ continue;
2891
+ }
2892
+ for (const entry of readDurableRegion(bytes, marker.state.durableByteLength)) {
2893
+ durableEntries.push(entry);
2894
+ if (entry.seqNo > highestReadFromWal) {
2895
+ highestReadFromWal = entry.seqNo;
2896
+ }
2897
+ }
2898
+ const tail = readTailBeyondFrontier(
2899
+ bytes,
2900
+ marker.state.durableByteLength,
2901
+ Math.max(highestReadFromWal, marker.state.highestDurableSeqNo)
2902
+ );
2903
+ activeTail = { key, entries: tail.entries, cleanEnd: tail.cleanEnd, segmentLength: bytes.length };
2904
+ }
2905
+ return { marker, segments, durableEntries, activeTail, highestReadFromWal };
2906
+ }
2907
+ async function replayWal(directory, indexName, partitionId, fromSeqNoExclusive, deps) {
2908
+ const read = await readWalSegments(directory, indexName, partitionId);
2909
+ if (read === null) {
2910
+ return { highestSeqNo: fromSeqNoExclusive, highestPrimaryTerm: 0 };
2911
+ }
2912
+ const { marker, segments, durableEntries, activeTail, highestReadFromWal } = read;
2913
+ if (Math.max(fromSeqNoExclusive, highestReadFromWal) < marker.state.highestDurableSeqNo) {
2914
+ throw new NarsilError(
2915
+ ErrorCodes.PERSISTENCE_WAL_CORRUPT,
2916
+ "A durable WAL record is missing: the highest recovered seqNo is below the commit marker",
2917
+ {
2918
+ indexName,
2919
+ partitionId,
2920
+ highestRead: highestReadFromWal,
2921
+ highestDurable: marker.state.highestDurableSeqNo
2922
+ }
2923
+ );
2924
+ }
2925
+ let highestSeqNo = fromSeqNoExclusive;
2926
+ let highestPrimaryTerm = 0;
2927
+ const replay = (entries) => {
2928
+ for (const entry of entries) {
2929
+ if (entry.seqNo <= highestSeqNo) {
2930
+ continue;
2931
+ }
2932
+ applyEntry(entry, deps);
2933
+ highestSeqNo = entry.seqNo;
2934
+ if (entry.primaryTerm > highestPrimaryTerm) {
2935
+ highestPrimaryTerm = entry.primaryTerm;
2936
+ }
2937
+ }
2938
+ };
2939
+ replay(durableEntries);
2940
+ if (activeTail !== null) {
2941
+ replay(activeTail.entries);
2942
+ }
2943
+ await deleteOrphanSegments(directory, segments, marker.state.activeSegmentSeqNo);
2944
+ if (activeTail !== null && activeTail.cleanEnd < activeTail.segmentLength) {
2945
+ await truncateSegmentTail(directory, activeTail.key, activeTail.cleanEnd);
2946
+ }
2947
+ return { highestSeqNo, highestPrimaryTerm };
2948
+ }
2949
+ async function collectSegments(directory, prefix) {
2950
+ const keys = await directory.list(prefix);
2951
+ const refs = [];
2952
+ for (const key of keys) {
2953
+ const startSeqNo = segmentStartSeqNo(key, prefix);
2954
+ if (startSeqNo !== null) {
2955
+ refs.push({ key, startSeqNo });
2956
+ }
2957
+ }
2958
+ refs.sort((a, b) => a.startSeqNo - b.startSeqNo);
2959
+ return refs;
2960
+ }
2961
+ async function deleteOrphanSegments(directory, segments, activeSegmentSeqNo) {
2962
+ for (const { key, startSeqNo } of segments) {
2963
+ if (startSeqNo > activeSegmentSeqNo) {
2964
+ await directory.remove(key);
2965
+ }
2966
+ }
2967
+ }
2968
+ async function truncateSegmentTail(directory, key, durableByteLength) {
2969
+ const cleanLength = Math.max(durableByteLength, SEGMENT_HEADER_SIZE);
2970
+ const handle = await directory.appendHandle(key);
2971
+ try {
2972
+ const currentSize = await handle.size();
2973
+ if (cleanLength < currentSize) {
2974
+ await handle.truncate(cleanLength);
2975
+ }
2976
+ } finally {
2977
+ await handle.close();
2978
+ }
2979
+ }
2980
+ function applyEntry(entry, deps) {
2981
+ if (entry.operation === "DELETE") {
2982
+ applyDeleteEntry(entry, deps.manager, deps.vectorIndexes);
2983
+ return;
2984
+ }
2985
+ applyIndexEntry(entry, deps.manager, deps.vectorFieldPaths, deps.vectorIndexes);
2986
+ }
2987
+ function snapshotCheckpointFor(checkpoint, partitionId) {
2988
+ return checkpointLastSeqNo(checkpoint, partitionId);
2989
+ }
2990
+ async function collectWalEntriesInRange(directory, indexName, partitionId, fromSeqNoExclusive, upToSeqNoInclusive) {
2991
+ const read = await readWalSegments(directory, indexName, partitionId);
2992
+ if (read === null) {
2993
+ return [];
2994
+ }
2995
+ if (Math.max(fromSeqNoExclusive, read.highestReadFromWal) < read.marker.state.highestDurableSeqNo) {
2996
+ throw new NarsilError(
2997
+ ErrorCodes.PERSISTENCE_WAL_CORRUPT,
2998
+ "A durable WAL record is missing: the highest recovered seqNo is below the commit marker",
2999
+ {
3000
+ indexName,
3001
+ partitionId,
3002
+ highestRead: read.highestReadFromWal,
3003
+ highestDurable: read.marker.state.highestDurableSeqNo
3004
+ }
3005
+ );
3006
+ }
3007
+ const entries = [];
3008
+ for (const entry of read.durableEntries) {
3009
+ if (entry.seqNo <= fromSeqNoExclusive || entry.seqNo > upToSeqNoInclusive) {
3010
+ continue;
3011
+ }
3012
+ entries.push(entry);
3013
+ }
3014
+ return entries;
3015
+ }
3016
+
3017
+ // src/persistence/durability/segment/build-segment.ts
3018
+ function buildSegmentFromEntries(input) {
3019
+ const router = createPartitionRouter();
3020
+ const vectorSink = /* @__PURE__ */ new Map();
3021
+ const manager = createPartitionManager(input.indexName, input.config, input.language, router, 1, vectorSink);
3022
+ const deleted = /* @__PURE__ */ new Set();
3023
+ for (const entry of input.entries) {
3024
+ if (entry.operation === "DELETE") {
3025
+ deleted.add(entry.documentId);
3026
+ applyDeleteEntry(entry, manager, vectorSink);
3027
+ } else {
3028
+ applyIndexEntry(entry, manager, input.vectorFieldPaths, vectorSink);
2752
3029
  }
2753
3030
  }
2754
- return names;
2755
- }
2756
- async function loadMetadata(directory, indexName) {
2757
- const bytes = await directory.read(`${indexName}/meta`);
2758
- if (bytes === null) {
3031
+ const liveDocIds = collectLiveDocIds(manager);
3032
+ const tombstones = [];
3033
+ for (const docId of deleted) {
3034
+ if (!liveDocIds.has(docId)) {
3035
+ tombstones.push(docId);
3036
+ }
3037
+ }
3038
+ if (liveDocIds.size === 0 && tombstones.length === 0) {
2759
3039
  return null;
2760
3040
  }
2761
- const { metadata } = await readMetadataEnvelope(bytes);
2762
- return metadata;
3041
+ return {
3042
+ payload: manager.serializePartitionToBytes(0),
3043
+ tombstones,
3044
+ docCount: liveDocIds.size
3045
+ };
2763
3046
  }
2764
- async function loadSnapshotBundleBytes(bytes, deps) {
2765
- const bundle = await decodeSnapshotBundle(bytes);
2766
- while (deps.manager.partitionCount < bundle.partitions.length) {
2767
- deps.manager.addPartition();
3047
+ function collectLiveDocIds(manager) {
3048
+ return new Set(manager.getPartition(0).docIds());
3049
+ }
3050
+
3051
+ // src/persistence/durability/segment/compaction.ts
3052
+ async function compactPartitionSegments(input) {
3053
+ const { segments, compactionThreshold } = input;
3054
+ if (segments.length <= compactionThreshold) {
3055
+ return { segments, nextSegmentId: input.nextSegmentId };
2768
3056
  }
2769
- for (let i = 0; i < bundle.partitions.length; i += 1) {
2770
- deps.manager.deserializePartition(i, deserializePayloadV2(bundle.partitions[i]));
3057
+ const windowLength = segments.length - compactionThreshold + 1;
3058
+ const start = chooseCheapestWindow(segments, windowLength);
3059
+ const window = segments.slice(start, start + windowLength);
3060
+ const contents = [];
3061
+ for (const ref of window) {
3062
+ contents.push(await readSegmentContents(input.directory, ref.key));
2771
3063
  }
2772
- for (const [fieldPath, payload] of Object.entries(bundle.vectorIndexes)) {
2773
- const vecIndex = deps.vectorIndexes.get(fieldPath);
2774
- if (vecIndex) {
2775
- vecIndex.deserialize(payload);
3064
+ const merged = mergeTimeOrderedSegments(contents, {
3065
+ indexName: input.indexName,
3066
+ partitionId: input.partitionId,
3067
+ totalPartitions: 1,
3068
+ language: input.language.name
3069
+ });
3070
+ const liveDocIds = new Set(Object.keys(merged.documents));
3071
+ const retainedTombstones = collectRetainedTombstones(contents, liveDocIds);
3072
+ const payload = reserialize(input, merged);
3073
+ const id = input.nextSegmentId;
3074
+ const key = segmentKey(input.indexName, input.partitionId, id);
3075
+ await persistSegmentFile(input.directory, key, payload, retainedTombstones);
3076
+ const mergedRef = {
3077
+ id,
3078
+ key,
3079
+ docCount: liveDocIds.size,
3080
+ tombstoneCount: retainedTombstones.length
3081
+ };
3082
+ const nextSegments = [...segments.slice(0, start), mergedRef, ...segments.slice(start + windowLength)];
3083
+ return { segments: nextSegments, nextSegmentId: id + 1 };
3084
+ }
3085
+ function chooseCheapestWindow(segments, windowLength) {
3086
+ let prefix = 0;
3087
+ for (let i = 0; i < windowLength; i += 1) {
3088
+ prefix += segments[i].docCount;
3089
+ }
3090
+ let bestStart = 0;
3091
+ let bestCost = prefix;
3092
+ for (let start = 1; start + windowLength <= segments.length; start += 1) {
3093
+ prefix += segments[start + windowLength - 1].docCount - segments[start - 1].docCount;
3094
+ if (prefix < bestCost) {
3095
+ bestCost = prefix;
3096
+ bestStart = start;
2776
3097
  }
2777
3098
  }
2778
- return bundle.checkpoint;
3099
+ return bestStart;
2779
3100
  }
2780
- async function loadSnapshot(directory, indexName, deps) {
2781
- const manifest = await readSegmentManifest(directory, indexName);
2782
- if (manifest !== null) {
2783
- return loadSegmentedSnapshot(directory, indexName, manifest, deps);
3101
+ function collectRetainedTombstones(contents, liveDocIds) {
3102
+ const retained = /* @__PURE__ */ new Set();
3103
+ for (const segment of contents) {
3104
+ for (const docId of segment.tombstones) {
3105
+ if (!liveDocIds.has(docId)) {
3106
+ retained.add(docId);
3107
+ }
3108
+ }
2784
3109
  }
2785
- const bytes = await directory.read(`${indexName}/snapshot`);
2786
- if (bytes === null) {
3110
+ return [...retained];
3111
+ }
3112
+ function reserialize(input, merged) {
3113
+ const router = createPartitionRouter();
3114
+ const vectorSink = /* @__PURE__ */ new Map();
3115
+ const manager = createPartitionManager(input.indexName, input.config, input.language, router, 1, vectorSink);
3116
+ manager.deserializePartition(0, merged);
3117
+ return manager.serializePartitionToBytes(0);
3118
+ }
3119
+ async function writePartitionVectors(input) {
3120
+ if (input.vectorFields.size === 0) {
2787
3121
  return [];
2788
3122
  }
2789
- return loadSnapshotBundleBytes(bytes, deps);
2790
- }
2791
- function segmentStartSeqNo(key, prefix) {
2792
- if (!key.startsWith(prefix)) {
2793
- return null;
3123
+ if (input.entries.length === 0) {
3124
+ return input.priorVectors;
2794
3125
  }
2795
- const tail = key.slice(prefix.length);
2796
- if (!/^\d{16}$/.test(tail)) {
2797
- return null;
3126
+ const priorByField = /* @__PURE__ */ new Map();
3127
+ for (const ref of input.priorVectors) {
3128
+ priorByField.set(ref.fieldPath, ref);
2798
3129
  }
2799
- const value = Number.parseInt(tail, 10);
2800
- return Number.isSafeInteger(value) ? value : null;
2801
- }
2802
- async function readWalSegments(directory, indexName, partitionId) {
2803
- const prefix = `${indexName}/wal/${partitionId}/`;
2804
- const markerBytes = await directory.read(`${prefix}commit`);
2805
- const marker = markerBytes === null ? null : readCommitMarker(markerBytes);
2806
- if (marker === null) {
2807
- return null;
3130
+ const vectorIndexes = /* @__PURE__ */ new Map();
3131
+ for (const [fieldPath, dimension] of input.vectorFields) {
3132
+ vectorIndexes.set(fieldPath, createVectorIndex(fieldPath, dimension, input.config.vectorPromotion));
2808
3133
  }
2809
- const segments = await collectSegments(directory, prefix);
2810
- const durableEntries = [];
2811
- let highestReadFromWal = 0;
2812
- let activeTail = null;
2813
- for (const { key, startSeqNo } of segments) {
2814
- if (startSeqNo > marker.state.activeSegmentSeqNo) {
2815
- continue;
2816
- }
2817
- const bytes = await directory.read(key);
2818
- if (bytes === null) {
3134
+ const router = createPartitionRouter();
3135
+ const manager = createPartitionManager(input.indexName, input.config, input.language, router, 1, vectorIndexes);
3136
+ for (const ref of input.priorVectors) {
3137
+ const vecIndex = vectorIndexes.get(ref.fieldPath);
3138
+ if (vecIndex === void 0) {
2819
3139
  continue;
2820
3140
  }
2821
- if (startSeqNo < marker.state.activeSegmentSeqNo) {
2822
- const header = checkSegmentHeader(bytes);
2823
- if (!header.ok) {
2824
- throw new NarsilError(
2825
- ErrorCodes.PERSISTENCE_WAL_CORRUPT,
2826
- `Sealed WAL segment header invalid: ${header.reason}`,
2827
- {
2828
- key,
2829
- reason: header.reason
2830
- }
2831
- );
2832
- }
2833
- for (const entry of readDurableRegion(bytes, bytes.length)) {
2834
- durableEntries.push(entry);
2835
- if (entry.seqNo > highestReadFromWal) {
2836
- highestReadFromWal = entry.seqNo;
2837
- }
2838
- }
2839
- continue;
3141
+ const bytes = await input.directory.read(ref.key);
3142
+ if (bytes !== null) {
3143
+ const { payloadBytes } = await unpackEnvelopeBytes(bytes);
3144
+ vecIndex.deserialize(decode(payloadBytes));
2840
3145
  }
2841
- for (const entry of readDurableRegion(bytes, marker.state.durableByteLength)) {
2842
- durableEntries.push(entry);
2843
- if (entry.seqNo > highestReadFromWal) {
2844
- highestReadFromWal = entry.seqNo;
2845
- }
3146
+ }
3147
+ for (const entry of input.entries) {
3148
+ if (entry.operation === "DELETE") {
3149
+ applyDeleteEntry(entry, manager, vectorIndexes);
3150
+ } else {
3151
+ applyIndexEntry(entry, manager, input.vectorFieldPaths, vectorIndexes);
2846
3152
  }
2847
- const tail = readTailBeyondFrontier(
2848
- bytes,
2849
- marker.state.durableByteLength,
2850
- Math.max(highestReadFromWal, marker.state.highestDurableSeqNo)
3153
+ }
3154
+ const result = [];
3155
+ for (const [fieldPath, vecIndex] of vectorIndexes) {
3156
+ const generation = (priorByField.get(fieldPath)?.generation ?? 0) + 1;
3157
+ const key = vectorSegmentKey(input.indexName, input.partitionId, fieldPath, generation);
3158
+ const parts = await packSnapshotEnvelopePartsRetrying(() => encode(vecIndex.serialize()));
3159
+ await input.directory.atomicWrite(key, [parts.header, parts.payload]);
3160
+ result.push({ fieldPath, generation, key });
3161
+ }
3162
+ return result;
3163
+ }
3164
+
3165
+ // src/persistence/durability/segment/write.ts
3166
+ async function writeSegmentedCheckpoint(input) {
3167
+ const { directory, metadata } = input;
3168
+ const indexName = metadata.indexName;
3169
+ const config = reconstructSchemaFromMetadata(metadata);
3170
+ const language = getLanguage(config.language ?? "english");
3171
+ const vectorFields = extractVectorFieldsFromSchema(config.schema);
3172
+ const compactionThreshold = resolveCompactionThreshold(input.compactionThreshold);
3173
+ const context = {
3174
+ directory,
3175
+ indexName,
3176
+ config,
3177
+ language,
3178
+ vectorFields,
3179
+ vectorFieldPaths: new Set(vectorFields.keys()),
3180
+ compactionThreshold
3181
+ };
3182
+ const priorManifest = await readSegmentManifest(directory, indexName);
3183
+ const checkpointByPartition = /* @__PURE__ */ new Map();
3184
+ for (const target of input.targets) {
3185
+ checkpointByPartition.set(target.partitionId, {
3186
+ partitionId: target.partitionId,
3187
+ lastSeqNo: target.lastSeqNo,
3188
+ primaryTerm: target.primaryTerm
3189
+ });
3190
+ }
3191
+ const partitions = [];
3192
+ for (const target of input.targets) {
3193
+ const priorPartition = priorManifest?.partitions.find((p) => p.partitionId === target.partitionId);
3194
+ const priorSeqNo = snapshotCheckpointFor(priorManifest?.checkpoint ?? [], target.partitionId);
3195
+ const entries = await collectWalEntriesInRange(
3196
+ directory,
3197
+ indexName,
3198
+ target.partitionId,
3199
+ priorSeqNo,
3200
+ target.lastSeqNo
3201
+ );
3202
+ partitions.push(
3203
+ input.wholePartitionPayload === void 0 ? await writePartition(context, target.partitionId, priorPartition, entries) : await writeWholePartition(
3204
+ context,
3205
+ target.partitionId,
3206
+ priorPartition,
3207
+ input.wholePartitionPayload(target.partitionId),
3208
+ entries
3209
+ )
2851
3210
  );
2852
- activeTail = { key, entries: tail.entries, cleanEnd: tail.cleanEnd, segmentLength: bytes.length };
2853
3211
  }
2854
- return { marker, segments, durableEntries, activeTail, highestReadFromWal };
3212
+ carryForwardUncheckpointedPartitions(priorManifest, input.targets, partitions, checkpointByPartition);
3213
+ const manifest = {
3214
+ schema: metadata.schema,
3215
+ language: metadata.language,
3216
+ checkpoint: [...checkpointByPartition.values()],
3217
+ partitions
3218
+ };
3219
+ const parts = await encodeSegmentManifest(manifest);
3220
+ await directory.atomicWrite(manifestKey(indexName), [parts.header, parts.payload]);
3221
+ await collectGarbage(directory, indexName, priorManifest, manifest);
2855
3222
  }
2856
- async function replayWal(directory, indexName, partitionId, fromSeqNoExclusive, deps) {
2857
- const read = await readWalSegments(directory, indexName, partitionId);
2858
- if (read === null) {
2859
- return { highestSeqNo: fromSeqNoExclusive, highestPrimaryTerm: 0 };
3223
+ async function writePartition(context, partitionId, priorPartition, entries) {
3224
+ let segments = priorPartition ? [...priorPartition.segments] : [];
3225
+ let nextSegmentId = priorPartition?.nextSegmentId ?? 0;
3226
+ const built = buildSegmentFromEntries({
3227
+ indexName: context.indexName,
3228
+ config: context.config,
3229
+ language: context.language,
3230
+ vectorFieldPaths: context.vectorFieldPaths,
3231
+ entries
3232
+ });
3233
+ if (built !== null) {
3234
+ const id = nextSegmentId;
3235
+ const key = segmentKey(context.indexName, partitionId, id);
3236
+ await persistSegmentFile(context.directory, key, built.payload, built.tombstones);
3237
+ segments.push({ id, key, docCount: built.docCount, tombstoneCount: built.tombstones.length });
3238
+ nextSegmentId = id + 1;
2860
3239
  }
2861
- const { marker, segments, durableEntries, activeTail, highestReadFromWal } = read;
2862
- if (Math.max(fromSeqNoExclusive, highestReadFromWal) < marker.state.highestDurableSeqNo) {
3240
+ if (segments.length > MAX_SEGMENTS_PER_PARTITION) {
2863
3241
  throw new NarsilError(
2864
- ErrorCodes.PERSISTENCE_WAL_CORRUPT,
2865
- "A durable WAL record is missing: the highest recovered seqNo is below the commit marker",
2866
- {
2867
- indexName,
2868
- partitionId,
2869
- highestRead: highestReadFromWal,
2870
- highestDurable: marker.state.highestDurableSeqNo
2871
- }
3242
+ ErrorCodes.PERSISTENCE_SAVE_FAILED,
3243
+ `Partition ${partitionId} would exceed the maximum of ${MAX_SEGMENTS_PER_PARTITION} segments`,
3244
+ { partitionId, segmentCount: segments.length, maximum: MAX_SEGMENTS_PER_PARTITION }
2872
3245
  );
2873
3246
  }
2874
- let highestSeqNo = fromSeqNoExclusive;
2875
- let highestPrimaryTerm = 0;
2876
- const replay = (entries) => {
2877
- for (const entry of entries) {
2878
- if (entry.seqNo <= highestSeqNo) {
2879
- continue;
2880
- }
2881
- applyEntry(entry, deps);
2882
- highestSeqNo = entry.seqNo;
2883
- if (entry.primaryTerm > highestPrimaryTerm) {
2884
- highestPrimaryTerm = entry.primaryTerm;
2885
- }
2886
- }
3247
+ const compacted = await compactPartitionSegments({
3248
+ directory: context.directory,
3249
+ indexName: context.indexName,
3250
+ partitionId,
3251
+ config: context.config,
3252
+ language: context.language,
3253
+ segments,
3254
+ nextSegmentId,
3255
+ compactionThreshold: context.compactionThreshold
3256
+ });
3257
+ segments = compacted.segments;
3258
+ nextSegmentId = compacted.nextSegmentId;
3259
+ const vectors = await writePartitionVectors({
3260
+ directory: context.directory,
3261
+ indexName: context.indexName,
3262
+ partitionId,
3263
+ config: context.config,
3264
+ language: context.language,
3265
+ vectorFields: context.vectorFields,
3266
+ vectorFieldPaths: context.vectorFieldPaths,
3267
+ entries,
3268
+ priorVectors: priorPartition?.vectors ?? []
3269
+ });
3270
+ return { partitionId, nextSegmentId, segments, vectors };
3271
+ }
3272
+ async function writeWholePartition(context, partitionId, priorPartition, whole, entries) {
3273
+ const id = priorPartition?.nextSegmentId ?? 0;
3274
+ const key = segmentKey(context.indexName, partitionId, id);
3275
+ await persistSegmentFile(context.directory, key, whole.payload, []);
3276
+ const vectors = await writePartitionVectors({
3277
+ directory: context.directory,
3278
+ indexName: context.indexName,
3279
+ partitionId,
3280
+ config: context.config,
3281
+ language: context.language,
3282
+ vectorFields: context.vectorFields,
3283
+ vectorFieldPaths: context.vectorFieldPaths,
3284
+ entries,
3285
+ priorVectors: priorPartition?.vectors ?? []
3286
+ });
3287
+ return {
3288
+ partitionId,
3289
+ nextSegmentId: id + 1,
3290
+ segments: [{ id, key, docCount: whole.docCount, tombstoneCount: 0 }],
3291
+ vectors
2887
3292
  };
2888
- replay(durableEntries);
2889
- if (activeTail !== null) {
2890
- replay(activeTail.entries);
2891
- }
2892
- await deleteOrphanSegments(directory, segments, marker.state.activeSegmentSeqNo);
2893
- if (activeTail !== null && activeTail.cleanEnd < activeTail.segmentLength) {
2894
- await truncateSegmentTail(directory, activeTail.key, activeTail.cleanEnd);
2895
- }
2896
- return { highestSeqNo, highestPrimaryTerm };
2897
3293
  }
2898
- async function collectSegments(directory, prefix) {
2899
- const keys = await directory.list(prefix);
2900
- const refs = [];
2901
- for (const key of keys) {
2902
- const startSeqNo = segmentStartSeqNo(key, prefix);
2903
- if (startSeqNo !== null) {
2904
- refs.push({ key, startSeqNo });
2905
- }
3294
+ function carryForwardUncheckpointedPartitions(priorManifest, targets, partitions, checkpointByPartition) {
3295
+ if (priorManifest === null) {
3296
+ return;
2906
3297
  }
2907
- refs.sort((a, b) => a.startSeqNo - b.startSeqNo);
2908
- return refs;
2909
- }
2910
- async function deleteOrphanSegments(directory, segments, activeSegmentSeqNo) {
2911
- for (const { key, startSeqNo } of segments) {
2912
- if (startSeqNo > activeSegmentSeqNo) {
2913
- await directory.remove(key);
3298
+ const covered = new Set(targets.map((t) => t.partitionId));
3299
+ for (const priorPartition of priorManifest.partitions) {
3300
+ if (covered.has(priorPartition.partitionId)) {
3301
+ continue;
2914
3302
  }
2915
- }
2916
- }
2917
- async function truncateSegmentTail(directory, key, durableByteLength) {
2918
- const cleanLength = Math.max(durableByteLength, SEGMENT_HEADER_SIZE);
2919
- const handle = await directory.appendHandle(key);
2920
- try {
2921
- const currentSize = await handle.size();
2922
- if (cleanLength < currentSize) {
2923
- await handle.truncate(cleanLength);
3303
+ partitions.push(priorPartition);
3304
+ const priorCheckpoint = priorManifest.checkpoint.find((c) => c.partitionId === priorPartition.partitionId);
3305
+ if (priorCheckpoint !== void 0 && !checkpointByPartition.has(priorPartition.partitionId)) {
3306
+ checkpointByPartition.set(priorPartition.partitionId, priorCheckpoint);
2924
3307
  }
2925
- } finally {
2926
- await handle.close();
2927
3308
  }
2928
3309
  }
2929
- function applyEntry(entry, deps) {
2930
- if (entry.operation === "DELETE") {
2931
- applyDeleteEntry(entry, deps.manager, deps.vectorIndexes);
2932
- return;
3310
+ function resolveCompactionThreshold(configured) {
3311
+ if (!Number.isInteger(configured) || configured <= 0 || configured > MAX_SEGMENTS_PER_PARTITION) {
3312
+ return DEFAULT_COMPACTION_THRESHOLD;
2933
3313
  }
2934
- applyIndexEntry(entry, deps.manager, deps.vectorFieldPaths, deps.vectorIndexes);
2935
- }
2936
- function snapshotCheckpointFor(checkpoint, partitionId) {
2937
- return checkpointLastSeqNo(checkpoint, partitionId);
3314
+ return configured;
2938
3315
  }
2939
- async function collectWalEntriesInRange(directory, indexName, partitionId, fromSeqNoExclusive, upToSeqNoInclusive) {
2940
- const read = await readWalSegments(directory, indexName, partitionId);
2941
- if (read === null) {
2942
- return [];
3316
+ async function collectGarbage(directory, indexName, priorManifest, manifest) {
3317
+ const referenced = manifestReferencedKeys(manifest);
3318
+ const partitionIds = /* @__PURE__ */ new Set();
3319
+ for (const partition of manifest.partitions) {
3320
+ partitionIds.add(partition.partitionId);
2943
3321
  }
2944
- if (Math.max(fromSeqNoExclusive, read.highestReadFromWal) < read.marker.state.highestDurableSeqNo) {
2945
- throw new NarsilError(
2946
- ErrorCodes.PERSISTENCE_WAL_CORRUPT,
2947
- "A durable WAL record is missing: the highest recovered seqNo is below the commit marker",
2948
- {
2949
- indexName,
2950
- partitionId,
2951
- highestRead: read.highestReadFromWal,
2952
- highestDurable: read.marker.state.highestDurableSeqNo
2953
- }
2954
- );
3322
+ if (priorManifest !== null) {
3323
+ for (const partition of priorManifest.partitions) {
3324
+ partitionIds.add(partition.partitionId);
3325
+ }
2955
3326
  }
2956
- const entries = [];
2957
- for (const entry of read.durableEntries) {
2958
- if (entry.seqNo <= fromSeqNoExclusive || entry.seqNo > upToSeqNoInclusive) {
2959
- continue;
3327
+ for (const partitionId of partitionIds) {
3328
+ const prefix = segmentPrefix(indexName, partitionId);
3329
+ for (const key of await directory.list(prefix)) {
3330
+ if (!referenced.has(key)) {
3331
+ await directory.remove(key);
3332
+ }
2960
3333
  }
2961
- entries.push(entry);
2962
3334
  }
2963
- return entries;
3335
+ await directory.remove(legacySnapshotKey(indexName));
3336
+ }
3337
+
3338
+ // src/persistence/durability/checkpoint-write.ts
3339
+ async function writeIndexCheckpoint(input) {
3340
+ const { directory, metadata, targets, compactionThreshold, manager } = input;
3341
+ const offloaded = !input.fromMemory && input.canOffload && metadata.tokenizer === void 0 && metadata.stopWords === void 0 && await runCheckpointOnWorker({ root: directory.root, metadata, targets, compactionThreshold });
3342
+ if (offloaded) {
3343
+ return;
3344
+ }
3345
+ await writeSegmentedCheckpoint({
3346
+ directory,
3347
+ metadata,
3348
+ targets,
3349
+ compactionThreshold,
3350
+ ...input.fromMemory ? {
3351
+ wholePartitionPayload: (partitionId) => ({
3352
+ payload: manager.serializePartitionToBytes(partitionId),
3353
+ docCount: manager.getPartition(partitionId).count()
3354
+ })
3355
+ } : {}
3356
+ });
2964
3357
  }
2965
3358
 
2966
3359
  // src/persistence/durability/seq-owner.ts
@@ -3232,6 +3625,7 @@ function createWalWriter(directory, config) {
3232
3625
  var DEFAULT_ASYNC_FLUSH_INTERVAL_MS = 1e3;
3233
3626
  function createDurabilityManager(config, hooks, directoryOverride) {
3234
3627
  const directory = createDurableDirectory(config.directory ?? "");
3628
+ const canOffloadCheckpoint = directoryOverride === void 0;
3235
3629
  const segmentMaxBytes = config.segmentMaxBytes ?? DEFAULT_SEGMENT_MAX_BYTES;
3236
3630
  const checkpointIntervalMs = config.checkpointIntervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS;
3237
3631
  const checkpointMutationThreshold = config.checkpointMutationThreshold ?? DEFAULT_CHECKPOINT_MUTATION_THRESHOLD;
@@ -3258,14 +3652,14 @@ function createDurabilityManager(config, hooks, directoryOverride) {
3258
3652
  }
3259
3653
  hooks.onFatalError(error);
3260
3654
  }
3261
- function toError(err) {
3655
+ function toError3(err) {
3262
3656
  return err instanceof Error ? err : new Error(String(err));
3263
3657
  }
3264
3658
  async function closeWriterReportingFailure(closePromise) {
3265
3659
  try {
3266
3660
  await closePromise;
3267
3661
  } catch (err) {
3268
- markFatal(toError(err));
3662
+ markFatal(toError3(err));
3269
3663
  }
3270
3664
  }
3271
3665
  function getOrCreateIndexState(indexName) {
@@ -3325,7 +3719,7 @@ function createDurabilityManager(config, hooks, directoryOverride) {
3325
3719
  try {
3326
3720
  await partition.walWriter.commit();
3327
3721
  } catch (err) {
3328
- partition.failed = toError(err);
3722
+ partition.failed = toError3(err);
3329
3723
  markFatal(partition.failed);
3330
3724
  }
3331
3725
  }
@@ -3339,7 +3733,7 @@ function createDurabilityManager(config, hooks, directoryOverride) {
3339
3733
  try {
3340
3734
  await checkpointIndex(indexName);
3341
3735
  } catch (err) {
3342
- markFatal(toError(err));
3736
+ markFatal(toError3(err));
3343
3737
  }
3344
3738
  }
3345
3739
  }
@@ -3354,21 +3748,24 @@ function createDurabilityManager(config, hooks, directoryOverride) {
3354
3748
  document: record.document
3355
3749
  });
3356
3750
  }
3357
- async function checkpointIndex(indexName) {
3751
+ async function checkpointIndex(indexName, fromMemory = false) {
3358
3752
  const indexState = indexes.get(indexName);
3359
3753
  if (indexState === void 0) {
3360
3754
  return;
3361
3755
  }
3362
3756
  if (indexState.checkpointInFlight !== null) {
3363
- return indexState.checkpointInFlight;
3757
+ await indexState.checkpointInFlight;
3758
+ if (!fromMemory) {
3759
+ return;
3760
+ }
3364
3761
  }
3365
- const run = performCheckpoint(indexName, indexState).finally(() => {
3762
+ const run = performCheckpoint(indexName, indexState, fromMemory).finally(() => {
3366
3763
  indexState.checkpointInFlight = null;
3367
3764
  });
3368
3765
  indexState.checkpointInFlight = run;
3369
3766
  return run;
3370
3767
  }
3371
- async function performCheckpoint(indexName, indexState) {
3768
+ async function performCheckpoint(indexName, indexState, fromMemory = false) {
3372
3769
  const manager = hooks.getManager(indexName);
3373
3770
  if (manager === void 0) {
3374
3771
  return;
@@ -3389,22 +3786,23 @@ function createDurabilityManager(config, hooks, directoryOverride) {
3389
3786
  try {
3390
3787
  await partition.walWriter.commit();
3391
3788
  } catch (err) {
3392
- partition.failed = toError(err);
3789
+ partition.failed = toError3(err);
3393
3790
  markFatal(partition.failed);
3394
3791
  throw partition.failed;
3395
3792
  }
3396
3793
  }
3397
3794
  }
3398
- const offloaded = await runCheckpointOnWorker({
3399
- root: directory.root,
3795
+ await writeIndexCheckpoint({
3796
+ directory,
3400
3797
  metadata,
3401
3798
  targets,
3402
- compactionThreshold
3799
+ compactionThreshold,
3800
+ manager,
3801
+ canOffload: canOffloadCheckpoint,
3802
+ fromMemory
3403
3803
  });
3404
- if (!offloaded) {
3405
- await writeSegmentedCheckpoint({ directory, metadata, targets, compactionThreshold });
3406
- }
3407
3804
  await truncateCoveredSegments(directory, indexName, targets);
3805
+ await reclaimWalBeyondCount(directory, indexName, manager.partitionCount, indexState.partitions, markFatal);
3408
3806
  indexState.mutationsSinceCheckpoint = 0;
3409
3807
  }
3410
3808
  async function recoverIndex(indexName) {
@@ -3464,7 +3862,7 @@ function createDurabilityManager(config, hooks, directoryOverride) {
3464
3862
  const entry = buildMutationEntry(record, allocatedSeqNo);
3465
3863
  await partition.walWriter.append(entry);
3466
3864
  } catch (err) {
3467
- partition.failed = toError(err);
3865
+ partition.failed = toError3(err);
3468
3866
  markFatal(partition.failed);
3469
3867
  throw partition.failed;
3470
3868
  }
@@ -3476,7 +3874,7 @@ function createDurabilityManager(config, hooks, directoryOverride) {
3476
3874
  try {
3477
3875
  await partition.walWriter.commit();
3478
3876
  } catch (err) {
3479
- partition.failed = toError(err);
3877
+ partition.failed = toError3(err);
3480
3878
  markFatal(partition.failed);
3481
3879
  throw partition.failed;
3482
3880
  }
@@ -3486,7 +3884,7 @@ function createDurabilityManager(config, hooks, directoryOverride) {
3486
3884
  startAsyncFlushTimer();
3487
3885
  if (indexState.mutationsSinceCheckpoint >= checkpointMutationThreshold) {
3488
3886
  void checkpointIndex(record.indexName).catch((err) => {
3489
- markFatal(toError(err));
3887
+ markFatal(toError3(err));
3490
3888
  });
3491
3889
  }
3492
3890
  return allocatedSeqNo;
@@ -3503,6 +3901,9 @@ function createDurabilityManager(config, hooks, directoryOverride) {
3503
3901
  checkpoint(indexName) {
3504
3902
  return checkpointIndex(indexName);
3505
3903
  },
3904
+ checkpointFromMemory(indexName) {
3905
+ return checkpointIndex(indexName, true);
3906
+ },
3506
3907
  async checkpointAll() {
3507
3908
  for (const indexName of [...indexes.keys()]) {
3508
3909
  await checkpointIndex(indexName);
@@ -3548,7 +3949,7 @@ function createDurabilityManager(config, hooks, directoryOverride) {
3548
3949
  function metadataKey(indexName) {
3549
3950
  return `${indexName}/meta`;
3550
3951
  }
3551
- function createSnapshotOnlyManager(adapter, config, hooks) {
3952
+ function createSnapshotOnlyManager(adapter, config, hooks, publisher) {
3552
3953
  const checkpointIntervalMs = config.checkpointIntervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS;
3553
3954
  const checkpointMutationThreshold = config.checkpointMutationThreshold ?? DEFAULT_CHECKPOINT_MUTATION_THRESHOLD;
3554
3955
  const indexes = /* @__PURE__ */ new Map();
@@ -3597,6 +3998,10 @@ function createSnapshotOnlyManager(adapter, config, hooks) {
3597
3998
  const { parts } = await buildSnapshotBundleBytes({
3598
3999
  schema: metadata.schema,
3599
4000
  language: metadata.language,
4001
+ ...metadata.analysisRevision !== void 0 ? { analysisRevision: metadata.analysisRevision } : {},
4002
+ ...metadata.tokenizer !== void 0 ? { tokenizer: metadata.tokenizer } : {},
4003
+ ...metadata.stopWords !== void 0 ? { stopWords: metadata.stopWords } : {},
4004
+ ...metadata.stopWordList !== void 0 ? { stopWordList: metadata.stopWordList } : {},
3600
4005
  manager,
3601
4006
  vectorIndexes: hooks.getVectorIndexes(indexName),
3602
4007
  seqNoByPartition,
@@ -3604,6 +4009,13 @@ function createSnapshotOnlyManager(adapter, config, hooks) {
3604
4009
  });
3605
4010
  await adapter.save(snapshotStorageKey(indexName), concatEnvelopeParts(parts));
3606
4011
  indexState.mutationsSinceCheckpoint = 0;
4012
+ if (publisher !== void 0) {
4013
+ const partitionIds = [];
4014
+ for (let i = 0; i < manager.partitionCount; i += 1) {
4015
+ partitionIds.push(i);
4016
+ }
4017
+ await publisher.publishPartitions(indexName, partitionIds);
4018
+ }
3607
4019
  }
3608
4020
  async function checkpointIndex(indexName) {
3609
4021
  const indexState = indexes.get(indexName);
@@ -3690,6 +4102,12 @@ function createSnapshotOnlyManager(adapter, config, hooks) {
3690
4102
  await adapter.delete(key);
3691
4103
  }
3692
4104
  },
4105
+ async reloadIndex(indexName) {
4106
+ const indexState = getOrCreateIndexState(indexName);
4107
+ const reload = indexState.applyChain.then(() => recoverIndex(indexName));
4108
+ indexState.applyChain = reload.catch(() => void 0);
4109
+ await reload;
4110
+ },
3693
4111
  async shutdown() {
3694
4112
  shuttingDown = true;
3695
4113
  if (checkpointTimer !== null) {
@@ -3728,6 +4146,36 @@ function buildMetadata(indexName, hooks) {
3728
4146
  if (config.surfaceForms) {
3729
4147
  metadata.surfaceForms = true;
3730
4148
  }
4149
+ if (config.analysisRevision !== void 0) {
4150
+ metadata.analysisRevision = config.analysisRevision;
4151
+ }
4152
+ if (config.tokenizer !== void 0) {
4153
+ metadata.tokenizer = config.tokenizer;
4154
+ }
4155
+ if (config.stopWords !== void 0) {
4156
+ metadata.stopWords = config.stopWords;
4157
+ }
4158
+ if (config.stopWordList !== void 0) {
4159
+ metadata.stopWordList = config.stopWordList;
4160
+ }
4161
+ if (config.partitionLimits !== void 0) {
4162
+ metadata.partitionLimits = config.partitionLimits;
4163
+ }
4164
+ if (config.defaultScoring !== void 0) {
4165
+ metadata.defaultScoring = config.defaultScoring;
4166
+ }
4167
+ if (config.trackPositions !== void 0) {
4168
+ metadata.trackPositions = config.trackPositions;
4169
+ }
4170
+ if (config.strict !== void 0) {
4171
+ metadata.strict = config.strict;
4172
+ }
4173
+ if (config.required !== void 0) {
4174
+ metadata.required = config.required;
4175
+ }
4176
+ if (config.vectorPromotion !== void 0) {
4177
+ metadata.vectorPromotion = config.vectorPromotion;
4178
+ }
3731
4179
  return metadata;
3732
4180
  }
3733
4181
  function createDurabilityIntegration(tier, hooks) {
@@ -3739,7 +4187,7 @@ function createDurabilityIntegration(tier, hooks) {
3739
4187
  createIndexFromMetadata: hooks.createIndexFromMetadata,
3740
4188
  onFatalError: hooks.onFatalError
3741
4189
  };
3742
- const manager = tier.kind === "wal" ? createDurabilityManager(tier.config, managerHooks) : createSnapshotOnlyManager(tier.adapter, tier.config, managerHooks);
4190
+ const manager = tier.kind === "wal" ? createDurabilityManager(tier.config, managerHooks) : createSnapshotOnlyManager(tier.adapter, tier.config, managerHooks, hooks.checkpointPublisher);
3743
4191
  function partitionFor(indexName, docId) {
3744
4192
  const partitionManager = hooks.getManager(indexName);
3745
4193
  const partitionCount = partitionManager?.partitionCount ?? 1;
@@ -3758,12 +4206,168 @@ function createDurabilityIntegration(tier, hooks) {
3758
4206
  return { indexName, partitionId, seqNo };
3759
4207
  }
3760
4208
  return {
3761
- manager,
3762
- recordInsertOrUpdate(indexName, docId, document, apply) {
3763
- return recordMutation(indexName, docId, "INDEX", encode(document), apply);
4209
+ manager,
4210
+ recordInsertOrUpdate(indexName, docId, document, apply) {
4211
+ return recordMutation(indexName, docId, "INDEX", encode(document), apply);
4212
+ },
4213
+ recordRemove(indexName, docId, apply) {
4214
+ return recordMutation(indexName, docId, "DELETE", null, apply);
4215
+ }
4216
+ };
4217
+ }
4218
+
4219
+ // src/engine/invalidation.ts
4220
+ var STATISTICS_BROADCAST_INTERVAL_MS = 5e3;
4221
+ var FOREIGN_STATS_TTL_MS = 6e4;
4222
+ function createInvalidationFromConfig(config, tierKind, deps) {
4223
+ if (config?.invalidation === void 0) {
4224
+ return null;
4225
+ }
4226
+ if (tierKind !== "snapshot") {
4227
+ throw new NarsilError(
4228
+ ErrorCodes.CONFIG_INVALID,
4229
+ "Invalidation requires a shared persistence adapter. The write-ahead log tier owns its directory exclusively, so configure a non-filesystem persistence adapter to share data between instances"
4230
+ );
4231
+ }
4232
+ return createInvalidationIntegration({
4233
+ adapter: config.invalidation,
4234
+ instanceId: generateId(),
4235
+ ...deps
4236
+ });
4237
+ }
4238
+ function createInvalidationIntegration(deps) {
4239
+ const { adapter, instanceId, reloadIndex, getManager, listBroadcastIndexNames, onError } = deps;
4240
+ const foreignStats = /* @__PURE__ */ new Map();
4241
+ const reloadChains = /* @__PURE__ */ new Map();
4242
+ let broadcastTimer = null;
4243
+ let stopped = false;
4244
+ function toError3(err) {
4245
+ return err instanceof Error ? err : new Error(String(err));
4246
+ }
4247
+ function pruneStaleForeignStats(entries) {
4248
+ const cutoff = Date.now() - FOREIGN_STATS_TTL_MS;
4249
+ for (const [sourceInstanceId, entry] of entries) {
4250
+ if (entry.receivedAt < cutoff) {
4251
+ entries.delete(sourceInstanceId);
4252
+ }
4253
+ }
4254
+ }
4255
+ function scheduleReload(indexName) {
4256
+ const chain = reloadChains.get(indexName) ?? Promise.resolve();
4257
+ const next = chain.then(() => reloadIndex(indexName)).catch((err) => onError(toError3(err)));
4258
+ reloadChains.set(indexName, next);
4259
+ }
4260
+ function storeForeignStats(indexName, sourceInstanceId, stats) {
4261
+ let entries = foreignStats.get(indexName);
4262
+ if (entries === void 0) {
4263
+ entries = /* @__PURE__ */ new Map();
4264
+ foreignStats.set(indexName, entries);
4265
+ }
4266
+ entries.set(sourceInstanceId, { stats, receivedAt: Date.now() });
4267
+ pruneStaleForeignStats(entries);
4268
+ }
4269
+ function handleEvent(event) {
4270
+ try {
4271
+ if (event.type === "partition") {
4272
+ if (event.sourceInstanceId === instanceId) {
4273
+ return;
4274
+ }
4275
+ scheduleReload(event.indexName);
4276
+ return;
4277
+ }
4278
+ if (event.instanceId === instanceId) {
4279
+ return;
4280
+ }
4281
+ storeForeignStats(event.indexName, event.instanceId, event.stats);
4282
+ } catch (err) {
4283
+ onError(toError3(err));
4284
+ }
4285
+ }
4286
+ async function publishOwnStatistics() {
4287
+ for (const indexName of listBroadcastIndexNames()) {
4288
+ const manager = getManager(indexName);
4289
+ if (manager === void 0) {
4290
+ continue;
4291
+ }
4292
+ const aggregate = manager.getAggregateStats();
4293
+ try {
4294
+ await adapter.publish({
4295
+ type: "statistics",
4296
+ indexName,
4297
+ instanceId,
4298
+ stats: {
4299
+ totalDocs: aggregate.totalDocuments,
4300
+ docFrequencies: aggregate.docFrequencies,
4301
+ totalFieldLengths: aggregate.totalFieldLengths
4302
+ }
4303
+ });
4304
+ } catch (err) {
4305
+ onError(toError3(err));
4306
+ }
4307
+ }
4308
+ }
4309
+ return {
4310
+ instanceId,
4311
+ async publishPartitions(indexName, partitions) {
4312
+ if (stopped || partitions.length === 0) {
4313
+ return;
4314
+ }
4315
+ try {
4316
+ await adapter.publish({
4317
+ type: "partition",
4318
+ indexName,
4319
+ partitions,
4320
+ timestamp: Date.now(),
4321
+ sourceInstanceId: instanceId
4322
+ });
4323
+ } catch (err) {
4324
+ onError(toError3(err));
4325
+ }
3764
4326
  },
3765
- recordRemove(indexName, docId, apply) {
3766
- return recordMutation(indexName, docId, "DELETE", null, apply);
4327
+ broadcastStats(indexName) {
4328
+ const manager = getManager(indexName);
4329
+ if (manager === void 0) {
4330
+ return void 0;
4331
+ }
4332
+ const own = manager.getAggregateStats();
4333
+ const entries = foreignStats.get(indexName);
4334
+ if (entries === void 0 || entries.size === 0) {
4335
+ return mergePartitionStats([own]);
4336
+ }
4337
+ pruneStaleForeignStats(entries);
4338
+ const combined = [own];
4339
+ for (const [, entry] of entries) {
4340
+ combined.push({
4341
+ totalDocuments: entry.stats.totalDocs,
4342
+ docFrequencies: entry.stats.docFrequencies,
4343
+ totalFieldLengths: entry.stats.totalFieldLengths
4344
+ });
4345
+ }
4346
+ return mergePartitionStats(combined);
4347
+ },
4348
+ async start() {
4349
+ await adapter.subscribe(handleEvent);
4350
+ if (broadcastTimer !== null || stopped) {
4351
+ return;
4352
+ }
4353
+ broadcastTimer = setInterval(() => {
4354
+ void publishOwnStatistics();
4355
+ }, STATISTICS_BROADCAST_INTERVAL_MS);
4356
+ if (typeof broadcastTimer.unref === "function") {
4357
+ broadcastTimer.unref();
4358
+ }
4359
+ },
4360
+ async shutdown() {
4361
+ stopped = true;
4362
+ if (broadcastTimer !== null) {
4363
+ clearInterval(broadcastTimer);
4364
+ broadcastTimer = null;
4365
+ }
4366
+ try {
4367
+ await adapter.shutdown();
4368
+ } catch (err) {
4369
+ onError(toError3(err));
4370
+ }
3767
4371
  }
3768
4372
  };
3769
4373
  }
@@ -4016,42 +4620,174 @@ function createWorkerPool(config) {
4016
4620
  };
4017
4621
  }
4018
4622
 
4623
+ // src/engine/worker-resync.ts
4624
+ async function transferIndexToPool(indexName, pool, config, manager) {
4625
+ const allExecutors = pool.getAllExecutors();
4626
+ await Promise.allSettled(
4627
+ allExecutors.map(
4628
+ (workerExecutor) => workerExecutor.execute({ type: "dropIndex", indexName, requestId: `resync-drop-${indexName}` })
4629
+ )
4630
+ );
4631
+ pool.addIndexToAll(indexName);
4632
+ await Promise.all(
4633
+ allExecutors.map(
4634
+ (workerExecutor) => workerExecutor.execute({
4635
+ type: "createIndex",
4636
+ indexName,
4637
+ config,
4638
+ requestId: `resync-create-${indexName}`
4639
+ })
4640
+ )
4641
+ );
4642
+ for (let i = 0; i < manager.partitionCount; i++) {
4643
+ const serialized = manager.serializePartition(i);
4644
+ await Promise.all(
4645
+ allExecutors.map(
4646
+ (workerExecutor) => workerExecutor.execute({
4647
+ type: "deserialize",
4648
+ indexName,
4649
+ partitionId: i,
4650
+ data: serialized,
4651
+ requestId: `resync-${indexName}-${i}`
4652
+ })
4653
+ )
4654
+ );
4655
+ }
4656
+ }
4657
+
4019
4658
  // src/engine/orchestration.ts
4659
+ function toError2(value) {
4660
+ return value instanceof Error ? value : new Error(String(value));
4661
+ }
4662
+ function isDeterministicFailure(error) {
4663
+ return error instanceof NarsilError && error.code === ErrorCodes.CONFIG_INVALID;
4664
+ }
4665
+ function alreadyPresentOnWorker(reason) {
4666
+ return reason instanceof NarsilError && reason.code === ErrorCodes.DOC_ALREADY_EXISTS;
4667
+ }
4668
+ function workerIneligibility(indexName, config, bootstrapModule) {
4669
+ try {
4670
+ assertConfigReachesWorker(indexName, config, bootstrapModule);
4671
+ return null;
4672
+ } catch (err) {
4673
+ if (err instanceof NarsilError && err.code === ErrorCodes.CONFIG_INVALID) {
4674
+ return err;
4675
+ }
4676
+ throw err;
4677
+ }
4678
+ }
4679
+ function assertConfigReachesWorker(indexName, config, bootstrapModule) {
4680
+ if (config.tokenizer !== void 0 && typeof config.tokenizer !== "string") {
4681
+ throw new NarsilError(
4682
+ ErrorCodes.CONFIG_INVALID,
4683
+ `Index "${indexName}" holds a tokenizer instance, and no worker thread can receive one. Register the tokenizer with registerTokenizer and name it in the index config`,
4684
+ { indexName }
4685
+ );
4686
+ }
4687
+ if (typeof config.stopWords === "function") {
4688
+ throw new NarsilError(
4689
+ ErrorCodes.CONFIG_INVALID,
4690
+ `Index "${indexName}" holds a stop word function, and no worker thread can receive one. Register the function with registerStopWords and name it in the index config`,
4691
+ { indexName }
4692
+ );
4693
+ }
4694
+ const language = config.language ?? "english";
4695
+ if (language !== "english" && bootstrapModule === void 0) {
4696
+ throw new NarsilError(
4697
+ ErrorCodes.CONFIG_INVALID,
4698
+ `Index "${indexName}" uses language "${language}", which a worker thread registers only from a bootstrap module. Set workers.bootstrapModule to a module that registers it`,
4699
+ { indexName, language }
4700
+ );
4701
+ }
4702
+ }
4020
4703
  function createWorkerOrchestrator(config, executor, promoter, indexRegistry, callbacks) {
4021
4704
  let workerPool = null;
4022
4705
  let promotionInProgress = false;
4706
+ let promotionBlocked = false;
4707
+ let promotionRun = null;
4023
4708
  const promotionBuffer = [];
4709
+ const awaitingBufferedWrites = /* @__PURE__ */ new Set();
4710
+ const reportedIneligible = /* @__PURE__ */ new Set();
4711
+ const promotedIndexes = /* @__PURE__ */ new Set();
4024
4712
  const workersEnabled = config?.workers?.enabled === true;
4025
- async function checkPromotion() {
4026
- if (!workersEnabled || promotionInProgress || workerPool) return;
4027
- const indexMap = /* @__PURE__ */ new Map();
4028
- for (const [name] of indexRegistry) {
4713
+ const bootstrapModule = config?.workers?.bootstrapModule;
4714
+ function reportIneligible(indexName, error) {
4715
+ if (reportedIneligible.has(indexName)) return;
4716
+ reportedIneligible.add(indexName);
4717
+ callbacks?.onPromotionFailure?.("index-excluded", error, false);
4718
+ }
4719
+ function collectEligibleIndexes() {
4720
+ const eligible = /* @__PURE__ */ new Map();
4721
+ for (const [name, entry] of indexRegistry) {
4722
+ const ineligibility = workerIneligibility(name, entry.config, bootstrapModule);
4723
+ if (ineligibility) {
4724
+ reportIneligible(name, ineligibility);
4725
+ continue;
4726
+ }
4029
4727
  const mgr = executor.getManager(name);
4030
- indexMap.set(name, { documentCount: mgr?.countDocuments() ?? 0 });
4728
+ eligible.set(name, { documentCount: mgr?.countDocuments() ?? 0 });
4031
4729
  }
4730
+ return eligible;
4731
+ }
4732
+ async function checkPromotion() {
4733
+ if (!workersEnabled || promotionInProgress || promotionBlocked || workerPool) return;
4734
+ if (callbacks?.shouldDeferPromotion?.()) return;
4735
+ const indexMap = collectEligibleIndexes();
4736
+ if (indexMap.size === 0) return;
4032
4737
  const result = promoter.check(indexMap);
4033
4738
  if (result.shouldPromote) {
4034
4739
  promotionInProgress = true;
4035
4740
  setTimeout(() => {
4036
- runPromotion(result.reason).catch((err) => {
4037
- console.warn("Worker promotion failed:", err);
4741
+ const run = runPromotion(result.reason).catch((err) => {
4742
+ const error = toError2(err);
4743
+ promotionBlocked = isDeterministicFailure(error);
4038
4744
  promotionInProgress = false;
4745
+ callbacks?.onPromotionFailure?.(result.reason, error, !promotionBlocked);
4746
+ });
4747
+ promotionRun = run.then(() => {
4748
+ promotionRun = null;
4039
4749
  });
4040
4750
  }, 0);
4041
4751
  }
4042
4752
  }
4043
4753
  async function runPromotion(reason) {
4044
4754
  try {
4755
+ const promotable = [];
4756
+ for (const [name, entry] of indexRegistry) {
4757
+ const ineligibility = workerIneligibility(name, entry.config, bootstrapModule);
4758
+ if (ineligibility) {
4759
+ reportIneligible(name, ineligibility);
4760
+ continue;
4761
+ }
4762
+ promotable.push(name);
4763
+ }
4764
+ if (promotable.length === 0) {
4765
+ promotionBuffer.length = 0;
4766
+ return;
4767
+ }
4045
4768
  const factory = await createWorkerFactory();
4046
4769
  const pool = createWorkerPool({
4047
4770
  count: config?.workers?.count,
4048
4771
  workerFactory: factory
4049
4772
  });
4050
- for (const [name] of indexRegistry) {
4773
+ for (const name of promotable) {
4051
4774
  pool.addIndexToAll(name);
4052
4775
  }
4053
4776
  const allExecutors = pool.getAllExecutors();
4054
- for (const [name, entry] of indexRegistry) {
4777
+ if (bootstrapModule !== void 0) {
4778
+ await Promise.all(
4779
+ allExecutors.map(
4780
+ (workerExecutor) => workerExecutor.execute({
4781
+ type: "bootstrap",
4782
+ moduleUrl: bootstrapModule,
4783
+ requestId: "promote-bootstrap"
4784
+ })
4785
+ )
4786
+ );
4787
+ }
4788
+ for (const name of promotable) {
4789
+ const entry = indexRegistry.get(name);
4790
+ if (!entry) continue;
4055
4791
  await Promise.all(
4056
4792
  allExecutors.map(
4057
4793
  (workerExecutor) => workerExecutor.execute({
@@ -4081,38 +4817,64 @@ function createWorkerOrchestrator(config, executor, promoter, indexRegistry, cal
4081
4817
  }
4082
4818
  }
4083
4819
  workerPool = pool;
4084
- promoter.markPromoted();
4085
- if (promotionBuffer.length > 0) {
4086
- const buffered = [...promotionBuffer];
4087
- promotionBuffer.length = 0;
4088
- for (const action of buffered) {
4089
- await replicateToWorkers(action);
4090
- }
4820
+ for (const name of promotable) {
4821
+ promotedIndexes.add(name);
4822
+ awaitingBufferedWrites.add(name);
4091
4823
  }
4824
+ promoter.markPromoted();
4825
+ await drainPromotionBuffer();
4826
+ awaitingBufferedWrites.clear();
4092
4827
  callbacks?.onPromotion?.(pool.workerCount, reason);
4093
4828
  } catch (err) {
4094
4829
  promotionBuffer.length = 0;
4095
4830
  throw err;
4096
4831
  } finally {
4832
+ awaitingBufferedWrites.clear();
4097
4833
  promotionInProgress = false;
4098
4834
  }
4099
4835
  }
4836
+ async function drainPromotionBuffer() {
4837
+ while (promotionBuffer.length > 0) {
4838
+ const action = promotionBuffer.shift();
4839
+ if (action === void 0) return;
4840
+ await dispatchToWorkers(action);
4841
+ }
4842
+ }
4100
4843
  async function replicateToWorkers(action) {
4101
4844
  if (promotionInProgress) {
4102
4845
  promotionBuffer.push(action);
4103
4846
  return;
4104
4847
  }
4848
+ await dispatchToWorkers(action);
4849
+ }
4850
+ async function dispatchToWorkers(action) {
4105
4851
  if (!workerPool) return;
4852
+ if (action.type === "createIndex") {
4853
+ const ineligibility = workerIneligibility(action.indexName, action.config, bootstrapModule);
4854
+ if (ineligibility) {
4855
+ reportIneligible(action.indexName, ineligibility);
4856
+ return;
4857
+ }
4858
+ workerPool.addIndexToAll(action.indexName);
4859
+ promotedIndexes.add(action.indexName);
4860
+ } else if ("indexName" in action && !promotedIndexes.has(action.indexName)) {
4861
+ return;
4862
+ }
4106
4863
  const allExecutors = workerPool.getAllExecutors();
4107
4864
  const results = await Promise.allSettled(allExecutors.map((workerExecutor) => workerExecutor.execute(action)));
4108
4865
  for (const result of results) {
4109
4866
  if (result.status === "rejected") {
4867
+ if (action.type === "insert" && alreadyPresentOnWorker(result.reason)) {
4868
+ continue;
4869
+ }
4110
4870
  console.warn("Worker replication failed:", result.reason);
4111
4871
  }
4112
4872
  }
4113
4873
  }
4114
- async function searchViaWorker(indexName, params) {
4874
+ async function searchViaWorker(indexName, params, globalStats) {
4115
4875
  if (!workerPool) return null;
4876
+ if (!promotedIndexes.has(indexName)) return null;
4877
+ if (awaitingBufferedWrites.has(indexName)) return null;
4116
4878
  const manager = executor.getManager(indexName);
4117
4879
  if (!manager) return null;
4118
4880
  const allExecutors = workerPool.getAllExecutors();
@@ -4125,7 +4887,8 @@ function createWorkerOrchestrator(config, executor, promoter, indexRegistry, cal
4125
4887
  type: "query",
4126
4888
  indexName,
4127
4889
  params,
4128
- requestId: createRequestId()
4890
+ requestId: createRequestId(),
4891
+ ...globalStats !== void 0 ? { globalStats } : {}
4129
4892
  });
4130
4893
  } catch (err) {
4131
4894
  console.warn("Worker search failed, falling back to local:", err);
@@ -4144,7 +4907,8 @@ function createWorkerOrchestrator(config, executor, promoter, indexRegistry, cal
4144
4907
  indexName,
4145
4908
  params,
4146
4909
  requestId: createRequestId(),
4147
- partitionIds: workerAssignments[idx]
4910
+ partitionIds: workerAssignments[idx],
4911
+ ...globalStats !== void 0 ? { globalStats } : {}
4148
4912
  })
4149
4913
  )
4150
4914
  );
@@ -4163,6 +4927,24 @@ function createWorkerOrchestrator(config, executor, promoter, indexRegistry, cal
4163
4927
  function isPromoted() {
4164
4928
  return workerPool !== null;
4165
4929
  }
4930
+ function desyncIndex(indexName) {
4931
+ return promotedIndexes.delete(indexName);
4932
+ }
4933
+ async function resyncIndex(indexName, wasPromoted) {
4934
+ if (promotionRun) {
4935
+ await promotionRun.catch(() => void 0);
4936
+ }
4937
+ if (!workerPool) return;
4938
+ if (!wasPromoted && !promotedIndexes.has(indexName)) return;
4939
+ const entry = indexRegistry.get(indexName);
4940
+ if (!entry) return;
4941
+ if (workerIneligibility(indexName, entry.config, bootstrapModule)) return;
4942
+ const manager = executor.getManager(indexName);
4943
+ if (!manager) return;
4944
+ promotedIndexes.delete(indexName);
4945
+ await transferIndexToPool(indexName, workerPool, entry.config, manager);
4946
+ promotedIndexes.add(indexName);
4947
+ }
4166
4948
  async function getWorkerMemoryStats() {
4167
4949
  if (!workerPool) return [];
4168
4950
  const reports = await workerPool.getMemoryStats();
@@ -4177,59 +4959,65 @@ function createWorkerOrchestrator(config, executor, promoter, indexRegistry, cal
4177
4959
  if (workerPool) {
4178
4960
  await workerPool.shutdown();
4179
4961
  workerPool = null;
4962
+ promotedIndexes.clear();
4180
4963
  }
4181
4964
  }
4182
- return { checkPromotion, replicateToWorkers, searchViaWorker, isPromoted, getWorkerMemoryStats, shutdown };
4965
+ return {
4966
+ checkPromotion,
4967
+ replicateToWorkers,
4968
+ searchViaWorker,
4969
+ isPromoted,
4970
+ desyncIndex,
4971
+ resyncIndex,
4972
+ getWorkerMemoryStats,
4973
+ shutdown
4974
+ };
4183
4975
  }
4184
4976
 
4185
- // src/engine/core.ts
4186
- function getVectorFieldPaths(schema) {
4187
- return new Set(extractVectorFieldsFromSchema(schema).keys());
4188
- }
4189
- function filesystemBackedDirectory(config) {
4190
- const adapterDirectory = config.persistence?.directory;
4191
- if (adapterDirectory !== void 0 && adapterDirectory.trim().length > 0) {
4192
- return adapterDirectory;
4193
- }
4194
- return null;
4195
- }
4196
- function resolveDurabilityTier(config) {
4197
- const filesystemDirectory = filesystemBackedDirectory(config);
4198
- if (config.durability) {
4199
- if (config.durability.directory !== void 0 && config.durability.directory.trim().length > 0) {
4200
- return { kind: "wal", config: { ...config.durability } };
4977
+ // src/engine/watermark.ts
4978
+ function createWatermarkNotifier(deps) {
4979
+ const latchedCapacity = /* @__PURE__ */ new Map();
4980
+ function check(indexName) {
4981
+ const partitionConfig = deps.getPartitionConfig(indexName);
4982
+ const maxDocs = partitionConfig?.maxDocsPerPartition;
4983
+ const watermark = partitionConfig?.watermark;
4984
+ if (maxDocs === void 0 || watermark === void 0) {
4985
+ latchedCapacity.delete(indexName);
4986
+ return;
4201
4987
  }
4202
- if (filesystemDirectory !== null) {
4203
- return { kind: "wal", config: { ...config.durability, directory: filesystemDirectory } };
4988
+ const manager = deps.getManager(indexName);
4989
+ if (!manager) {
4990
+ latchedCapacity.delete(indexName);
4991
+ return;
4204
4992
  }
4205
- if (config.persistence !== void 0) {
4206
- throw new NarsilError(
4207
- ErrorCodes.CONFIG_INVALID,
4208
- "WAL durability requires a filesystem directory, but the configured persistence adapter is not filesystem-backed. Use a filesystem persistence adapter or set durability.directory"
4209
- );
4993
+ const partitionCount = manager.partitionCount;
4994
+ const capacity = maxDocs * partitionCount;
4995
+ const documentCount = manager.countDocuments();
4996
+ const threshold = watermark * capacity;
4997
+ if (documentCount < threshold) {
4998
+ latchedCapacity.delete(indexName);
4999
+ return;
4210
5000
  }
4211
- throw new NarsilError(
4212
- ErrorCodes.CONFIG_INVALID,
4213
- "Durability requires a directory. Set durability.directory explicitly, or configure a filesystem persistence adapter"
4214
- );
4215
- }
4216
- if (config.persistence === void 0) {
4217
- return null;
5001
+ if (latchedCapacity.get(indexName) === capacity) return;
5002
+ latchedCapacity.set(indexName, capacity);
5003
+ deps.emit({ indexName, documentCount, capacity, partitionCount });
4218
5004
  }
4219
- if (filesystemDirectory !== null) {
4220
- return { kind: "wal", config: { directory: filesystemDirectory } };
5005
+ function forget(indexName) {
5006
+ latchedCapacity.delete(indexName);
4221
5007
  }
4222
- return { kind: "snapshot", config: {}, adapter: config.persistence };
5008
+ return { check, forget };
4223
5009
  }
4224
- function createDurabilityFromConfig(config, wiring) {
4225
- if (config === void 0) {
4226
- return null;
4227
- }
4228
- const tier = resolveDurabilityTier(config);
5010
+
5011
+ // src/engine/core.ts
5012
+ function getVectorFieldPaths(schema) {
5013
+ return new Set(extractVectorFieldsFromSchema(schema).keys());
5014
+ }
5015
+ function createDurabilityFromTier(tier, wiring) {
4229
5016
  if (tier === null) {
4230
5017
  return null;
4231
5018
  }
4232
5019
  return createDurabilityIntegration(tier, {
5020
+ checkpointPublisher: { publishPartitions: wiring.publishCheckpointedPartitions },
4233
5021
  getManager: (indexName) => wiring.indexRegistry.has(indexName) ? wiring.requireManager(indexName) : void 0,
4234
5022
  getVectorFieldPaths: (indexName) => wiring.indexRegistry.get(indexName)?.vectorFieldPaths ?? /* @__PURE__ */ new Set(),
4235
5023
  getVectorIndexes: (indexName) => wiring.indexRegistry.has(indexName) ? wiring.requireManager(indexName).getVectorIndexes() : /* @__PURE__ */ new Map(),
@@ -4248,7 +5036,17 @@ function createDurabilityFromConfig(config, wiring) {
4248
5036
  k1: entry.config.bm25?.k1 ?? 1.2,
4249
5037
  b: entry.config.bm25?.b ?? 0.75,
4250
5038
  ...embedding !== void 0 ? { embedding } : {},
4251
- ...entry.config.surfaceForms === true ? { surfaceForms: true } : {}
5039
+ ...entry.config.surfaceForms === true ? { surfaceForms: true } : {},
5040
+ analysisRevision: entry.language.revision,
5041
+ ...typeof entry.config.tokenizer === "string" ? { tokenizer: entry.config.tokenizer } : {},
5042
+ ...typeof entry.config.stopWords === "string" ? { stopWords: entry.config.stopWords } : {},
5043
+ ...entry.config.stopWords instanceof Set ? { stopWordList: [...entry.config.stopWords].sort() } : {},
5044
+ ...entry.config.partitions !== void 0 ? { partitionLimits: entry.config.partitions } : {},
5045
+ ...entry.config.defaultScoring !== void 0 ? { defaultScoring: entry.config.defaultScoring } : {},
5046
+ ...entry.config.trackPositions !== void 0 ? { trackPositions: entry.config.trackPositions } : {},
5047
+ ...entry.config.strict !== void 0 ? { strict: entry.config.strict } : {},
5048
+ ...entry.config.required !== void 0 ? { required: entry.config.required } : {},
5049
+ ...entry.config.vectorPromotion !== void 0 ? { vectorPromotion: entry.config.vectorPromotion } : {}
4252
5050
  };
4253
5051
  },
4254
5052
  createIndexFromMetadata: wiring.createIndexFromMetadata,
@@ -4277,19 +5075,30 @@ function createEngineCore(config) {
4277
5075
  const eventHandlers = /* @__PURE__ */ new Map();
4278
5076
  const shutdownState = { isShutdown: false };
4279
5077
  const abortController = new AbortController();
5078
+ const rebalancingIndexes = /* @__PURE__ */ new Set();
4280
5079
  const orchestrator = createWorkerOrchestrator(config, executor, promoter, indexRegistry, {
5080
+ shouldDeferPromotion() {
5081
+ return rebalancingIndexes.size > 0;
5082
+ },
4281
5083
  onPromotion(workerCount, reason) {
4282
5084
  const handlers = eventHandlers.get("workerPromote");
4283
5085
  if (handlers) {
4284
5086
  for (const handler of handlers) handler({ workerCount, reason });
4285
5087
  }
5088
+ },
5089
+ onPromotionFailure(reason, error, retryable) {
5090
+ const handlers = eventHandlers.get("workerPromoteFailure");
5091
+ if (!handlers || handlers.size === 0) {
5092
+ console.warn(`Worker promotion failed (${reason}):`, error);
5093
+ return;
5094
+ }
5095
+ for (const handler of handlers) handler({ reason, error, retryable });
4286
5096
  }
4287
5097
  });
4288
5098
  const rebalancer = createRebalancer();
4289
5099
  const rebalanceRouter = createPartitionRouter();
4290
- const rebalancingIndexes = /* @__PURE__ */ new Set();
4291
5100
  const waqMap = /* @__PURE__ */ new Map();
4292
- const lastAppliedSeqMap = /* @__PURE__ */ new Map();
5101
+ const rebalanceTargets = /* @__PURE__ */ new Map();
4293
5102
  function guardShutdown() {
4294
5103
  if (shutdownState.isShutdown) {
4295
5104
  throw new NarsilError(ErrorCodes.INDEX_NOT_FOUND, "This Narsil instance has been shut down");
@@ -4346,7 +5155,18 @@ function createEngineCore(config) {
4346
5155
  embeddingAdapter = candidate;
4347
5156
  }
4348
5157
  }
4349
- executor.createIndex(metadata.indexName, indexConfig, language);
5158
+ try {
5159
+ executor.createIndex(metadata.indexName, indexConfig, language);
5160
+ } catch (err) {
5161
+ if (err instanceof NarsilError && err.code === ErrorCodes.CONFIG_INVALID) {
5162
+ throw new NarsilError(err.code, `Recovery of index "${metadata.indexName}" failed: ${err.message}`, {
5163
+ indexName: metadata.indexName,
5164
+ tokenizer: metadata.tokenizer,
5165
+ stopWords: metadata.stopWords
5166
+ });
5167
+ }
5168
+ throw err;
5169
+ }
4350
5170
  indexRegistry.set(metadata.indexName, {
4351
5171
  config: indexConfig,
4352
5172
  language,
@@ -4354,8 +5174,18 @@ function createEngineCore(config) {
4354
5174
  embeddingAdapterName: adapterName,
4355
5175
  vectorFieldPaths: getVectorFieldPaths(indexConfig.schema)
4356
5176
  });
5177
+ if (metadata.analysisRevision !== language.revision) {
5178
+ analysisRebuild.markStale({
5179
+ indexName: metadata.indexName,
5180
+ language: language.name,
5181
+ storedRevision: metadata.analysisRevision ?? null,
5182
+ currentRevision: language.revision
5183
+ });
5184
+ }
4357
5185
  }
4358
- const durability = createDurabilityFromConfig(config, {
5186
+ const durabilityTier = config !== void 0 ? resolveDurabilityTier(config) : null;
5187
+ let invalidation = null;
5188
+ const durability = createDurabilityFromTier(durabilityTier, {
4359
5189
  requireManager,
4360
5190
  indexRegistry,
4361
5191
  createIndexFromMetadata,
@@ -4364,8 +5194,53 @@ function createEngineCore(config) {
4364
5194
  if (handlers) {
4365
5195
  for (const handler of handlers) handler({ error });
4366
5196
  }
5197
+ },
5198
+ publishCheckpointedPartitions: (indexName, partitions) => invalidation?.publishPartitions(indexName, partitions) ?? Promise.resolve()
5199
+ });
5200
+ invalidation = createInvalidationFromConfig(config, durabilityTier?.kind ?? null, {
5201
+ getManager: (indexName) => executor.getManager(indexName) ?? void 0,
5202
+ listBroadcastIndexNames: () => {
5203
+ const names = [];
5204
+ for (const [name, entry] of indexRegistry) {
5205
+ if (entry.config.defaultScoring === "broadcast") {
5206
+ names.push(name);
5207
+ }
5208
+ }
5209
+ return names;
5210
+ },
5211
+ reloadIndex: (indexName) => durability?.manager.reloadIndex?.(indexName) ?? Promise.resolve(),
5212
+ onError(error) {
5213
+ const handlers = eventHandlers.get("invalidationError");
5214
+ if (!handlers || handlers.size === 0) {
5215
+ console.warn("Invalidation error:", error);
5216
+ return;
5217
+ }
5218
+ for (const handler of handlers) handler({ error });
5219
+ }
5220
+ });
5221
+ const watermarkNotifier = createWatermarkNotifier({
5222
+ getManager: (indexName) => executor.getManager(indexName),
5223
+ getPartitionConfig: (indexName) => indexRegistry.get(indexName)?.config.partitions,
5224
+ emit(payload) {
5225
+ const handlers = eventHandlers.get("partitionWatermark");
5226
+ if (!handlers) return;
5227
+ for (const handler of handlers) {
5228
+ try {
5229
+ handler(payload);
5230
+ } catch (err) {
5231
+ console.warn("partitionWatermark handler error:", err instanceof Error ? err.message : String(err));
5232
+ }
5233
+ }
4367
5234
  }
4368
5235
  });
5236
+ const analysisRebuild = wireAnalysisRebuild({
5237
+ config: config?.analysis,
5238
+ eventHandlers,
5239
+ getManager: (indexName) => executor.getManager(indexName),
5240
+ desyncIndex: (indexName) => orchestrator.desyncIndex(indexName),
5241
+ resyncIndex: (indexName, wasPromoted) => orchestrator.resyncIndex(indexName, wasPromoted),
5242
+ durabilityManager: durability?.manager ?? null
5243
+ });
4369
5244
  const mutationCtx = {
4370
5245
  executor,
4371
5246
  pluginRegistry,
@@ -4376,15 +5251,24 @@ function createEngineCore(config) {
4376
5251
  guardShutdown,
4377
5252
  requireIndex,
4378
5253
  requireManager,
4379
- bufferIfRebalancing
5254
+ bufferIfRebalancing,
5255
+ isRebalancing: (indexName) => rebalancingIndexes.has(indexName),
5256
+ pendingRebalanceWrites: (indexName) => waqMap.get(indexName)?.size ?? 0,
5257
+ rebalanceTargetPartitionCount: (indexName) => rebalanceTargets.get(indexName),
5258
+ bufferedDocState: (indexName, docId) => waqMap.get(indexName)?.bufferedDocState(docId),
5259
+ checkWatermark: watermarkNotifier.check
4380
5260
  };
4381
5261
  const rebalanceCtx = {
4382
5262
  rebalancer,
4383
5263
  router: rebalanceRouter,
4384
5264
  waqMap,
4385
5265
  rebalancingIndexes,
4386
- lastAppliedSeqMap,
5266
+ rebalanceTargets,
4387
5267
  eventHandlers,
5268
+ pluginRegistry,
5269
+ orchestrator,
5270
+ durabilityManager: durability?.manager ?? null,
5271
+ checkWatermark: watermarkNotifier.check,
4388
5272
  requireIndex
4389
5273
  };
4390
5274
  return {
@@ -4392,6 +5276,7 @@ function createEngineCore(config) {
4392
5276
  promoter,
4393
5277
  pluginRegistry,
4394
5278
  durability,
5279
+ invalidation,
4395
5280
  idGenerator,
4396
5281
  indexRegistry,
4397
5282
  embeddingAdapters,
@@ -4403,11 +5288,12 @@ function createEngineCore(config) {
4403
5288
  rebalanceRouter,
4404
5289
  rebalancingIndexes,
4405
5290
  waqMap,
4406
- lastAppliedSeqMap,
4407
5291
  guardShutdown,
4408
5292
  requireIndex,
4409
5293
  requireManager,
4410
5294
  bufferIfRebalancing,
5295
+ watermarkNotifier,
5296
+ analysisRebuild,
4411
5297
  mutationCtx,
4412
5298
  rebalanceCtx
4413
5299
  };
@@ -4587,6 +5473,24 @@ function validateDocId(docId) {
4587
5473
  throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Document ID must not contain null bytes", { docId });
4588
5474
  }
4589
5475
  }
5476
+ function validatePartitionConfig(partitions) {
5477
+ const { maxDocsPerPartition, maxPartitions, watermark } = partitions;
5478
+ if (maxDocsPerPartition !== void 0 && (!Number.isInteger(maxDocsPerPartition) || maxDocsPerPartition < 1)) {
5479
+ throw new NarsilError(ErrorCodes.CONFIG_INVALID, "partitions.maxDocsPerPartition must be a positive integer", {
5480
+ maxDocsPerPartition
5481
+ });
5482
+ }
5483
+ if (maxPartitions !== void 0 && (!Number.isInteger(maxPartitions) || maxPartitions < 1)) {
5484
+ throw new NarsilError(ErrorCodes.CONFIG_INVALID, "partitions.maxPartitions must be a positive integer", {
5485
+ maxPartitions
5486
+ });
5487
+ }
5488
+ if (watermark !== void 0 && (typeof watermark !== "number" || !(watermark > 0) || watermark > 1)) {
5489
+ throw new NarsilError(ErrorCodes.CONFIG_INVALID, "partitions.watermark must be above 0 and at most 1", {
5490
+ watermark
5491
+ });
5492
+ }
5493
+ }
4590
5494
  function clampLimit(limit) {
4591
5495
  if (limit === void 0) return DEFAULT_LIMIT;
4592
5496
  return Math.max(0, Math.min(limit, MAX_LIMIT));
@@ -4605,6 +5509,25 @@ async function createEngineIndex(core, config, name, indexConfig) {
4605
5509
  }
4606
5510
  validateSchema(indexConfig.schema);
4607
5511
  validateVectorPromotion(indexConfig.vectorPromotion);
5512
+ if (indexConfig.partitions) {
5513
+ validatePartitionConfig(indexConfig.partitions);
5514
+ }
5515
+ if (core.durability) {
5516
+ if (indexConfig.tokenizer !== void 0 && typeof indexConfig.tokenizer !== "string") {
5517
+ throw new NarsilError(
5518
+ ErrorCodes.CONFIG_INVALID,
5519
+ `Index "${name}" cannot use a tokenizer instance while durability is configured, because a checkpoint persists no code. Register it with registerTokenizer and pass its name`,
5520
+ { indexName: name }
5521
+ );
5522
+ }
5523
+ if (typeof indexConfig.stopWords === "function") {
5524
+ throw new NarsilError(
5525
+ ErrorCodes.CONFIG_INVALID,
5526
+ `Index "${name}" cannot use a stop word function while durability is configured, because a checkpoint persists no code. Register it with registerStopWords and pass its name`,
5527
+ { indexName: name }
5528
+ );
5529
+ }
5530
+ }
4608
5531
  let resolvedEmbeddingAdapter = null;
4609
5532
  let embeddingAdapterName = null;
4610
5533
  if (indexConfig.embedding) {
@@ -4649,6 +5572,8 @@ async function dropEngineIndex(core, name) {
4649
5572
  const entry = core.requireIndex(name);
4650
5573
  core.executor.dropIndex(name);
4651
5574
  core.indexRegistry.delete(name);
5575
+ core.watermarkNotifier.forget(name);
5576
+ core.analysisRebuild.clearStale(name);
4652
5577
  if (core.durability) {
4653
5578
  await core.durability.manager.removeIndex(name);
4654
5579
  }
@@ -4697,6 +5622,9 @@ async function shutdownEngine(core) {
4697
5622
  if (durability) {
4698
5623
  await durability.manager.shutdown();
4699
5624
  }
5625
+ if (core.invalidation) {
5626
+ await core.invalidation.shutdown();
5627
+ }
4700
5628
  const adaptersToShutdown = /* @__PURE__ */ new Set();
4701
5629
  for (const [, entry] of indexRegistry) {
4702
5630
  if (entry.embeddingAdapter?.shutdown) {
@@ -4979,14 +5907,23 @@ function providedDocId(document) {
4979
5907
  const id = document.id;
4980
5908
  return typeof id === "string" && id.length > 0 ? id : void 0;
4981
5909
  }
5910
+ function admitInsert(ctx, indexName, manager, docId) {
5911
+ if (!ctx.isRebalancing(indexName)) {
5912
+ manager.assertCapacity();
5913
+ return;
5914
+ }
5915
+ const bufferedState = ctx.bufferedDocState(indexName, docId);
5916
+ const exists = bufferedState !== void 0 ? bufferedState === "present" : manager.has(docId);
5917
+ if (exists) {
5918
+ throw new NarsilError(ErrorCodes.DOC_ALREADY_EXISTS, `Document "${docId}" already exists`, { docId });
5919
+ }
5920
+ manager.assertCapacity(ctx.pendingRebalanceWrites(indexName), ctx.rebalanceTargetPartitionCount(indexName));
5921
+ }
4982
5922
  async function insertDocument(ctx, indexName, document, docId, options) {
4983
5923
  ctx.guardShutdown();
4984
5924
  const entry = ctx.requireIndex(indexName);
4985
5925
  const resolvedDocId = docId ?? providedDocId(document) ?? ctx.idGenerator();
4986
5926
  validateDocId(resolvedDocId);
4987
- if (ctx.bufferIfRebalancing(indexName, { action: "insert", docId: resolvedDocId, document, indexName })) {
4988
- return resolvedDocId;
4989
- }
4990
5927
  await ctx.pluginRegistry.runHook("beforeInsert", { indexName, docId: resolvedDocId, document });
4991
5928
  if (entry.config.required && entry.config.required.length > 0) {
4992
5929
  validateRequiredFields(document, entry.config.required);
@@ -5008,6 +5945,7 @@ async function insertDocument(ctx, indexName, document, docId, options) {
5008
5945
  }
5009
5946
  }
5010
5947
  const insertManager = ctx.requireManager(indexName);
5948
+ admitInsert(ctx, indexName, insertManager, resolvedDocId);
5011
5949
  const insertVecIndexes = insertManager.getVectorIndexes();
5012
5950
  const { partitionDoc, extractedVectors } = prepareDocumentVectors(
5013
5951
  document,
@@ -5018,7 +5956,13 @@ async function insertDocument(ctx, indexName, document, docId, options) {
5018
5956
  validateVectorDimensions(extractedVectors, insertVecIndexes);
5019
5957
  }
5020
5958
  let inserted = false;
5959
+ let buffered = false;
5021
5960
  const applyInsert = async () => {
5961
+ admitInsert(ctx, indexName, insertManager, resolvedDocId);
5962
+ if (ctx.bufferIfRebalancing(indexName, { action: "insert", docId: resolvedDocId, document, indexName })) {
5963
+ buffered = true;
5964
+ return;
5965
+ }
5022
5966
  await ctx.executor.execute({
5023
5967
  type: "insert",
5024
5968
  indexName,
@@ -5053,6 +5997,10 @@ async function insertDocument(ctx, indexName, document, docId, options) {
5053
5997
  } else {
5054
5998
  await applyInsert();
5055
5999
  }
6000
+ if (buffered) {
6001
+ ctx.checkWatermark(indexName);
6002
+ return resolvedDocId;
6003
+ }
5056
6004
  try {
5057
6005
  await ctx.pluginRegistry.runHook("afterInsert", { indexName, docId: resolvedDocId, document });
5058
6006
  } catch (err) {
@@ -5072,6 +6020,7 @@ async function insertDocument(ctx, indexName, document, docId, options) {
5072
6020
  vecIndex.scheduleBuild();
5073
6021
  }
5074
6022
  }
6023
+ ctx.checkWatermark(indexName);
5075
6024
  await ctx.orchestrator.checkPromotion();
5076
6025
  return resolvedDocId;
5077
6026
  }
@@ -5090,6 +6039,7 @@ async function insertDocumentBatch(ctx, indexName, documents, options) {
5090
6039
  const batchVecIndexes = batchManager.getVectorIndexes();
5091
6040
  const batchVectorFieldPaths = batchVecIndexes.size > 0 ? entry.vectorFieldPaths : /* @__PURE__ */ new Set();
5092
6041
  const touchedVectorFields = /* @__PURE__ */ new Set();
6042
+ const bufferedDocIds = /* @__PURE__ */ new Set();
5093
6043
  for (let chunkStart = 0; chunkStart < documents.length; chunkStart += BATCH_CHUNK_SIZE) {
5094
6044
  if (ctx.abortController.signal.aborted) break;
5095
6045
  const chunkEnd = Math.min(chunkStart + BATCH_CHUNK_SIZE, documents.length);
@@ -5171,8 +6121,20 @@ async function insertDocumentBatch(ctx, indexName, documents, options) {
5171
6121
  if (extractedVectors.size > 0) {
5172
6122
  validateVectorDimensions(extractedVectors, batchVecIndexes);
5173
6123
  }
6124
+ admitInsert(ctx, indexName, batchManager, batchDocId);
5174
6125
  let batchInserted = false;
6126
+ let batchBuffered = false;
5175
6127
  const applyBatchInsert = async () => {
6128
+ admitInsert(ctx, indexName, batchManager, batchDocId);
6129
+ if (ctx.bufferIfRebalancing(indexName, {
6130
+ action: "insert",
6131
+ docId: batchDocId,
6132
+ document: documents[i],
6133
+ indexName
6134
+ })) {
6135
+ batchBuffered = true;
6136
+ return;
6137
+ }
5176
6138
  const result = ctx.executor.execute({
5177
6139
  type: "insert",
5178
6140
  indexName,
@@ -5210,6 +6172,12 @@ async function insertDocumentBatch(ctx, indexName, documents, options) {
5210
6172
  } else {
5211
6173
  await applyBatchInsert();
5212
6174
  }
6175
+ if (batchBuffered) {
6176
+ bufferedDocIds.add(batchDocId);
6177
+ succeeded.push(batchDocId);
6178
+ succeededDocs.push(documents[i]);
6179
+ continue;
6180
+ }
5213
6181
  for (const fieldPath of extractedVectors.keys()) {
5214
6182
  touchedVectorFields.add(fieldPath);
5215
6183
  }
@@ -5234,6 +6202,7 @@ async function insertDocumentBatch(ctx, indexName, documents, options) {
5234
6202
  }
5235
6203
  }
5236
6204
  for (let i = 0; i < succeeded.length; i++) {
6205
+ if (bufferedDocIds.has(succeeded[i])) continue;
5237
6206
  await ctx.orchestrator.replicateToWorkers({
5238
6207
  type: "insert",
5239
6208
  indexName,
@@ -5249,6 +6218,7 @@ async function insertDocumentBatch(ctx, indexName, documents, options) {
5249
6218
  vecIndex.scheduleBuild();
5250
6219
  }
5251
6220
  }
6221
+ ctx.checkWatermark(indexName);
5252
6222
  await ctx.orchestrator.checkPromotion();
5253
6223
  return { succeeded, failed };
5254
6224
  }
@@ -5258,16 +6228,26 @@ async function removeDocument(ctx, indexName, docId) {
5258
6228
  ctx.guardShutdown();
5259
6229
  ctx.requireIndex(indexName);
5260
6230
  validateDocId(docId);
5261
- if (ctx.bufferIfRebalancing(indexName, { action: "remove", docId, indexName })) {
5262
- return;
5263
- }
5264
6231
  await ctx.pluginRegistry.runHook("beforeRemove", { indexName, docId });
5265
- if (ctx.durability && !ctx.requireManager(indexName).has(docId)) {
6232
+ const rebalancing = ctx.isRebalancing(indexName);
6233
+ if (ctx.durability && !rebalancing && !ctx.requireManager(indexName).has(docId)) {
5266
6234
  throw new NarsilError(ErrorCodes.DOC_NOT_FOUND, `Document "${docId}" not found in any partition`, { docId });
5267
6235
  }
5268
- const restoreRef = ctx.durability ? ctx.requireManager(indexName).getRef(docId) : void 0;
6236
+ const restoreRef = ctx.durability && !rebalancing ? ctx.requireManager(indexName).getRef(docId) : void 0;
5269
6237
  const restoreDoc = restoreRef ? structuredClone(restoreRef) : void 0;
6238
+ let buffered = false;
5270
6239
  const applyRemove = async () => {
6240
+ if (ctx.isRebalancing(indexName)) {
6241
+ const bufferedState = ctx.bufferedDocState(indexName, docId);
6242
+ const exists = bufferedState !== void 0 ? bufferedState === "present" : ctx.requireManager(indexName).has(docId);
6243
+ if (!exists) {
6244
+ throw new NarsilError(ErrorCodes.DOC_NOT_FOUND, `Document "${docId}" not found in any partition`, { docId });
6245
+ }
6246
+ }
6247
+ if (ctx.bufferIfRebalancing(indexName, { action: "remove", docId, indexName })) {
6248
+ buffered = true;
6249
+ return;
6250
+ }
5271
6251
  await ctx.executor.execute({ type: "remove", indexName, docId, requestId: docId });
5272
6252
  const removeVecIndexes = ctx.requireManager(indexName).getVectorIndexes();
5273
6253
  removeDocumentVectors(docId, removeVecIndexes);
@@ -5282,6 +6262,9 @@ async function removeDocument(ctx, indexName, docId) {
5282
6262
  } else {
5283
6263
  await applyRemove();
5284
6264
  }
6265
+ if (buffered) {
6266
+ return;
6267
+ }
5285
6268
  try {
5286
6269
  await ctx.pluginRegistry.runHook("afterRemove", { indexName, docId });
5287
6270
  } catch (err) {
@@ -5343,9 +6326,6 @@ async function updateDocument(ctx, indexName, docId, document) {
5343
6326
  ctx.guardShutdown();
5344
6327
  const entry = ctx.requireIndex(indexName);
5345
6328
  validateDocId(docId);
5346
- if (ctx.bufferIfRebalancing(indexName, { action: "update", docId, document, indexName })) {
5347
- return;
5348
- }
5349
6329
  if (entry.config.embedding) {
5350
6330
  if (entry.embeddingAdapter) {
5351
6331
  await embedDocumentFields(
@@ -5388,7 +6368,22 @@ async function updateDocument(ctx, indexName, docId, document) {
5388
6368
  }
5389
6369
  }
5390
6370
  const { partitionDoc } = prepareUpdatePartitionDoc(document, updateExtractedVectors);
6371
+ let buffered = false;
5391
6372
  const applyUpdate = async () => {
6373
+ if (ctx.isRebalancing(indexName)) {
6374
+ const bufferedState = ctx.bufferedDocState(indexName, docId);
6375
+ const exists = bufferedState !== void 0 ? bufferedState === "present" : updateManager.has(docId);
6376
+ if (!exists) {
6377
+ updateManager.assertCapacity(
6378
+ ctx.pendingRebalanceWrites(indexName),
6379
+ ctx.rebalanceTargetPartitionCount(indexName)
6380
+ );
6381
+ }
6382
+ }
6383
+ if (ctx.bufferIfRebalancing(indexName, { action: "update", docId, document, indexName })) {
6384
+ buffered = true;
6385
+ return;
6386
+ }
5392
6387
  await ctx.executor.execute({
5393
6388
  type: "update",
5394
6389
  indexName,
@@ -5428,6 +6423,9 @@ async function updateDocument(ctx, indexName, docId, document) {
5428
6423
  } else {
5429
6424
  await applyUpdate();
5430
6425
  }
6426
+ if (buffered) {
6427
+ return;
6428
+ }
5431
6429
  try {
5432
6430
  await ctx.pluginRegistry.runHook("afterUpdate", {
5433
6431
  indexName,
@@ -5892,13 +6890,13 @@ function highlightField(text, queryTokens, language, options) {
5892
6890
  }
5893
6891
 
5894
6892
  // src/engine/query/highlight.ts
5895
- function applyHighlights(hits, params, language, config) {
6893
+ function applyHighlights(hits, params, language, analysis) {
5896
6894
  if (!params.highlight) return;
5897
6895
  const queryTokenResult = tokenize(params.term ?? "", language, {
5898
6896
  stem: true,
5899
6897
  removeStopWords: true,
5900
- customTokenizer: config.tokenizer,
5901
- stopWordOverride: config.stopWords
6898
+ customTokenizer: analysis.customTokenizer,
6899
+ stopWordOverride: analysis.stopWords
5902
6900
  });
5903
6901
  let prefixToken;
5904
6902
  if (params.prefix === true && params.exact !== true && queryTokenResult.tokens.length > 0) {
@@ -5906,8 +6904,8 @@ function applyHighlights(hits, params, language, config) {
5906
6904
  const unstemmed = tokenize(params.term ?? "", language, {
5907
6905
  stem: false,
5908
6906
  removeStopWords: true,
5909
- customTokenizer: config.tokenizer,
5910
- stopWordOverride: config.stopWords
6907
+ customTokenizer: analysis.customTokenizer,
6908
+ stopWordOverride: analysis.stopWords
5911
6909
  });
5912
6910
  for (const t of unstemmed.tokens) {
5913
6911
  if (t.position === lastPosition) {
@@ -5930,8 +6928,64 @@ function applyHighlights(hits, params, language, config) {
5930
6928
  });
5931
6929
  }
5932
6930
  }
5933
- hit.highlights = highlights;
6931
+ hit.highlights = highlights;
6932
+ }
6933
+ }
6934
+
6935
+ // src/engine/query/shared.ts
6936
+ function scoringConfigFor(params, context) {
6937
+ const scoringMode = params.scoring ?? context.config.defaultScoring ?? "local";
6938
+ if (scoringMode !== "broadcast") {
6939
+ return { scoringMode };
6940
+ }
6941
+ return { scoringMode, globalStats: context.broadcastStats?.(context.indexName) };
6942
+ }
6943
+ function broadcastStatsForWorker(params, context, scoring) {
6944
+ if (scoring.scoringMode !== "broadcast" || scoring.globalStats === void 0) {
6945
+ return void 0;
6946
+ }
6947
+ const term = params.term;
6948
+ if (term === void 0 || term.trim().length === 0) {
6949
+ return void 0;
6950
+ }
6951
+ return pruneStatsToQueryTerms(scoring.globalStats, term, context.language, context.manager.analysis);
6952
+ }
6953
+ function searchOptionsFor(manager) {
6954
+ return {
6955
+ bm25Params: manager.config.bm25,
6956
+ stopWords: manager.analysis.stopWords,
6957
+ customTokenizer: manager.analysis.customTokenizer
6958
+ };
6959
+ }
6960
+ function collectFilterDocIds(manager, params, schema) {
6961
+ const filterDocIds = /* @__PURE__ */ new Set();
6962
+ if (!params.filters) return filterDocIds;
6963
+ for (const partition of manager.getAllPartitions()) {
6964
+ const partitionFiltered = partition.applyFilters(params.filters, schema);
6965
+ for (const docId of partitionFiltered) {
6966
+ filterDocIds.add(docId);
6967
+ }
5934
6968
  }
6969
+ return filterDocIds;
6970
+ }
6971
+ function vectorResultsToScored(results) {
6972
+ return results.map((r) => ({
6973
+ docId: r.docId,
6974
+ score: r.score,
6975
+ termFrequencies: {},
6976
+ fieldLengths: {},
6977
+ idf: {}
6978
+ }));
6979
+ }
6980
+ function resolveVectorIndex(manager, fieldName) {
6981
+ return manager.getVectorIndexes().get(fieldName);
6982
+ }
6983
+ function clampAlpha(alpha) {
6984
+ if (alpha === void 0) return 0.5;
6985
+ if (!Number.isFinite(alpha)) return 0.5;
6986
+ if (alpha < 0) return 0;
6987
+ if (alpha > 1) return 1;
6988
+ return alpha;
5935
6989
  }
5936
6990
 
5937
6991
  // src/search/fusion.ts
@@ -6015,38 +7069,6 @@ function minMaxNormalize(docs) {
6015
7069
  return docs.map((d) => ({ docId: d.docId, score: (d.score - min) / range }));
6016
7070
  }
6017
7071
 
6018
- // src/engine/query/shared.ts
6019
- function collectFilterDocIds(manager, params, schema) {
6020
- const filterDocIds = /* @__PURE__ */ new Set();
6021
- if (!params.filters) return filterDocIds;
6022
- for (const partition of manager.getAllPartitions()) {
6023
- const partitionFiltered = partition.applyFilters(params.filters, schema);
6024
- for (const docId of partitionFiltered) {
6025
- filterDocIds.add(docId);
6026
- }
6027
- }
6028
- return filterDocIds;
6029
- }
6030
- function vectorResultsToScored(results) {
6031
- return results.map((r) => ({
6032
- docId: r.docId,
6033
- score: r.score,
6034
- termFrequencies: {},
6035
- fieldLengths: {},
6036
- idf: {}
6037
- }));
6038
- }
6039
- function resolveVectorIndex(manager, fieldName) {
6040
- return manager.getVectorIndexes().get(fieldName);
6041
- }
6042
- function clampAlpha(alpha) {
6043
- if (alpha === void 0) return 0.5;
6044
- if (!Number.isFinite(alpha)) return 0.5;
6045
- if (alpha < 0) return 0;
6046
- if (alpha > 1) return 1;
6047
- return alpha;
6048
- }
6049
-
6050
7072
  // src/engine/query/vector.ts
6051
7073
  function executeVectorSearch(params, manager, config, limit, offset) {
6052
7074
  const vectorConfig = params.vector;
@@ -6075,7 +7097,8 @@ function executeVectorSearch(params, manager, config, limit, offset) {
6075
7097
  const scored = vectorResultsToScored(results);
6076
7098
  return { scored, totalMatched: scored.length };
6077
7099
  }
6078
- async function executeHybridSearch(params, manager, language, config, workerSearch, indexName, limit, offset) {
7100
+ async function executeHybridSearch(params, context, limit, offset) {
7101
+ const { manager, language, config, workerSearch, indexName } = context;
6079
7102
  const { vector: _vector, mode: _mode, hybrid: _hybrid, ...textOnlyParams } = params;
6080
7103
  let filterDocIds;
6081
7104
  if (params.filters) {
@@ -6085,22 +7108,18 @@ async function executeHybridSearch(params, manager, language, config, workerSear
6085
7108
  }
6086
7109
  }
6087
7110
  let textFanOutResult;
6088
- const textWorkerResult = workerSearch ? await workerSearch(indexName, textOnlyParams) : null;
7111
+ const scoring = scoringConfigFor(params, context);
7112
+ const textWorkerResult = workerSearch ? await workerSearch(indexName, textOnlyParams, broadcastStatsForWorker(textOnlyParams, context, scoring)) : null;
6089
7113
  if (textWorkerResult) {
6090
7114
  textFanOutResult = textWorkerResult;
6091
7115
  } else {
6092
- const searchOptions = {
6093
- bm25Params: config.bm25,
6094
- stopWords: config.stopWords,
6095
- customTokenizer: config.tokenizer
6096
- };
6097
7116
  textFanOutResult = await fanOutQuery(
6098
7117
  manager,
6099
7118
  textOnlyParams,
6100
7119
  language,
6101
7120
  config.schema,
6102
- { scoringMode: params.scoring ?? config.defaultScoring ?? "local" },
6103
- searchOptions
7121
+ scoring,
7122
+ searchOptionsFor(manager)
6104
7123
  );
6105
7124
  }
6106
7125
  const vectorConfig = params.vector;
@@ -6164,25 +7183,14 @@ async function executeQuery(params, context) {
6164
7183
  if (isVectorOnly && hasGlobalVectorIndex) {
6165
7184
  fanOutResult = executeVectorSearch(params, manager, config, limit, offset);
6166
7185
  } else if (isHybridMode && hasGlobalVectorIndex) {
6167
- fanOutResult = await executeHybridSearch(params, manager, language, config, workerSearch, indexName, limit, offset);
7186
+ fanOutResult = await executeHybridSearch(params, context, limit, offset);
6168
7187
  } else {
6169
- const workerResult = workerSearch ? await workerSearch(indexName, params) : null;
7188
+ const scoring = scoringConfigFor(params, context);
7189
+ const workerResult = workerSearch ? await workerSearch(indexName, params, broadcastStatsForWorker(params, context, scoring)) : null;
6170
7190
  if (workerResult) {
6171
7191
  fanOutResult = workerResult;
6172
7192
  } else {
6173
- const searchOptions = {
6174
- bm25Params: config.bm25,
6175
- stopWords: config.stopWords,
6176
- customTokenizer: config.tokenizer
6177
- };
6178
- fanOutResult = await fanOutQuery(
6179
- manager,
6180
- params,
6181
- language,
6182
- config.schema,
6183
- { scoringMode: params.scoring ?? config.defaultScoring ?? "local" },
6184
- searchOptions
6185
- );
7193
+ fanOutResult = await fanOutQuery(manager, params, language, config.schema, scoring, searchOptionsFor(manager));
6186
7194
  }
6187
7195
  }
6188
7196
  const needsFullHits = params.sort !== void 0 || params.group !== void 0 || params.pinned !== void 0 || params.searchAfter !== void 0;
@@ -6233,7 +7241,7 @@ async function executeQuery(params, context) {
6233
7241
  }
6234
7242
  }
6235
7243
  if (params.highlight) {
6236
- applyHighlights(paginated, params, language, config);
7244
+ applyHighlights(paginated, params, language, manager.analysis);
6237
7245
  }
6238
7246
  const elapsed = now() - startTime;
6239
7247
  return {
@@ -6261,34 +7269,21 @@ async function executePreflight(params, context) {
6261
7269
  const result = executeVectorSearch(params, manager, config, preflightLimit, preflightOffset);
6262
7270
  totalMatched = result.totalMatched;
6263
7271
  } else if (isHybridMode && hasGlobalVectorIndex) {
6264
- const result = await executeHybridSearch(
6265
- params,
6266
- manager,
6267
- language,
6268
- config,
6269
- workerSearch,
6270
- indexName,
6271
- preflightLimit,
6272
- preflightOffset
6273
- );
7272
+ const result = await executeHybridSearch(params, context, preflightLimit, preflightOffset);
6274
7273
  totalMatched = result.totalMatched;
6275
7274
  } else {
6276
- const workerResult = workerSearch ? await workerSearch(indexName, params) : null;
7275
+ const scoring = scoringConfigFor(params, context);
7276
+ const workerResult = workerSearch ? await workerSearch(indexName, params, broadcastStatsForWorker(params, context, scoring)) : null;
6277
7277
  if (workerResult) {
6278
7278
  totalMatched = workerResult.totalMatched;
6279
7279
  } else {
6280
- const searchOptions = {
6281
- bm25Params: config.bm25,
6282
- stopWords: config.stopWords,
6283
- customTokenizer: config.tokenizer
6284
- };
6285
7280
  const fanOutResult = await fanOutQuery(
6286
7281
  manager,
6287
7282
  params,
6288
7283
  language,
6289
7284
  config.schema,
6290
- { scoringMode: params.scoring ?? config.defaultScoring ?? "local" },
6291
- searchOptions
7285
+ scoring,
7286
+ searchOptionsFor(manager)
6292
7287
  );
6293
7288
  totalMatched = fanOutResult.totalMatched;
6294
7289
  }
@@ -6300,6 +7295,7 @@ async function executePreflight(params, context) {
6300
7295
  // src/partitioning/write-ahead-queue.ts
6301
7296
  function createWriteAheadQueue(maxSize = 1e4) {
6302
7297
  const entries = [];
7298
+ const docStates = /* @__PURE__ */ new Map();
6303
7299
  let nextSequence = 1;
6304
7300
  return {
6305
7301
  push(entry) {
@@ -6312,6 +7308,7 @@ function createWriteAheadQueue(maxSize = 1e4) {
6312
7308
  }
6313
7309
  const seq = nextSequence++;
6314
7310
  entries.push({ ...entry, sequenceNumber: seq });
7311
+ docStates.set(entry.docId, entry.action === "remove" ? "absent" : "present");
6315
7312
  return seq;
6316
7313
  },
6317
7314
  drain() {
@@ -6321,6 +7318,10 @@ function createWriteAheadQueue(maxSize = 1e4) {
6321
7318
  },
6322
7319
  clear() {
6323
7320
  entries.length = 0;
7321
+ docStates.clear();
7322
+ },
7323
+ bufferedDocState(docId) {
7324
+ return docStates.get(docId);
6324
7325
  },
6325
7326
  get size() {
6326
7327
  return entries.length;
@@ -6332,6 +7333,135 @@ function createWriteAheadQueue(maxSize = 1e4) {
6332
7333
  }
6333
7334
 
6334
7335
  // src/engine/rebalance-executor.ts
7336
+ function warnHookError(hook, err) {
7337
+ console.warn(`${hook} plugin hook error:`, err instanceof Error ? err.message : String(err));
7338
+ }
7339
+ function applyReplayUpdateVectors(entry, vectorFieldPaths) {
7340
+ const updateVectors = /* @__PURE__ */ new Map();
7341
+ for (const fieldPath of vectorFieldPaths) {
7342
+ updateVectors.set(fieldPath, extractVectorFromDoc(entry.document, fieldPath));
7343
+ }
7344
+ let updateDoc = entry.document;
7345
+ if (updateVectors.size > 0) {
7346
+ updateDoc = structuredClone(entry.document);
7347
+ for (const fieldPath of updateVectors.keys()) {
7348
+ deleteNestedValue(updateDoc, fieldPath);
7349
+ }
7350
+ }
7351
+ return { updateDoc, updateVectors };
7352
+ }
7353
+ async function replayEntry(manager, indexName, entry, ctx) {
7354
+ const vecIndexes = manager.getVectorIndexes();
7355
+ const vectorFieldPaths = vecIndexes.size > 0 ? ctx.requireIndex(indexName).vectorFieldPaths : /* @__PURE__ */ new Set();
7356
+ if (entry.action === "insert" && entry.document) {
7357
+ const { partitionDoc, extractedVectors } = prepareDocumentVectors(
7358
+ entry.document,
7359
+ vectorFieldPaths,
7360
+ vecIndexes
7361
+ );
7362
+ const overwrote = manager.has(entry.docId);
7363
+ if (overwrote) {
7364
+ removeDocumentVectors(entry.docId, vecIndexes);
7365
+ manager.remove(entry.docId);
7366
+ }
7367
+ manager.insert(entry.docId, partitionDoc);
7368
+ if (extractedVectors.size > 0) {
7369
+ insertDocumentVectors(entry.docId, extractedVectors, vecIndexes);
7370
+ for (const fieldPath of extractedVectors.keys()) {
7371
+ vecIndexes.get(fieldPath)?.scheduleBuild();
7372
+ }
7373
+ }
7374
+ try {
7375
+ await ctx.pluginRegistry.runHook("afterInsert", { indexName, docId: entry.docId, document: entry.document });
7376
+ } catch (err) {
7377
+ warnHookError("afterInsert", err);
7378
+ }
7379
+ await ctx.orchestrator.replicateToWorkers({
7380
+ type: overwrote ? "update" : "insert",
7381
+ indexName,
7382
+ docId: entry.docId,
7383
+ document: entry.document,
7384
+ requestId: `replicate-insert-${entry.docId}`
7385
+ });
7386
+ return;
7387
+ }
7388
+ if (entry.action === "remove") {
7389
+ manager.remove(entry.docId);
7390
+ removeDocumentVectors(entry.docId, vecIndexes);
7391
+ try {
7392
+ await ctx.pluginRegistry.runHook("afterRemove", { indexName, docId: entry.docId });
7393
+ } catch (err) {
7394
+ warnHookError("afterRemove", err);
7395
+ }
7396
+ await ctx.orchestrator.replicateToWorkers({
7397
+ type: "remove",
7398
+ indexName,
7399
+ docId: entry.docId,
7400
+ requestId: `replicate-remove-${entry.docId}`
7401
+ });
7402
+ return;
7403
+ }
7404
+ if (entry.action === "update" && entry.document) {
7405
+ const oldDocument = manager.get(entry.docId);
7406
+ const { updateDoc, updateVectors } = applyReplayUpdateVectors(entry, vectorFieldPaths);
7407
+ if (manager.has(entry.docId)) {
7408
+ manager.update(entry.docId, updateDoc);
7409
+ } else {
7410
+ manager.insert(entry.docId, updateDoc);
7411
+ }
7412
+ for (const [fieldPath, newVec] of updateVectors) {
7413
+ const vecIndex = vecIndexes.get(fieldPath);
7414
+ if (!vecIndex) continue;
7415
+ if (newVec === null) {
7416
+ if (vecIndex.has(entry.docId)) {
7417
+ vecIndex.remove(entry.docId);
7418
+ }
7419
+ } else {
7420
+ const oldVec = vecIndex.getVector(entry.docId);
7421
+ if (!vectorsEqual(oldVec, newVec)) {
7422
+ if (vecIndex.has(entry.docId)) {
7423
+ vecIndex.remove(entry.docId);
7424
+ }
7425
+ vecIndex.insert(entry.docId, newVec);
7426
+ vecIndex.scheduleBuild();
7427
+ }
7428
+ }
7429
+ }
7430
+ try {
7431
+ await ctx.pluginRegistry.runHook("afterUpdate", {
7432
+ indexName,
7433
+ docId: entry.docId,
7434
+ oldDocument: oldDocument ?? {},
7435
+ newDocument: entry.document
7436
+ });
7437
+ } catch (err) {
7438
+ warnHookError("afterUpdate", err);
7439
+ }
7440
+ await ctx.orchestrator.replicateToWorkers({
7441
+ type: "update",
7442
+ indexName,
7443
+ docId: entry.docId,
7444
+ document: entry.document,
7445
+ requestId: `replicate-update-${entry.docId}`
7446
+ });
7447
+ }
7448
+ }
7449
+ async function replayQueued(manager, indexName, waq, ctx) {
7450
+ while (true) {
7451
+ const entries = waq.drain();
7452
+ if (entries.length === 0) break;
7453
+ for (const entry of entries) {
7454
+ try {
7455
+ await replayEntry(manager, indexName, entry, ctx);
7456
+ } catch (replayErr) {
7457
+ const isMissing = replayErr instanceof NarsilError && replayErr.code === ErrorCodes.DOC_NOT_FOUND;
7458
+ if (!isMissing) {
7459
+ console.warn(`WAQ replay failed for ${entry.action} on doc "${entry.docId}":`, replayErr);
7460
+ }
7461
+ }
7462
+ }
7463
+ }
7464
+ }
6335
7465
  async function executeRebalance(manager, indexName, targetPartitionCount, ctx) {
6336
7466
  if (targetPartitionCount <= 0 || !Number.isInteger(targetPartitionCount)) {
6337
7467
  throw new NarsilError(
@@ -6340,6 +7470,15 @@ async function executeRebalance(manager, indexName, targetPartitionCount, ctx) {
6340
7470
  { targetPartitionCount }
6341
7471
  );
6342
7472
  }
7473
+ const entry = ctx.requireIndex(indexName);
7474
+ const maxPartitions = entry.config.partitions?.maxPartitions;
7475
+ if (maxPartitions !== void 0 && targetPartitionCount > maxPartitions) {
7476
+ throw new NarsilError(
7477
+ ErrorCodes.PARTITION_CAPACITY_EXCEEDED,
7478
+ `Target partition count (${targetPartitionCount}) is above the maximum of ${maxPartitions} partitions`,
7479
+ { targetPartitionCount, maxPartitions }
7480
+ );
7481
+ }
6343
7482
  if (ctx.rebalancingIndexes.has(indexName)) {
6344
7483
  throw new NarsilError(
6345
7484
  ErrorCodes.PARTITION_REBALANCING_BACKPRESSURE,
@@ -6352,6 +7491,8 @@ async function executeRebalance(manager, indexName, targetPartitionCount, ctx) {
6352
7491
  const waq = createWriteAheadQueue();
6353
7492
  ctx.waqMap.set(indexName, waq);
6354
7493
  ctx.rebalancingIndexes.add(indexName);
7494
+ ctx.rebalanceTargets.set(indexName, targetPartitionCount);
7495
+ const wasPromoted = ctx.orchestrator.desyncIndex(indexName);
6355
7496
  try {
6356
7497
  const oldCount = manager.partitionCount;
6357
7498
  await ctx.rebalancer.rebalance(manager, targetPartitionCount, ctx.router, (progress) => {
@@ -6364,71 +7505,36 @@ async function executeRebalance(manager, indexName, targetPartitionCount, ctx) {
6364
7505
  }
6365
7506
  }
6366
7507
  });
6367
- const entries = waq.drain();
6368
- const appliedSeqs = ctx.lastAppliedSeqMap.get(indexName) ?? /* @__PURE__ */ new Map();
6369
- const rebalanceEntry = ctx.requireIndex(indexName);
6370
- const rebalanceVecIndexes = manager.getVectorIndexes();
6371
- const rebalanceVectorFieldPaths = rebalanceVecIndexes.size > 0 ? rebalanceEntry.vectorFieldPaths : /* @__PURE__ */ new Set();
6372
- for (const entry of entries) {
6373
- const partitionLastSeq = appliedSeqs.get(0) ?? 0;
6374
- if (entry.sequenceNumber <= partitionLastSeq) continue;
6375
- try {
6376
- if (entry.action === "insert" && entry.document) {
6377
- const { partitionDoc, extractedVectors } = prepareDocumentVectors(
6378
- entry.document,
6379
- rebalanceVectorFieldPaths,
6380
- rebalanceVecIndexes
6381
- );
6382
- manager.insert(entry.docId, partitionDoc);
6383
- if (extractedVectors.size > 0) {
6384
- insertDocumentVectors(entry.docId, extractedVectors, rebalanceVecIndexes);
6385
- }
6386
- } else if (entry.action === "remove") {
6387
- manager.remove(entry.docId);
6388
- removeDocumentVectors(entry.docId, rebalanceVecIndexes);
6389
- } else if (entry.action === "update" && entry.document) {
6390
- const replayUpdateVectors = /* @__PURE__ */ new Map();
6391
- for (const fieldPath of rebalanceVectorFieldPaths) {
6392
- const newVec = extractVectorFromDoc(entry.document, fieldPath);
6393
- replayUpdateVectors.set(fieldPath, newVec);
6394
- }
6395
- const replayUpdateDoc = replayUpdateVectors.size > 0 ? structuredClone(entry.document) : entry.document;
6396
- if (replayUpdateVectors.size > 0) {
6397
- for (const fieldPath of replayUpdateVectors.keys()) {
6398
- deleteNestedValue(replayUpdateDoc, fieldPath);
6399
- }
6400
- }
6401
- manager.update(entry.docId, replayUpdateDoc);
6402
- for (const [fieldPath, newVec] of replayUpdateVectors) {
6403
- const vecIndex = rebalanceVecIndexes.get(fieldPath);
6404
- if (!vecIndex) continue;
6405
- if (newVec === null) {
6406
- if (vecIndex.has(entry.docId)) {
6407
- vecIndex.remove(entry.docId);
6408
- }
6409
- } else {
6410
- const oldVec = vecIndex.getVector(entry.docId);
6411
- if (!vectorsEqual(oldVec, newVec)) {
6412
- vecIndex.remove(entry.docId);
6413
- vecIndex.insert(entry.docId, newVec);
6414
- }
6415
- }
6416
- }
6417
- }
6418
- } catch (replayErr) {
6419
- const isDuplicate = replayErr instanceof NarsilError && replayErr.code === ErrorCodes.DOC_ALREADY_EXISTS;
6420
- const isMissing = replayErr instanceof NarsilError && replayErr.code === ErrorCodes.DOC_NOT_FOUND;
6421
- if (!isDuplicate && !isMissing) {
6422
- console.warn(`WAQ replay failed for ${entry.action} on doc "${entry.docId}":`, replayErr);
6423
- }
6424
- }
6425
- appliedSeqs.set(0, entry.sequenceNumber);
7508
+ await replayQueued(manager, indexName, waq, ctx);
7509
+ if (ctx.durabilityManager) {
7510
+ await ctx.durabilityManager.persistMetadata(indexName);
7511
+ await ctx.durabilityManager.checkpoint(indexName);
7512
+ await replayQueued(manager, indexName, waq, ctx);
7513
+ }
7514
+ await ctx.orchestrator.resyncIndex(indexName, wasPromoted);
7515
+ do {
7516
+ await replayQueued(manager, indexName, waq, ctx);
7517
+ } while (waq.size > 0);
7518
+ } catch (err) {
7519
+ try {
7520
+ await replayQueued(manager, indexName, waq, ctx);
7521
+ await ctx.orchestrator.resyncIndex(indexName, wasPromoted);
7522
+ do {
7523
+ await replayQueued(manager, indexName, waq, ctx);
7524
+ } while (waq.size > 0);
7525
+ } catch (recoveryErr) {
7526
+ console.warn(
7527
+ `Rebalance failure recovery for index "${indexName}" did not complete:`,
7528
+ recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr)
7529
+ );
6426
7530
  }
6427
- ctx.lastAppliedSeqMap.set(indexName, appliedSeqs);
7531
+ throw err;
6428
7532
  } finally {
6429
7533
  ctx.rebalancingIndexes.delete(indexName);
7534
+ ctx.rebalanceTargets.delete(indexName);
6430
7535
  ctx.waqMap.delete(indexName);
6431
7536
  }
7537
+ ctx.checkWatermark(indexName);
6432
7538
  }
6433
7539
 
6434
7540
  // src/engine/resolve-vector-text.ts
@@ -6474,8 +7580,176 @@ async function resolveVectorText(params, embeddingAdapter, signal, embeddingAdap
6474
7580
  return { ...params, vector: resolved };
6475
7581
  }
6476
7582
 
6477
- // src/engine/snapshot.ts
7583
+ // src/engine/snapshot/restore-config.ts
7584
+ function invalid(message) {
7585
+ throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, `Invalid snapshot: ${message}`);
7586
+ }
7587
+ function isPlainObject(value) {
7588
+ return value !== null && typeof value === "object" && !Array.isArray(value);
7589
+ }
7590
+ function restoredBm25(raw) {
7591
+ if (raw === void 0) {
7592
+ return void 0;
7593
+ }
7594
+ if (!isPlainObject(raw)) {
7595
+ invalid("bm25 must be an object");
7596
+ }
7597
+ const { k1, b } = raw;
7598
+ if (k1 !== void 0 && (typeof k1 !== "number" || !Number.isFinite(k1))) {
7599
+ invalid("bm25 k1 must be a finite number");
7600
+ }
7601
+ if (b !== void 0 && (typeof b !== "number" || !Number.isFinite(b))) {
7602
+ invalid("bm25 b must be a finite number");
7603
+ }
7604
+ return {
7605
+ ...k1 !== void 0 ? { k1 } : {},
7606
+ ...b !== void 0 ? { b } : {}
7607
+ };
7608
+ }
7609
+ function restoredStopWords(name, list) {
7610
+ if (name !== void 0 && typeof name !== "string") {
7611
+ invalid("stopWords must be a name");
7612
+ }
7613
+ if (list !== void 0 && (!Array.isArray(list) || !list.every((word) => typeof word === "string"))) {
7614
+ invalid("stopWordList must be a list of words");
7615
+ }
7616
+ if (typeof name === "string" && Array.isArray(list)) {
7617
+ invalid("stopWords and stopWordList cannot both be present");
7618
+ }
7619
+ if (typeof name === "string") {
7620
+ return name;
7621
+ }
7622
+ if (Array.isArray(list)) {
7623
+ return new Set(list);
7624
+ }
7625
+ return void 0;
7626
+ }
7627
+ function restoredPartitionConfig(raw) {
7628
+ if (raw === void 0) {
7629
+ return void 0;
7630
+ }
7631
+ if (!isPlainObject(raw)) {
7632
+ invalid("partitionConfig must be an object");
7633
+ }
7634
+ const { maxDocsPerPartition, maxPartitions, watermark } = raw;
7635
+ if (maxDocsPerPartition !== void 0 && (typeof maxDocsPerPartition !== "number" || !Number.isInteger(maxDocsPerPartition) || maxDocsPerPartition < 1)) {
7636
+ invalid("partitionConfig maxDocsPerPartition must be a positive integer");
7637
+ }
7638
+ if (maxPartitions !== void 0 && (typeof maxPartitions !== "number" || !Number.isInteger(maxPartitions) || maxPartitions < 1)) {
7639
+ invalid("partitionConfig maxPartitions must be a positive integer");
7640
+ }
7641
+ if (watermark !== void 0 && (typeof watermark !== "number" || !(watermark > 0) || watermark > 1)) {
7642
+ invalid("partitionConfig watermark must be above 0 and at most 1");
7643
+ }
7644
+ return {
7645
+ ...typeof maxDocsPerPartition === "number" ? { maxDocsPerPartition } : {},
7646
+ ...typeof maxPartitions === "number" ? { maxPartitions } : {},
7647
+ ...typeof watermark === "number" ? { watermark } : {}
7648
+ };
7649
+ }
7650
+ function restoredScoring(raw) {
7651
+ if (raw === void 0) {
7652
+ return void 0;
7653
+ }
7654
+ if (raw !== "local" && raw !== "dfs" && raw !== "broadcast") {
7655
+ invalid("defaultScoring must be 'local', 'dfs', or 'broadcast'");
7656
+ }
7657
+ return raw;
7658
+ }
7659
+ function restoredBoolean(raw, field) {
7660
+ if (raw === void 0) {
7661
+ return void 0;
7662
+ }
7663
+ if (typeof raw !== "boolean") {
7664
+ invalid(`${field} must be a boolean`);
7665
+ }
7666
+ return raw;
7667
+ }
7668
+ function restoredRequired(raw) {
7669
+ if (raw === void 0) {
7670
+ return void 0;
7671
+ }
7672
+ if (!Array.isArray(raw) || !raw.every((field) => typeof field === "string")) {
7673
+ invalid("required must be a list of field paths");
7674
+ }
7675
+ return raw;
7676
+ }
7677
+ function restoredVectorPromotion(raw) {
7678
+ if (raw === void 0) {
7679
+ return void 0;
7680
+ }
7681
+ if (!isPlainObject(raw) || raw.hnswConfig !== void 0 && !isPlainObject(raw.hnswConfig)) {
7682
+ invalid("vectorPromotion must be an object");
7683
+ }
7684
+ return raw;
7685
+ }
7686
+ function restoredEmbedding(raw) {
7687
+ if (raw === void 0) {
7688
+ return void 0;
7689
+ }
7690
+ if (!isPlainObject(raw)) {
7691
+ invalid("embedding must be an object");
7692
+ }
7693
+ const { fields, adapter } = raw;
7694
+ if (adapter !== void 0 && typeof adapter !== "string") {
7695
+ invalid("embedding adapter must be a name");
7696
+ }
7697
+ if (!isPlainObject(fields)) {
7698
+ invalid("embedding fields must be an object");
7699
+ }
7700
+ for (const value of Object.values(fields)) {
7701
+ const isPath = typeof value === "string";
7702
+ const isPathList = Array.isArray(value) && value.every((path) => typeof path === "string");
7703
+ if (!isPath && !isPathList) {
7704
+ invalid("embedding fields must map vector fields to source paths");
7705
+ }
7706
+ }
7707
+ return {
7708
+ fields,
7709
+ ...adapter !== void 0 ? { adapter } : {}
7710
+ };
7711
+ }
7712
+ function restoredConfigFields(envelope) {
7713
+ if (envelope.tokenizer !== void 0 && typeof envelope.tokenizer !== "string") {
7714
+ invalid("tokenizer must be a name");
7715
+ }
7716
+ const stopWords = restoredStopWords(envelope.stopWords, envelope.stopWordList);
7717
+ const bm25 = restoredBm25(envelope.bm25);
7718
+ const surfaceForms = restoredBoolean(envelope.surfaceForms, "surfaceForms");
7719
+ const partitions = restoredPartitionConfig(envelope.partitionConfig);
7720
+ const defaultScoring = restoredScoring(envelope.defaultScoring);
7721
+ const trackPositions = restoredBoolean(envelope.trackPositions, "trackPositions");
7722
+ const strict = restoredBoolean(envelope.strict, "strict");
7723
+ const required = restoredRequired(envelope.required);
7724
+ const vectorPromotion = restoredVectorPromotion(envelope.vectorPromotion);
7725
+ return {
7726
+ ...typeof envelope.tokenizer === "string" ? { tokenizer: envelope.tokenizer } : {},
7727
+ ...stopWords !== void 0 ? { stopWords } : {},
7728
+ ...bm25 !== void 0 ? { bm25 } : {},
7729
+ ...surfaceForms !== void 0 ? { surfaceForms } : {},
7730
+ ...partitions !== void 0 ? { partitions } : {},
7731
+ ...defaultScoring !== void 0 ? { defaultScoring } : {},
7732
+ ...trackPositions !== void 0 ? { trackPositions } : {},
7733
+ ...strict !== void 0 ? { strict } : {},
7734
+ ...required !== void 0 ? { required } : {},
7735
+ ...vectorPromotion !== void 0 ? { vectorPromotion } : {}
7736
+ };
7737
+ }
7738
+
7739
+ // src/engine/snapshot/index.ts
6478
7740
  async function createSnapshot(manager, entry) {
7741
+ if (entry.config.tokenizer !== void 0 && typeof entry.config.tokenizer !== "string") {
7742
+ throw new NarsilError(
7743
+ ErrorCodes.CONFIG_INVALID,
7744
+ "A snapshot persists no code, so the index tokenizer must be a name registered with registerTokenizer"
7745
+ );
7746
+ }
7747
+ if (typeof entry.config.stopWords === "function") {
7748
+ throw new NarsilError(
7749
+ ErrorCodes.CONFIG_INVALID,
7750
+ "A snapshot persists no code, so the index stop words must be a name registered with registerStopWords"
7751
+ );
7752
+ }
6479
7753
  const partitionBuffers = [];
6480
7754
  for (let i = 0; i < manager.partitionCount; i++) {
6481
7755
  partitionBuffers.push(manager.serializePartitionToBytes(i));
@@ -6485,16 +7759,41 @@ async function createSnapshot(manager, entry) {
6485
7759
  for (const [fieldPath, vecIndex] of snapshotVecIndexes) {
6486
7760
  vectorPayloads[fieldPath] = vecIndex.serialize();
6487
7761
  }
7762
+ const config = entry.config;
7763
+ const bm25 = config.bm25;
6488
7764
  const { encode: encode10 } = await import('@msgpack/msgpack');
6489
7765
  return encode10({
6490
7766
  version: 2,
6491
- schema: entry.config.schema,
7767
+ schema: config.schema,
6492
7768
  language: entry.language.name,
7769
+ analysisRevision: entry.language.revision,
7770
+ ...typeof config.tokenizer === "string" ? { tokenizer: config.tokenizer } : {},
7771
+ ...typeof config.stopWords === "string" ? { stopWords: config.stopWords } : {},
7772
+ ...config.stopWords instanceof Set ? { stopWordList: [...config.stopWords].sort() } : {},
7773
+ ...bm25 !== void 0 ? {
7774
+ bm25: {
7775
+ ...bm25.k1 !== void 0 ? { k1: bm25.k1 } : {},
7776
+ ...bm25.b !== void 0 ? { b: bm25.b } : {}
7777
+ }
7778
+ } : {},
7779
+ ...config.surfaceForms === true ? { surfaceForms: true } : {},
7780
+ ...config.partitions !== void 0 ? { partitionConfig: config.partitions } : {},
7781
+ ...config.defaultScoring !== void 0 ? { defaultScoring: config.defaultScoring } : {},
7782
+ ...config.trackPositions !== void 0 ? { trackPositions: config.trackPositions } : {},
7783
+ ...config.strict !== void 0 ? { strict: config.strict } : {},
7784
+ ...config.required !== void 0 ? { required: config.required } : {},
7785
+ ...config.vectorPromotion !== void 0 ? { vectorPromotion: config.vectorPromotion } : {},
7786
+ ...config.embedding !== void 0 ? {
7787
+ embedding: {
7788
+ fields: config.embedding.fields,
7789
+ ...entry.embeddingAdapterName !== null ? { adapter: entry.embeddingAdapterName } : {}
7790
+ }
7791
+ } : {},
6493
7792
  partitions: partitionBuffers,
6494
7793
  vectorIndexes: vectorPayloads
6495
7794
  });
6496
7795
  }
6497
- async function restoreFromSnapshot(indexName, data, executor, indexRegistry, getVectorFieldPaths2, dropIndex, requireManager) {
7796
+ async function restoreFromSnapshot(indexName, data, deps) {
6498
7797
  if (!(data instanceof Uint8Array)) {
6499
7798
  throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Snapshot data must be a Uint8Array");
6500
7799
  }
@@ -6516,24 +7815,60 @@ async function restoreFromSnapshot(indexName, data, executor, indexRegistry, get
6516
7815
  if (!Array.isArray(envelope.partitions)) {
6517
7816
  throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Invalid snapshot: missing partitions");
6518
7817
  }
7818
+ const configFields = restoredConfigFields(envelope);
7819
+ const embedding = restoredEmbedding(envelope.embedding);
6519
7820
  const language = getLanguage(envelope.language);
6520
7821
  const schema = envelope.schema;
6521
7822
  validateSchema(schema);
6522
- if (indexRegistry.has(indexName)) {
6523
- await dropIndex(indexName);
7823
+ validateVectorPromotion(configFields.vectorPromotion);
7824
+ if (configFields.required !== void 0 && configFields.required.length > 0) {
7825
+ validateRequiredFieldsInSchema(configFields.required, schema);
7826
+ }
7827
+ const indexConfig = {
7828
+ schema,
7829
+ language: envelope.language,
7830
+ ...configFields,
7831
+ ...embedding !== void 0 ? {
7832
+ embedding: {
7833
+ fields: embedding.fields,
7834
+ ...embedding.adapter !== void 0 ? { adapter: embedding.adapter } : {}
7835
+ }
7836
+ } : {}
7837
+ };
7838
+ resolveIndexAnalysis(indexConfig);
7839
+ const adapterName = embedding?.adapter ?? null;
7840
+ let embeddingAdapter = null;
7841
+ if (embedding !== void 0) {
7842
+ const candidate = adapterName !== null ? deps.embeddingAdapters.get(adapterName) ?? null : deps.defaultEmbeddingAdapter;
7843
+ if (candidate) {
7844
+ validateEmbeddingConfig({ fields: embedding.fields, adapter: candidate }, schema, void 0);
7845
+ embeddingAdapter = candidate;
7846
+ }
7847
+ }
7848
+ if (deps.indexRegistry.has(indexName)) {
7849
+ await deps.dropIndex(indexName);
7850
+ }
7851
+ const storedRevision = typeof envelope.analysisRevision === "string" ? envelope.analysisRevision : null;
7852
+ deps.clearAnalysisStale(indexName);
7853
+ if (storedRevision !== language.revision) {
7854
+ deps.markAnalysisStale({
7855
+ indexName,
7856
+ language: language.name,
7857
+ storedRevision,
7858
+ currentRevision: language.revision
7859
+ });
6524
7860
  }
6525
- const indexConfig = { schema, language: envelope.language };
6526
- executor.createIndex(indexName, indexConfig, language);
6527
- const vectorFieldPaths = getVectorFieldPaths2(schema);
6528
- indexRegistry.set(indexName, {
7861
+ deps.executor.createIndex(indexName, indexConfig, language);
7862
+ const vectorFieldPaths = deps.getVectorFieldPaths(schema);
7863
+ deps.indexRegistry.set(indexName, {
6529
7864
  config: indexConfig,
6530
7865
  language,
6531
- embeddingAdapter: null,
6532
- embeddingAdapterName: null,
7866
+ embeddingAdapter,
7867
+ embeddingAdapterName: adapterName,
6533
7868
  vectorFieldPaths
6534
7869
  });
6535
7870
  try {
6536
- const manager = requireManager(indexName);
7871
+ const manager = deps.requireManager(indexName);
6537
7872
  while (manager.partitionCount < envelope.partitions.length) {
6538
7873
  manager.addPartition();
6539
7874
  }
@@ -6553,12 +7888,27 @@ async function restoreFromSnapshot(indexName, data, executor, indexRegistry, get
6553
7888
  }
6554
7889
  } catch (err) {
6555
7890
  try {
6556
- executor.dropIndex(indexName);
6557
- indexRegistry.delete(indexName);
7891
+ deps.executor.dropIndex(indexName);
7892
+ deps.indexRegistry.delete(indexName);
6558
7893
  } catch (_) {
6559
7894
  }
6560
7895
  throw err;
6561
7896
  }
7897
+ if (deps.durability) {
7898
+ const manager = deps.requireManager(indexName);
7899
+ for (let partitionId = 0; partitionId < manager.partitionCount; partitionId++) {
7900
+ for (const docId of manager.getPartition(partitionId).docIds()) {
7901
+ const document = manager.get(docId);
7902
+ if (document !== void 0) {
7903
+ await deps.durability.recordInsertOrUpdate(indexName, docId, document, noopApply);
7904
+ }
7905
+ }
7906
+ }
7907
+ await deps.durability.manager.persistMetadata(indexName);
7908
+ await deps.durability.manager.checkpoint(indexName);
7909
+ }
7910
+ }
7911
+ function noopApply() {
6562
7912
  }
6563
7913
 
6564
7914
  // src/engine/suggest.ts
@@ -6679,6 +8029,10 @@ async function createNarsil(config) {
6679
8029
  if (core.durability) {
6680
8030
  await core.durability.manager.recover();
6681
8031
  }
8032
+ if (core.invalidation) {
8033
+ await core.invalidation.start();
8034
+ }
8035
+ await core.analysisRebuild.reviewStaleIndexes();
6682
8036
  return createNarsilFromCore(core, config);
6683
8037
  }
6684
8038
  function createNarsilFromCore(core, config) {
@@ -6698,6 +8052,8 @@ function createNarsilFromCore(core, config) {
6698
8052
  mutationCtx,
6699
8053
  rebalanceCtx
6700
8054
  } = core;
8055
+ const invalidation = core.invalidation;
8056
+ const broadcastStats = invalidation === null ? void 0 : (name) => invalidation.broadcastStats(name);
6701
8057
  const narsil = {
6702
8058
  createIndex(name, indexConfig) {
6703
8059
  return createEngineIndex(core, config, name, indexConfig);
@@ -6716,7 +8072,8 @@ function createNarsilFromCore(core, config) {
6716
8072
  name,
6717
8073
  documentCount: manager?.countDocuments() ?? 0,
6718
8074
  partitionCount: manager?.partitionCount ?? 0,
6719
- language: entry.language.name
8075
+ language: entry.language.name,
8076
+ ...core.analysisRebuild.isStale(name) ? { analysisStale: true } : {}
6720
8077
  });
6721
8078
  }
6722
8079
  return infos;
@@ -6798,7 +8155,8 @@ function createNarsilFromCore(core, config) {
6798
8155
  language: entry.language,
6799
8156
  config: entry.config,
6800
8157
  workerSearch,
6801
- indexName
8158
+ indexName,
8159
+ broadcastStats
6802
8160
  });
6803
8161
  try {
6804
8162
  await pluginRegistry.runHook("afterSearch", {
@@ -6809,6 +8167,9 @@ function createNarsilFromCore(core, config) {
6809
8167
  } catch (err) {
6810
8168
  console.warn("afterSearch plugin hook error:", err);
6811
8169
  }
8170
+ if (core.analysisRebuild.isStale(indexName)) {
8171
+ result.analysisStale = true;
8172
+ }
6812
8173
  return result;
6813
8174
  },
6814
8175
  async preflight(indexName, params) {
@@ -6822,17 +8183,31 @@ function createNarsilFromCore(core, config) {
6822
8183
  entry.embeddingAdapterName
6823
8184
  );
6824
8185
  const workerSearch = orchestrator.isPromoted() ? orchestrator.searchViaWorker.bind(orchestrator) : void 0;
6825
- return executePreflight(resolvedParams, {
8186
+ const result = await executePreflight(resolvedParams, {
6826
8187
  manager,
6827
8188
  language: entry.language,
6828
8189
  config: entry.config,
6829
8190
  workerSearch,
6830
- indexName
8191
+ indexName,
8192
+ broadcastStats
6831
8193
  });
8194
+ if (core.analysisRebuild.isStale(indexName)) {
8195
+ result.analysisStale = true;
8196
+ }
8197
+ return result;
6832
8198
  },
6833
8199
  async suggest(indexName, params) {
6834
8200
  guardShutdown();
6835
- return executeSuggest(requireManager(indexName), requireIndex(indexName).language, params);
8201
+ const result = await executeSuggest(requireManager(indexName), requireIndex(indexName).language, params);
8202
+ if (core.analysisRebuild.isStale(indexName)) {
8203
+ result.analysisStale = true;
8204
+ }
8205
+ return result;
8206
+ },
8207
+ async rebuildAnalysis(indexName) {
8208
+ guardShutdown();
8209
+ requireIndex(indexName);
8210
+ await core.analysisRebuild.rebuild(indexName);
6836
8211
  },
6837
8212
  async snapshot(indexName) {
6838
8213
  guardShutdown();
@@ -6840,15 +8215,18 @@ function createNarsilFromCore(core, config) {
6840
8215
  },
6841
8216
  async restore(indexName, data) {
6842
8217
  guardShutdown();
6843
- return restoreFromSnapshot(
6844
- indexName,
6845
- data,
8218
+ return restoreFromSnapshot(indexName, data, {
6846
8219
  executor,
6847
8220
  indexRegistry,
6848
8221
  getVectorFieldPaths,
6849
- narsil.dropIndex.bind(narsil),
6850
- requireManager
6851
- );
8222
+ dropIndex: narsil.dropIndex.bind(narsil),
8223
+ requireManager,
8224
+ durability,
8225
+ embeddingAdapters: core.embeddingAdapters,
8226
+ defaultEmbeddingAdapter: config?.embedding ?? null,
8227
+ markAnalysisStale: core.analysisRebuild.markStale,
8228
+ clearAnalysisStale: core.analysisRebuild.clearStale
8229
+ });
6852
8230
  },
6853
8231
  async checkpoint(indexName) {
6854
8232
  guardShutdown();
@@ -6862,6 +8240,7 @@ function createNarsilFromCore(core, config) {
6862
8240
  requireIndex(indexName);
6863
8241
  await executor.execute({ type: "clear", indexName, requestId: indexName });
6864
8242
  await orchestrator.replicateToWorkers({ type: "clear", indexName, requestId: `replicate-clear-${indexName}` });
8243
+ core.watermarkNotifier.forget(indexName);
6865
8244
  },
6866
8245
  async rebalance(indexName, targetPartitionCount) {
6867
8246
  guardShutdown();
@@ -6878,11 +8257,18 @@ function createNarsilFromCore(core, config) {
6878
8257
  `Index "${indexName}" is currently being rebalanced`
6879
8258
  );
6880
8259
  }
8260
+ validatePartitionConfig(partitionConfig);
8261
+ if (partitionConfig.maxPartitions !== void 0 && partitionConfig.maxPartitions < manager.partitionCount) {
8262
+ throw new NarsilError(
8263
+ ErrorCodes.PARTITION_CAPACITY_EXCEEDED,
8264
+ `maxPartitions (${partitionConfig.maxPartitions}) is less than the current partition count (${manager.partitionCount})`,
8265
+ { maxPartitions: partitionConfig.maxPartitions, partitionCount: manager.partitionCount }
8266
+ );
8267
+ }
6881
8268
  const currentDocCount = manager.countDocuments();
6882
8269
  const newMaxDocs = partitionConfig.maxDocsPerPartition ?? entry.config.partitions?.maxDocsPerPartition;
6883
- const newMaxPartitions = partitionConfig.maxPartitions ?? entry.config.partitions?.maxPartitions ?? manager.partitionCount;
6884
8270
  if (newMaxDocs !== void 0) {
6885
- const newTotalCapacity = newMaxDocs * newMaxPartitions;
8271
+ const newTotalCapacity = newMaxDocs * manager.partitionCount;
6886
8272
  if (newTotalCapacity < currentDocCount) {
6887
8273
  throw new NarsilError(
6888
8274
  ErrorCodes.PARTITION_CAPACITY_EXCEEDED,
@@ -6896,6 +8282,9 @@ function createNarsilFromCore(core, config) {
6896
8282
  entry.config.partitions.maxDocsPerPartition = partitionConfig.maxDocsPerPartition;
6897
8283
  if (partitionConfig.maxPartitions !== void 0)
6898
8284
  entry.config.partitions.maxPartitions = partitionConfig.maxPartitions;
8285
+ if (partitionConfig.watermark !== void 0) entry.config.partitions.watermark = partitionConfig.watermark;
8286
+ if (durability) await durability.manager.persistMetadata(indexName);
8287
+ core.watermarkNotifier.check(indexName);
6899
8288
  },
6900
8289
  async getMemoryStats() {
6901
8290
  let estimatedIndexBytes = 0;