@dikolab/kbdb 0.2.1 → 0.3.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 (120) hide show
  1. package/README.md +59 -24
  2. package/dist/cli.cjs +462 -73
  3. package/dist/cli.cjs.map +4 -4
  4. package/dist/cli.mjs +454 -65
  5. package/dist/cli.mjs.map +4 -4
  6. package/dist/kbdb-worker.cjs +395 -60
  7. package/dist/kbdb-worker.cjs.map +4 -4
  8. package/dist/mod.cjs +18 -1
  9. package/dist/mod.cjs.map +2 -2
  10. package/dist/mod.mjs +18 -1
  11. package/dist/mod.mjs.map +2 -2
  12. package/dist/src/shared/cli/functions/dispatch-command.function.d.ts +3 -1
  13. package/dist/src/shared/cli/functions/install-default-skills.function.d.ts +12 -0
  14. package/dist/src/shared/cli/functions/run-check.function.d.ts +47 -5
  15. package/dist/src/shared/cli/functions/run-export.function.d.ts +34 -0
  16. package/dist/src/shared/cli/functions/run-gc.function.d.ts +6 -0
  17. package/dist/src/shared/cli/functions/run-learn.function.d.ts +35 -6
  18. package/dist/src/shared/cli/functions/run-recall.function.d.ts +45 -0
  19. package/dist/src/shared/cli/functions/run-search.function.d.ts +52 -15
  20. package/dist/src/shared/cli/index.d.ts +4 -0
  21. package/dist/src/shared/cli/typings/cli-options.interface.d.ts +13 -0
  22. package/dist/src/shared/cli/typings/learn-result.model.d.ts +19 -0
  23. package/dist/src/shared/cli/typings/search-display.model.d.ts +18 -0
  24. package/dist/src/shared/mcp/classes/mcp-server.class.d.ts +8 -0
  25. package/dist/src/shared/mcp/typings/mcp-client.interface.d.ts +43 -0
  26. package/dist/src/shared/version/constants/version.constant.d.ts +1 -1
  27. package/dist/src/shared/wasm-codec/functions/decode-add-section-result.function.d.ts +48 -0
  28. package/dist/src/shared/wasm-codec/functions/decode-scored-results.function.d.ts +58 -0
  29. package/dist/src/shared/wasm-codec/functions/decode-section-records.function.d.ts +33 -0
  30. package/dist/src/shared/wasm-codec/functions/encode-add-section-params.function.d.ts +52 -0
  31. package/dist/src/shared/wasm-codec/index.d.ts +2 -0
  32. package/dist/src/shared/worker-client/classes/worker-client.class.d.ts +16 -0
  33. package/dist/src/shared/worker-client/index.d.ts +1 -1
  34. package/dist/src/shared/worker-client/typings/add-section-result.model.d.ts +29 -15
  35. package/dist/src/shared/worker-client/typings/export-result.model.d.ts +14 -0
  36. package/dist/src/shared/worker-client/typings/gc-result.model.d.ts +6 -0
  37. package/dist/src/shared/worker-client/typings/search-result.model.d.ts +12 -0
  38. package/dist/src/shared/worker-client/typings/section.model.d.ts +16 -0
  39. package/dist/src/shared/worker-daemon/classes/worker-daemon.class.d.ts +35 -0
  40. package/dist/wasm/fs-database/kbdb_fs_database.d.ts +22 -1
  41. package/dist/wasm/fs-database/kbdb_fs_database.js +28 -1
  42. package/dist/wasm/fs-database/kbdb_fs_database_bg.wasm +0 -0
  43. package/dist/wasm/fs-database/kbdb_fs_database_bg.wasm.d.ts +1 -0
  44. package/dist/wasm/kb-worker/kbdb_worker.d.ts +48 -1
  45. package/dist/wasm/kb-worker/kbdb_worker.js +60 -1
  46. package/dist/wasm/kb-worker/kbdb_worker_bg.wasm +0 -0
  47. package/dist/wasm/kb-worker/kbdb_worker_bg.wasm.d.ts +2 -0
  48. package/dist/wasm/query-parser/kbdb_query_parser_bg.wasm +0 -0
  49. package/dist/worker.mjs +396 -60
  50. package/dist/worker.mjs.map +4 -4
  51. package/docs/details/cli.md +102 -15
  52. package/docs/details/library-api.md +55 -6
  53. package/docs/details/mcp-server.md +8 -4
  54. package/docs/details/search-and-ranking.md +63 -5
  55. package/docs/details/storage.md +2 -0
  56. package/docs/goals/cli.md +255 -14
  57. package/docs/goals/database.md +244 -17
  58. package/docs/goals/document.md +1 -1
  59. package/docs/goals/mcp.md +83 -14
  60. package/docs/goals/query-parser.md +98 -4
  61. package/docs/goals/query-result.md +177 -10
  62. package/docs/goals/skills.md +72 -0
  63. package/docs/goals/worker-daemon.md +54 -7
  64. package/docs/modules/rust/fs-database/functions/integrity.md +11 -0
  65. package/docs/modules/rust/fs-database/functions/section.md +23 -6
  66. package/docs/modules/rust/fs-database/module.md +1 -0
  67. package/docs/modules/rust/fs-database/types/section.md +27 -1
  68. package/docs/modules/rust/overview.md +2 -1
  69. package/docs/modules/rust/query-parser/functions/module.md +2 -2
  70. package/docs/modules/rust/query-parser/functions/pipeline.md +44 -6
  71. package/docs/modules/rust/query-parser/module.md +5 -4
  72. package/docs/modules/rust/query-parser/types/keyphrase.md +11 -0
  73. package/docs/modules/rust/query-parser/types/module.md +3 -3
  74. package/docs/modules/typescript/shared/cli/functions.md +68 -3
  75. package/package.json +1 -1
  76. package/src/shared/cli/functions/dispatch-command.function.ts +53 -1
  77. package/src/shared/cli/functions/format-output.function.ts +36 -12
  78. package/src/shared/cli/functions/generate-help.function.ts +56 -1
  79. package/src/shared/cli/functions/install-default-skills.function.ts +296 -0
  80. package/src/shared/cli/functions/parse-args.function.ts +13 -0
  81. package/src/shared/cli/functions/run-check.function.ts +87 -6
  82. package/src/shared/cli/functions/run-export.function.ts +40 -0
  83. package/src/shared/cli/functions/run-gc.function.ts +6 -0
  84. package/src/shared/cli/functions/run-init.function.ts +2 -0
  85. package/src/shared/cli/functions/run-learn.function.ts +68 -16
  86. package/src/shared/cli/functions/run-recall.function.ts +66 -0
  87. package/src/shared/cli/functions/run-search.function.ts +58 -15
  88. package/src/shared/cli/index.ts +4 -0
  89. package/src/shared/cli/typings/cli-options.interface.ts +13 -0
  90. package/src/shared/cli/typings/learn-result.model.ts +16 -0
  91. package/src/shared/cli/typings/search-display.model.ts +18 -0
  92. package/src/shared/mcp/classes/mcp-server.class.ts +30 -1
  93. package/src/shared/mcp/functions/create-tool-definitions.function.ts +46 -5
  94. package/src/shared/mcp/functions/handle-tool-call.function.ts +70 -2
  95. package/src/shared/mcp/typings/mcp-client.interface.ts +45 -0
  96. package/src/shared/version/constants/version.constant.ts +1 -1
  97. package/src/shared/wasm-codec/functions/decode-add-section-result.function.ts +112 -0
  98. package/src/shared/wasm-codec/functions/decode-scored-results.function.ts +86 -0
  99. package/src/shared/wasm-codec/functions/decode-section-records.function.ts +87 -0
  100. package/src/shared/wasm-codec/functions/encode-add-section-params.function.ts +68 -0
  101. package/src/shared/wasm-codec/index.ts +5 -0
  102. package/src/shared/worker-client/classes/worker-client.class.ts +19 -0
  103. package/src/shared/worker-client/index.ts +4 -1
  104. package/src/shared/worker-client/typings/add-section-result.model.ts +30 -15
  105. package/src/shared/worker-client/typings/export-result.model.ts +14 -0
  106. package/src/shared/worker-client/typings/gc-result.model.ts +6 -0
  107. package/src/shared/worker-client/typings/search-result.model.ts +12 -0
  108. package/src/shared/worker-client/typings/section.model.ts +16 -0
  109. package/src/shared/worker-daemon/classes/worker-daemon.class.ts +315 -46
  110. package/src/shared/worker-daemon/functions/create-host-imports.function.ts +19 -4
  111. package/src/shared/worker-daemon/functions/create-ipc-server.function.ts +5 -1
  112. package/src/wasm/fs-database/kbdb_fs_database.d.ts +22 -1
  113. package/src/wasm/fs-database/kbdb_fs_database.js +28 -1
  114. package/src/wasm/fs-database/kbdb_fs_database_bg.wasm +0 -0
  115. package/src/wasm/fs-database/kbdb_fs_database_bg.wasm.d.ts +1 -0
  116. package/src/wasm/kb-worker/kbdb_worker.d.ts +48 -1
  117. package/src/wasm/kb-worker/kbdb_worker.js +60 -1
  118. package/src/wasm/kb-worker/kbdb_worker_bg.wasm +0 -0
  119. package/src/wasm/kb-worker/kbdb_worker_bg.wasm.d.ts +2 -0
  120. package/src/wasm/query-parser/kbdb_query_parser_bg.wasm +0 -0
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  existsSync,
3
3
  readFileSync,
4
+ readdirSync,
4
5
  accessSync,
5
6
  writeFileSync,
6
7
  unlinkSync,
@@ -38,6 +39,7 @@ import {
38
39
  decodeKeyValueText,
39
40
  decodeRemoveFlag,
40
41
  decodeRecallResult,
42
+ decodeAddSectionResult,
41
43
  } from '../../wasm-codec/index.ts';
42
44
 
43
45
  import type { AddSectionInput } from '../../wasm-codec/index.ts';
@@ -89,6 +91,8 @@ const WASM_METHOD_MAP: Readonly<Partial<Record<string, string>>> = {
89
91
  listDocumentsByType: 'worker_list_docs_by_type',
90
92
  list_docs_by_type: 'worker_list_docs_by_type',
91
93
  recall: 'worker_recall',
94
+ export: 'worker_export',
95
+ exportDb: 'worker_export',
92
96
  };
93
97
 
94
98
  /** Config method name prefix. */
@@ -155,6 +159,18 @@ export class WorkerDaemon {
155
159
  private _server: Server | null = null;
156
160
  private readonly _wasmDir: string;
157
161
 
162
+ /** Queue of waiters for the single-writer lock. */
163
+ private _writeQueue: { resolve: () => void }[] = [];
164
+
165
+ /** Whether the write lock is currently held. */
166
+ private _writeLocked = false;
167
+
168
+ /** Whether the startup integrity check passed. */
169
+ private _integrityOk = true;
170
+
171
+ /** Integrity errors found at startup, if any. */
172
+ private _integrityErrors: string[] = [];
173
+
158
174
  /**
159
175
  * @param ctx - context ID (16-hex characters), the first 16 of
160
176
  * the SHA-256 hash of `targetDir`
@@ -229,6 +245,7 @@ export class WorkerDaemon {
229
245
  this._wasmModules = wasmModules;
230
246
 
231
247
  this.initWorkerContext();
248
+ this.runStartupIntegrityCheck();
232
249
 
233
250
  const pidPath = join(tmpdir(), `kbdb-${this.ctx}.pid`);
234
251
  writeFileSync(pidPath, process.pid.toString());
@@ -470,7 +487,11 @@ export class WorkerDaemon {
470
487
  params: Record<string, unknown>,
471
488
  ): unknown {
472
489
  if (method === 'status') {
473
- return this._connectionCount > 0 ? 'busy' : 'idle';
490
+ return {
491
+ status: this._connectionCount > 0 ? 'busy' : 'idle',
492
+ integrityOk: this._integrityOk,
493
+ integrityErrors: this._integrityErrors,
494
+ };
474
495
  }
475
496
 
476
497
  if (method === 'idle_at') {
@@ -493,71 +514,36 @@ export class WorkerDaemon {
493
514
  throw new IpcError(-32601, `Method not found: ${method}`);
494
515
  }
495
516
 
496
- private dispatchWasm(
517
+ private async dispatchWasm(
497
518
  method: string,
498
519
  wasmExport: string,
499
520
  params: Record<string, unknown>,
500
- ): unknown {
521
+ ): Promise<unknown> {
522
+ // Read-only operations — no write lock required.
501
523
  if (method === 'search' || method === 'query') {
502
524
  return this.handleSearch(params);
503
525
  }
504
- if (method === 'addSection' || method === 'add_section') {
505
- return this.handleAddSection(params);
506
- }
507
- if (method === 'updateSection' || method === 'update_section') {
508
- return this.handleUpdateSection(params);
509
- }
510
- if (method === 'removeSection' || method === 'remove_section') {
511
- return this.handleRemoveSection(params);
512
- }
513
526
  if (method === 'readSections' || method === 'read_sections') {
514
527
  return this.handleReadSections(params);
515
528
  }
516
529
  if (method === 'retrieveDocument' || method === 'retrieve_doc') {
517
530
  return this.handleRetrieveDoc(params);
518
531
  }
519
- if (method === 'groupSections' || method === 'group_sections') {
520
- return this.handleGroupSections(params);
521
- }
522
- if (method === 'removeGrouping' || method === 'remove_grouping') {
523
- return this.handleRemoveGrouping(params);
524
- }
525
532
  if (method === 'content' || method === 'compose') {
526
533
  return this.handleCompose(params);
527
534
  }
528
535
  if (method === 'integrityCheck' || method === 'integrity_check') {
529
536
  return this.handleIntegrityCheck();
530
537
  }
531
- if (method === 'gc') {
532
- return this.handleGc();
533
- }
534
- if (method === 'rebuildIndexes' || method === 'rebuild_indexes') {
535
- return this.handleRebuild();
536
- }
537
538
  if (method === 'dbStatus' || method === 'db_status') {
538
539
  return this.handleDbStatus();
539
540
  }
540
- if (method === 'initDirectory' || method === 'init_directory') {
541
- return this.handleInitDirectory(params);
542
- }
543
- if (method === 'invalidate') {
544
- return this.handleInvalidate(params);
545
- }
546
- if (method === 'invalidateAll' || method === 'invalidate_all') {
547
- return this.handleInvalidateAll();
548
- }
549
541
  if (method === 'get_result_status') {
550
542
  return this.handleResultStatus(params);
551
543
  }
552
544
  if (method === 'get_result') {
553
545
  return this.handleGetResult(params);
554
546
  }
555
- if (method === 'migrateDatabase') {
556
- throw new IpcError(
557
- -32601,
558
- 'migrateDatabase is not yet supported',
559
- );
560
- }
561
547
  if (method === 'listByType' || method === 'list_by_type') {
562
548
  return this.handleListByType(params);
563
549
  }
@@ -570,7 +556,74 @@ export class WorkerDaemon {
570
556
  if (method === 'recall') {
571
557
  return this.handleRecall(params);
572
558
  }
573
- return this.invokeWasmRaw(wasmExport, new Uint8Array(0));
559
+ if (method === 'export' || method === 'exportDb') {
560
+ return this.handleExport(params);
561
+ }
562
+
563
+ // Write operations — serialised through the single-writer lock.
564
+ await this.acquireWriteLock();
565
+ try {
566
+ if (method === 'invalidate') {
567
+ return this.handleInvalidate(params);
568
+ }
569
+ if (
570
+ method === 'invalidateAll' ||
571
+ method === 'invalidate_all'
572
+ ) {
573
+ return this.handleInvalidateAll();
574
+ }
575
+ if (method === 'addSection' || method === 'add_section') {
576
+ return this.handleAddSection(params);
577
+ }
578
+ if (
579
+ method === 'updateSection' ||
580
+ method === 'update_section'
581
+ ) {
582
+ return this.handleUpdateSection(params);
583
+ }
584
+ if (
585
+ method === 'removeSection' ||
586
+ method === 'remove_section'
587
+ ) {
588
+ return this.handleRemoveSection(params);
589
+ }
590
+ if (
591
+ method === 'groupSections' ||
592
+ method === 'group_sections'
593
+ ) {
594
+ return this.handleGroupSections(params);
595
+ }
596
+ if (
597
+ method === 'removeGrouping' ||
598
+ method === 'remove_grouping'
599
+ ) {
600
+ return this.handleRemoveGrouping(params);
601
+ }
602
+ if (method === 'gc') {
603
+ return this.handleGc();
604
+ }
605
+ if (
606
+ method === 'rebuildIndexes' ||
607
+ method === 'rebuild_indexes'
608
+ ) {
609
+ return this.handleRebuild();
610
+ }
611
+ if (
612
+ method === 'initDirectory' ||
613
+ method === 'init_directory'
614
+ ) {
615
+ return this.handleInitDirectory(params);
616
+ }
617
+ if (method === 'migrateDatabase') {
618
+ throw new IpcError(
619
+ -32601,
620
+ 'migrateDatabase is not yet supported',
621
+ );
622
+ }
623
+ return this.invokeWasmRaw(wasmExport, new Uint8Array(0));
624
+ } finally {
625
+ this.releaseWriteLock();
626
+ }
574
627
  }
575
628
 
576
629
  // -- Search/query (token-based async) --
@@ -643,6 +696,9 @@ export class WorkerDaemon {
643
696
  : 'text';
644
697
  const typeName =
645
698
  sectionType !== semanticType ? sectionType : null;
699
+ const tags = Array.isArray(params['tags'])
700
+ ? (params['tags'] as string[])
701
+ : [];
646
702
  const input: AddSectionInput = {
647
703
  sectionType: semanticType,
648
704
  mimeType: params['mimeType'] as string | undefined,
@@ -651,11 +707,32 @@ export class WorkerDaemon {
651
707
  description: (params['description'] as string | null) ?? null,
652
708
  docid: (params['docid'] as string | null) ?? null,
653
709
  typeName,
710
+ sourcePath:
711
+ typeof params['sourcePath'] === 'string'
712
+ ? params['sourcePath']
713
+ : undefined,
714
+ sourceMtime:
715
+ typeof params['sourceMtime'] === 'string'
716
+ ? params['sourceMtime']
717
+ : undefined,
718
+ tags,
719
+ replace: params['replace'] === true,
654
720
  };
655
721
  const encoded = encodeAddSectionParams(input);
656
722
  const bytes = this.callWasmSync('worker_add_section', encoded);
657
- const kbid = new TextDecoder().decode(bytes);
658
- return { kbid };
723
+ const decoded = decodeAddSectionResult(bytes);
724
+ const nearDups =
725
+ decoded.nearDuplicates.length > 0
726
+ ? decoded.nearDuplicates
727
+ : this.scanNearDuplicates(input.content, decoded.kbid);
728
+ return {
729
+ kbid: decoded.kbid,
730
+ indexed_terms_count: decoded.indexedTermsCount,
731
+ elapsed_ms: decoded.elapsedMs,
732
+ superseded: decoded.superseded,
733
+ warnings: decoded.warnings,
734
+ near_duplicates: nearDups,
735
+ };
659
736
  }
660
737
 
661
738
  private handleUpdateSection(
@@ -759,7 +836,18 @@ export class WorkerDaemon {
759
836
  private handleInvalidate(params: Record<string, unknown>): unknown {
760
837
  const kbid = (params['kbid'] as string | undefined) ?? '';
761
838
  const encoded = new TextEncoder().encode(kbid);
762
- this.callWasmSync('worker_invalidate', encoded);
839
+ const fn = this.getExport('worker_invalidate');
840
+ const ptr = this.wasmAlloc(encoded.length);
841
+ this.wasmWrite(ptr, encoded);
842
+ const rc = fn(ptr, encoded.length) as number;
843
+ this.wasmFree(ptr, encoded.length);
844
+ if (rc === -1) {
845
+ const errBytes = this.readReturnSlot();
846
+ throw new IpcError(
847
+ -32000,
848
+ new TextDecoder().decode(errBytes) || 'invalidate failed',
849
+ );
850
+ }
763
851
  return { ok: true };
764
852
  }
765
853
 
@@ -819,15 +907,161 @@ export class WorkerDaemon {
819
907
  }
820
908
 
821
909
  private handleGc(): unknown {
910
+ const start = Date.now();
822
911
  this.callWasmZeroArg('worker_garbage_collect');
823
912
  const bytes = this.readReturnSlot();
824
913
  const text = new TextDecoder().decode(bytes);
825
- return decodeKeyValueText(text);
914
+ const kv = decodeKeyValueText(text);
915
+ const removedCount = Number(kv['removed'] ?? 0);
916
+ const staleSources = Number(kv['stale_sources'] ?? 0);
917
+ return {
918
+ removed_sections: removedCount,
919
+ removed_bytes: 0,
920
+ elapsed_ms: Date.now() - start,
921
+ stale_sources: staleSources,
922
+ };
826
923
  }
827
924
 
828
925
  private handleRebuild(): unknown {
829
- this.callWasmZeroArg('worker_rebuild_indexes');
830
- return { ok: true };
926
+ const fn = this.getExport('worker_rebuild_indexes');
927
+ const rc = fn() as number;
928
+ if (rc === -1) {
929
+ const errBytes = this.readReturnSlot();
930
+ const errMsg = new TextDecoder().decode(errBytes);
931
+ throw new IpcError(-32000, errMsg || 'rebuild failed');
932
+ }
933
+ const resultBytes = this.readReturnSlot();
934
+ const reindexed =
935
+ resultBytes.length >= 4
936
+ ? new DataView(
937
+ resultBytes.buffer,
938
+ resultBytes.byteOffset,
939
+ resultBytes.byteLength,
940
+ ).getUint32(0, true)
941
+ : 0;
942
+ return { ok: true, sections_reindexed: reindexed };
943
+ }
944
+
945
+ // -- Export (read operation, no lock required) --
946
+
947
+ private handleExport(params: Record<string, unknown>): unknown {
948
+ const targetPath =
949
+ (params['targetPath'] as string | undefined) ??
950
+ (params['path'] as string | undefined) ??
951
+ './kbdb-export/';
952
+ const encoded = new TextEncoder().encode(targetPath);
953
+ const start = Date.now();
954
+ const bytes = this.callWasmSync('worker_export', encoded);
955
+ const elapsed = Date.now() - start;
956
+ const text = new TextDecoder().decode(bytes);
957
+ const kv = decodeKeyValueText(text);
958
+ return {
959
+ path: kv['path'] ?? targetPath,
960
+ sections_exported: Number(kv['sections_exported'] ?? 0),
961
+ documents_exported: Number(kv['documents_exported'] ?? 0),
962
+ elapsed_ms: elapsed,
963
+ };
964
+ }
965
+
966
+ // -- Near-duplicate detection --
967
+
968
+ private scanNearDuplicates(
969
+ content: string,
970
+ newKbid: string,
971
+ ): { kbid: string; similarity: number }[] {
972
+ const newTerms = this.extractTermSet(content);
973
+ if (newTerms.size === 0) return [];
974
+
975
+ const sectionsDir = join(this.targetDir, 'sections');
976
+ let shards: string[];
977
+ try {
978
+ shards = readdirSync(sectionsDir);
979
+ } catch {
980
+ return [];
981
+ }
982
+
983
+ const results: { kbid: string; similarity: number }[] = [];
984
+ for (const shard of shards) {
985
+ let files: string[];
986
+ try {
987
+ files = readdirSync(join(sectionsDir, shard));
988
+ } catch {
989
+ continue;
990
+ }
991
+ for (const file of files) {
992
+ if (!file.endsWith('.section')) continue;
993
+ const kbid = file.slice(0, -8);
994
+ if (kbid === newKbid) continue;
995
+ try {
996
+ const data = readFileSync(
997
+ join(sectionsDir, shard, file),
998
+ 'utf-8',
999
+ );
1000
+ const sep = data.indexOf('\n---\n');
1001
+ const body = sep >= 0 ? data.slice(sep + 5) : data;
1002
+ const existing = this.extractTermSet(body);
1003
+ let intersection = 0;
1004
+ for (const t of newTerms) {
1005
+ if (existing.has(t)) intersection++;
1006
+ }
1007
+ const union =
1008
+ newTerms.size + existing.size - intersection;
1009
+ const sim = union > 0 ? intersection / union : 0;
1010
+ if (sim >= 0.85) {
1011
+ results.push({
1012
+ kbid,
1013
+ similarity: Math.round(sim * 100) / 100,
1014
+ });
1015
+ }
1016
+ } catch {
1017
+ continue;
1018
+ }
1019
+ }
1020
+ }
1021
+ return results;
1022
+ }
1023
+
1024
+ private extractTermSet(text: string): Set<string> {
1025
+ return new Set(
1026
+ text
1027
+ .toLowerCase()
1028
+ .split(/\W+/)
1029
+ .filter((w) => w.length > 2),
1030
+ );
1031
+ }
1032
+
1033
+ // -- Single-writer lock --
1034
+
1035
+ /**
1036
+ * Acquires the write lock.
1037
+ *
1038
+ * If the lock is free it is granted immediately. If it is held the
1039
+ * caller is queued and its promise resolves once the lock is
1040
+ * released by the current holder.
1041
+ */
1042
+ private acquireWriteLock(): Promise<void> {
1043
+ if (!this._writeLocked) {
1044
+ this._writeLocked = true;
1045
+ return Promise.resolve();
1046
+ }
1047
+ return new Promise<void>((resolve) => {
1048
+ this._writeQueue.push({ resolve });
1049
+ });
1050
+ }
1051
+
1052
+ /**
1053
+ * Releases the write lock.
1054
+ *
1055
+ * If waiters are queued the next one is granted the lock
1056
+ * immediately. Otherwise the lock is marked free.
1057
+ */
1058
+ private releaseWriteLock(): void {
1059
+ const next = this._writeQueue.shift();
1060
+ if (next !== undefined) {
1061
+ next.resolve();
1062
+ } else {
1063
+ this._writeLocked = false;
1064
+ }
831
1065
  }
832
1066
 
833
1067
  // -- WASM lifecycle --
@@ -837,6 +1071,41 @@ export class WorkerDaemon {
837
1071
  this.callWasmSync('worker_init', pathBytes);
838
1072
  }
839
1073
 
1074
+ /**
1075
+ * Runs a database integrity check immediately after context
1076
+ * initialisation.
1077
+ *
1078
+ * Stores the result in `_integrityOk` and `_integrityErrors`.
1079
+ * Logs a warning to stderr when issues are found so operators
1080
+ * are alerted without aborting the daemon.
1081
+ */
1082
+ private runStartupIntegrityCheck(): void {
1083
+ try {
1084
+ this.callWasmZeroArg('worker_check_integrity');
1085
+ const bytes = this.readReturnSlot();
1086
+ const text = new TextDecoder().decode(bytes);
1087
+ const kv = decodeKeyValueText(text);
1088
+ this._integrityOk = kv['ok'] === 'true';
1089
+ if (!this._integrityOk) {
1090
+ this._integrityErrors = Object.entries(kv)
1091
+ .filter(([k]) => k !== 'ok')
1092
+ .map(([k, v]) => `${k}=${v}`);
1093
+ // eslint-disable-next-line no-console
1094
+ console.error(
1095
+ '[kbdb-worker] startup integrity check failed:',
1096
+ this._integrityErrors.join(', '),
1097
+ );
1098
+ }
1099
+ } catch {
1100
+ this._integrityOk = false;
1101
+ this._integrityErrors = ['integrity_check_failed'];
1102
+ // eslint-disable-next-line no-console
1103
+ console.error(
1104
+ '[kbdb-worker] startup integrity check threw an error',
1105
+ );
1106
+ }
1107
+ }
1108
+
840
1109
  // -- WASM bridge primitives --
841
1110
 
842
1111
  private getExport(name: string): WasmFn {
@@ -136,8 +136,9 @@ export function createHostImports(
136
136
 
137
137
  function readPathArg(ptr: number, len: number): string {
138
138
  const bytes = new Uint8Array(getBuffer(), ptr, len);
139
- const relative = new TextDecoder().decode(bytes);
140
- return join(contextPath, relative);
139
+ const decoded = new TextDecoder().decode(bytes);
140
+ if (decoded.startsWith('/')) return decoded;
141
+ return join(contextPath, decoded);
141
142
  }
142
143
 
143
144
  return {
@@ -163,14 +164,21 @@ export function createHostImports(
163
164
  const filePath = readPathArg(pathPtr, pathLen);
164
165
  const data = fs.readFileSync(filePath);
165
166
  const dataLen = data.byteLength;
166
- const dv = new DataView(getBuffer());
167
167
  if (dataLen === 0) {
168
+ // No allocation -- buffer cannot have grown; safe to
169
+ // create DataView now.
170
+ const dv = new DataView(getBuffer());
168
171
  dv.setUint32(outPtr, 0, true);
169
172
  dv.setUint32(outLen, 0, true);
170
173
  return 0;
171
174
  }
172
175
  const dataPtr = allocator.alloc(dataLen);
173
176
  if (dataPtr === 0) return -1;
177
+ // Create DataView AFTER alloc() -- alloc may trigger WASM
178
+ // memory growth, which detaches the old ArrayBuffer and
179
+ // replaces it with a new one. A DataView captured before
180
+ // growth would reference the detached buffer and throw.
181
+ const dv = new DataView(getBuffer());
174
182
  const memView = new Uint8Array(getBuffer());
175
183
  memView.set(
176
184
  new Uint8Array(
@@ -267,14 +275,21 @@ export function createHostImports(
267
275
  const entries = fs.readdirSync(dirPath);
268
276
  const text = entries.join('\n');
269
277
  const encoded = new TextEncoder().encode(text);
270
- const dv = new DataView(getBuffer());
271
278
  if (encoded.length === 0) {
279
+ // No allocation -- buffer cannot have grown; safe to
280
+ // create DataView now.
281
+ const dv = new DataView(getBuffer());
272
282
  dv.setUint32(outPtr, 0, true);
273
283
  dv.setUint32(outLen, 0, true);
274
284
  return 0;
275
285
  }
276
286
  const dataPtr = allocator.alloc(encoded.length);
277
287
  if (dataPtr === 0) return -1;
288
+ // Create DataView AFTER alloc() -- alloc may trigger WASM
289
+ // memory growth, which detaches the old ArrayBuffer and
290
+ // replaces it with a new one. A DataView captured before
291
+ // growth would reference the detached buffer and throw.
292
+ const dv = new DataView(getBuffer());
278
293
  new Uint8Array(getBuffer(), dataPtr, encoded.length).set(
279
294
  encoded,
280
295
  );
@@ -28,6 +28,8 @@ export function createIpcServer(
28
28
  const server = createServer((socket) => {
29
29
  let buffer = '';
30
30
 
31
+ let processingQueue = Promise.resolve();
32
+
31
33
  socket.on('data', (chunk: Buffer) => {
32
34
  buffer += chunk.toString();
33
35
  const lines = buffer.split('\n');
@@ -35,7 +37,9 @@ export function createIpcServer(
35
37
 
36
38
  for (const line of lines) {
37
39
  if (!line.trim()) continue;
38
- void handleLine(line, handler, socket);
40
+ processingQueue = processingQueue.then(() =>
41
+ handleLine(line, handler, socket),
42
+ );
39
43
  }
40
44
  });
41
45
 
@@ -5,7 +5,10 @@ declare namespace wasm_bindgen {
5
5
  /**
6
6
  * Adds a section. `ptr`/`len` point to an encoded `AddSectionParams`.
7
7
  *
8
- * On success: writes encoded kbid string to return slot, returns 0.
8
+ * On success: writes an encoded `AddSectionResult` to the return slot and
9
+ * returns 0. When `params.replace` is true and a section with the same
10
+ * `source_key` exists, the superseded section's index entries are removed
11
+ * and the returned `AddSectionResult.superseded` is set to its kbid.
9
12
  * On error: writes error message to return slot, returns -1.
10
13
  */
11
14
  export function db_add_section(ptr: number, len: number): number;
@@ -42,6 +45,23 @@ declare namespace wasm_bindgen {
42
45
  */
43
46
  export function db_check_version(ptr: number, len: number): number;
44
47
 
48
+ /**
49
+ * Exports the database to a target directory.
50
+ *
51
+ * `ptr`/`len` point to a UTF-8 string containing the target path.
52
+ *
53
+ * Copies `sections/`, `documents/`, and `catalog.toml` to the target.
54
+ * Indexes are not copied because they can be rebuilt from the data.
55
+ *
56
+ * The target path may be absolute or relative. It is an external output
57
+ * destination, not a database-internal path, so no path validation is
58
+ * applied to it.
59
+ *
60
+ * On success a summary string is placed in the return slot:
61
+ * `path={target}\nsections_exported={N}\ndocuments_exported={M}`
62
+ */
63
+ export function db_export(ptr: number, len: number): number;
64
+
45
65
  /**
46
66
  * Runs garbage collection. Returns 0 on success.
47
67
  * On success a summary string is placed in the return slot.
@@ -200,6 +220,7 @@ declare interface InitOutput {
200
220
  readonly db_add_to_index: (a: number, b: number) => number;
201
221
  readonly db_check_integrity: (a: number, b: number) => number;
202
222
  readonly db_check_version: (a: number, b: number) => number;
223
+ readonly db_export: (a: number, b: number) => number;
203
224
  readonly db_gc: (a: number, b: number) => number;
204
225
  readonly db_get_back_refs: (a: number, b: number) => number;
205
226
  readonly db_get_forward_refs: (a: number, b: number) => number;
@@ -7,7 +7,10 @@ let wasm_bindgen = (function(exports) {
7
7
  /**
8
8
  * Adds a section. `ptr`/`len` point to an encoded `AddSectionParams`.
9
9
  *
10
- * On success: writes encoded kbid string to return slot, returns 0.
10
+ * On success: writes an encoded `AddSectionResult` to the return slot and
11
+ * returns 0. When `params.replace` is true and a section with the same
12
+ * `source_key` exists, the superseded section's index entries are removed
13
+ * and the returned `AddSectionResult.superseded` is set to its kbid.
11
14
  * On error: writes error message to return slot, returns -1.
12
15
  * @param {number} ptr
13
16
  * @param {number} len
@@ -72,6 +75,30 @@ let wasm_bindgen = (function(exports) {
72
75
  }
73
76
  exports.db_check_version = db_check_version;
74
77
 
78
+ /**
79
+ * Exports the database to a target directory.
80
+ *
81
+ * `ptr`/`len` point to a UTF-8 string containing the target path.
82
+ *
83
+ * Copies `sections/`, `documents/`, and `catalog.toml` to the target.
84
+ * Indexes are not copied because they can be rebuilt from the data.
85
+ *
86
+ * The target path may be absolute or relative. It is an external output
87
+ * destination, not a database-internal path, so no path validation is
88
+ * applied to it.
89
+ *
90
+ * On success a summary string is placed in the return slot:
91
+ * `path={target}\nsections_exported={N}\ndocuments_exported={M}`
92
+ * @param {number} ptr
93
+ * @param {number} len
94
+ * @returns {number}
95
+ */
96
+ function db_export(ptr, len) {
97
+ const ret = wasm.db_export(ptr, len);
98
+ return ret;
99
+ }
100
+ exports.db_export = db_export;
101
+
75
102
  /**
76
103
  * Runs garbage collection. Returns 0 on success.
77
104
  * On success a summary string is placed in the return slot.
@@ -7,6 +7,7 @@ export const db_add_section: (a: number, b: number) => number;
7
7
  export const db_add_to_index: (a: number, b: number) => number;
8
8
  export const db_check_integrity: (a: number, b: number) => number;
9
9
  export const db_check_version: (a: number, b: number) => number;
10
+ export const db_export: (a: number, b: number) => number;
10
11
  export const db_gc: (a: number, b: number) => number;
11
12
  export const db_get_back_refs: (a: number, b: number) => number;
12
13
  export const db_get_forward_refs: (a: number, b: number) => number;