@statewalker/indexer-mem 0.1.5 → 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.
package/README.md CHANGED
@@ -4,9 +4,11 @@
4
4
 
5
5
  An in-memory vector sub-index, `MemVectorIndex`, and `memVectorProvider`, the `VectorProvider` that creates it. Search is a brute-force cosine scan over all stored embeddings. The index can save itself as one Apache Arrow IPC stream.
6
6
 
7
+ It also holds the in-memory block store behind the full-text indexes. `MemBlockStore` owns the blocks keyed by path + blockId, path-prefix selection, enumeration, multi-query score fusion (scores summed across queries), dirty tracking and the persistence framing (`{ version: 3, ...engineState, blocks }`). `MemFullTextIndex` is the `FullTextIndex` over that store. A full-text engine plugs in as a `MemBlockEngine` adapter that supplies only `add`, `remove`, `search` and `exportState`.
8
+
7
9
  ## Why it exists
8
10
 
9
- The in-memory indexers (`@statewalker/indexer-mem-flexsearch`, `@statewalker/indexer-mem-minisearch`) differ only in their full-text engine. Both use this package for the vector side, so the vector code and its file format exist once.
11
+ The in-memory indexers (`@statewalker/indexer-mem-flexsearch`, `@statewalker/indexer-mem-minisearch`) differ only in their full-text engine. Both use this package for the vector side and for the full-text block store, so the vector code, the block and path rules and the file formats exist once, and another engine costs only an adapter.
10
12
 
11
13
  ## How to use
12
14
 
@@ -14,7 +16,7 @@ The in-memory indexers (`@statewalker/indexer-mem-flexsearch`, `@statewalker/ind
14
16
  pnpm add @statewalker/indexer-mem
15
17
  ```
16
18
 
17
- One entry point, `@statewalker/indexer-mem`: `MemVectorIndex`, `memVectorProvider`. Runs anywhere (browser, Node, workers).
19
+ One entry point, `@statewalker/indexer-mem`: `MemVectorIndex`, `memVectorProvider`, `MemBlockStore`, `MemBlockEngine`, `MemFullTextIndex`. Runs anywhere (browser, Node, workers).
18
20
 
19
21
  Most applications use `createFlexSearchIndexer` or `createMiniSearchIndexer`, which already include `memVectorProvider`. Use this package directly when you build your own indexer from providers.
20
22
 
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { MEM_BLOCK_STORE_VERSION, MemBlockEngine, MemBlockHit, MemBlockStore, MemBlockStoreOptions, RankedKey } from "./mem-block-store.js";
2
+ import { MemFullTextEngine, MemFullTextIndex } from "./mem-full-text-index.js";
1
3
  import { MemVectorIndex } from "./mem-vector-index.js";
2
4
  import { memVectorProvider } from "./mem-vector-provider.js";
3
- export { MemVectorIndex, memVectorProvider };
5
+ export { MEM_BLOCK_STORE_VERSION, type MemBlockEngine, type MemBlockHit, MemBlockStore, type MemBlockStoreOptions, type MemFullTextEngine, MemFullTextIndex, MemVectorIndex, type RankedKey, memVectorProvider };
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { MEM_BLOCK_STORE_VERSION, MemBlockStore } from "./mem-block-store.js";
2
+ import { MemFullTextIndex } from "./mem-full-text-index.js";
1
3
  import { MemVectorIndex } from "./mem-vector-index.js";
2
4
  import { memVectorProvider } from "./mem-vector-provider.js";
3
- export { MemVectorIndex, memVectorProvider };
5
+ export { MEM_BLOCK_STORE_VERSION, MemBlockStore, MemFullTextIndex, MemVectorIndex, memVectorProvider };
@@ -0,0 +1,114 @@
1
+ import { BlockReference, DocumentPath, PathSelector } from "@statewalker/indexer-api";
2
+ //#region src/mem-block-store.d.ts
3
+ /** One engine hit: the store key of a block and the engine's native score. */
4
+ export interface RankedKey {
5
+ key: string;
6
+ score: number;
7
+ }
8
+ /**
9
+ * The engine side of a {@link MemBlockStore}: what one search engine
10
+ * (FlexSearch, MiniSearch, …) supplies. The store owns the blocks and calls
11
+ * the engine only to index, unindex and rank them, addressed by the store's
12
+ * key for a block.
13
+ */
14
+ export interface MemBlockEngine<B> {
15
+ /** Index `block` under `key`. A previous version is always removed first. */
16
+ add(key: string, block: B): void;
17
+ /** Unindex the block under `key`. Called only for keys the store holds. */
18
+ remove(key: string): void;
19
+ /**
20
+ * Rank the indexed blocks for one query, best first. `limit` is the depth
21
+ * the caller wants after path filtering; an engine may return more.
22
+ * Keys the store no longer holds are ignored.
23
+ */
24
+ search(query: string, limit: number): RankedKey[];
25
+ /**
26
+ * The engine's own state as JSON-safe fields, framed with the blocks by
27
+ * {@link MemBlockStore.serialize}. Must not use the names `version` or `blocks`.
28
+ */
29
+ exportState(): Record<string, unknown>;
30
+ }
31
+ /** A fused hit from {@link MemBlockStore.search}. */
32
+ export interface MemBlockHit<B> {
33
+ block: B;
34
+ /** The sum of the block's scores over every query it matched. */
35
+ score: number;
36
+ }
37
+ export interface MemBlockStoreOptions<B> {
38
+ /** Named in errors, e.g. the class of the index that owns the store. */
39
+ label: string;
40
+ engine: MemBlockEngine<B>;
41
+ }
42
+ /** The persistence format version written by {@link MemBlockStore.serialize}. */
43
+ export declare const MEM_BLOCK_STORE_VERSION = 3;
44
+ /**
45
+ * In-memory block store shared by the in-memory search engines.
46
+ *
47
+ * Owns the blocks keyed by `path` + `blockId`, path-prefix selection,
48
+ * enumeration, multi-query score fusion, dirty tracking and the persistence
49
+ * framing; the {@link MemBlockEngine} supplies only add, remove and search.
50
+ *
51
+ * Dirty tracking: the store starts dirty, becomes clean after
52
+ * {@link serialize} or {@link restore}, and becomes dirty again only on a
53
+ * change. Empty batches and deletes that remove nothing are not changes.
54
+ * While clean, `serialize` returns the cached output without asking the
55
+ * engine to export again.
56
+ */
57
+ export declare class MemBlockStore<B extends BlockReference> {
58
+ private readonly label;
59
+ private engineRef;
60
+ private blocks;
61
+ private dirty;
62
+ private cached?;
63
+ constructor(options: MemBlockStoreOptions<B>);
64
+ /** The engine currently indexing the blocks (replaced by {@link restore}). */
65
+ get engine(): MemBlockEngine<B>;
66
+ /** `true` when the blocks changed since the last serialize or restore. */
67
+ isDirty(): boolean;
68
+ private markDirty;
69
+ /** The block under `key`, if any. */
70
+ get(key: string): B | undefined;
71
+ /**
72
+ * Add or replace a batch of blocks. An empty batch changes nothing and
73
+ * leaves the store clean.
74
+ */
75
+ put(batch: readonly B[]): void;
76
+ /**
77
+ * Delete the blocks the selectors match: one block when `blockId` is set,
78
+ * otherwise every block under the path prefix. Returns how many blocks were
79
+ * removed; the store becomes dirty only when that is more than zero.
80
+ */
81
+ delete(selectors: PathSelector[] | AsyncIterable<PathSelector>): Promise<number>;
82
+ private removeKey;
83
+ /** Drop every block, from the store and the engine. */
84
+ clear(): void;
85
+ private keys;
86
+ /** The blocks under `pathPrefix` (all blocks without one), in insertion order. */
87
+ select(pathPrefix?: DocumentPath): Iterable<B>;
88
+ /** How many blocks lie under `pathPrefix` (all blocks without one). */
89
+ count(pathPrefix?: DocumentPath): number;
90
+ /** The distinct document paths under `pathPrefix`, in first-seen order. */
91
+ paths(pathPrefix?: DocumentPath): Iterable<DocumentPath>;
92
+ /**
93
+ * Multi-query search with score fusion. Each query is ranked by the engine,
94
+ * filtered to `pathPrefixes` (when given) and cut to `topK`; the per-block
95
+ * scores are then **summed** across queries, so a block matching more
96
+ * queries ranks higher, and the fused list is cut to `topK` again.
97
+ */
98
+ search(queries: readonly string[], topK: number, pathPrefixes?: DocumentPath[]): MemBlockHit<B>[];
99
+ /**
100
+ * Serialize the blocks and the engine state into one JSON document,
101
+ * `{ version: 3, ...engineState, blocks }`. While the store is clean the
102
+ * previous output is returned as is.
103
+ */
104
+ serialize(): string;
105
+ /**
106
+ * Replace the store's content with a document written by {@link serialize}.
107
+ * `restoreEngine` rebuilds the engine from the document's fields. Throws on
108
+ * a document of another version, leaving the store unchanged. The store is
109
+ * clean afterwards and re-serializes to `json` until it changes.
110
+ */
111
+ restore(json: string, restoreEngine: (state: Record<string, unknown>) => MemBlockEngine<B>): void;
112
+ }
113
+ //#endregion
114
+ //# sourceMappingURL=mem-block-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mem-block-store.d.ts","names":[],"sources":["../src/mem-block-store.ts"],"mappings":";;;iBAIiB;EACf;EACA;;;;;;;;iBASe,eAAe;;EAE9B,IAAI,aAAa,OAAO;;EAExB,OAAO;;;;;;EAMP,OAAO,eAAe,gBAAgB;;;;;EAKtC,eAAe;;;iBAIA,YAAY;EAC3B,OAAO;;EAEP;;iBAGe,qBAAqB;;EAEpC;EACA,QAAQ,eAAe;;;qBAIZ;;;;;;;;;;;;;;qBAeA,cAAc,UAAU;mBAClB;UACT;UACA;UACA;UACA;EAER,YAAY,SAAS,qBAAqB;;MAMtC,UAAU,eAAe;;EAK7B;UAIQ;;EAMR,IAAI,cAAc;;;;;EAQlB,IAAI,gBAAgB;;;;;;EAgBd,OAAO,WAAW,iBAAiB,cAAc,gBAAgB;UAe/D;;EAQR;UAMS;;EAOR,OAAO,aAAa,eAAe,SAAS;;EAQ7C,MAAM,aAAa;;EAQlB,MAAM,aAAa,eAAe,SAAS;;;;;;;EAe5C,OACE,4BACA,cACA,eAAe,iBACd,YAAY;;;;;;EAwBf;;;;;;;EAkBA,QACE,cACA,gBAAgB,OAAO,4BAA4B,eAAe"}
@@ -0,0 +1,173 @@
1
+ import { compositeKey, matchesPrefix, toAsyncIterable } from "@statewalker/indexer-core";
2
+ //#region src/mem-block-store.ts
3
+ /** The persistence format version written by {@link MemBlockStore.serialize}. */
4
+ const MEM_BLOCK_STORE_VERSION = 3;
5
+ /**
6
+ * In-memory block store shared by the in-memory search engines.
7
+ *
8
+ * Owns the blocks keyed by `path` + `blockId`, path-prefix selection,
9
+ * enumeration, multi-query score fusion, dirty tracking and the persistence
10
+ * framing; the {@link MemBlockEngine} supplies only add, remove and search.
11
+ *
12
+ * Dirty tracking: the store starts dirty, becomes clean after
13
+ * {@link serialize} or {@link restore}, and becomes dirty again only on a
14
+ * change. Empty batches and deletes that remove nothing are not changes.
15
+ * While clean, `serialize` returns the cached output without asking the
16
+ * engine to export again.
17
+ */
18
+ var MemBlockStore = class {
19
+ label;
20
+ engineRef;
21
+ blocks = /* @__PURE__ */ new Map();
22
+ dirty = true;
23
+ cached;
24
+ constructor(options) {
25
+ this.label = options.label;
26
+ this.engineRef = options.engine;
27
+ }
28
+ /** The engine currently indexing the blocks (replaced by {@link restore}). */
29
+ get engine() {
30
+ return this.engineRef;
31
+ }
32
+ /** `true` when the blocks changed since the last serialize or restore. */
33
+ isDirty() {
34
+ return this.dirty;
35
+ }
36
+ markDirty() {
37
+ this.dirty = true;
38
+ this.cached = void 0;
39
+ }
40
+ /** The block under `key`, if any. */
41
+ get(key) {
42
+ return this.blocks.get(key);
43
+ }
44
+ /**
45
+ * Add or replace a batch of blocks. An empty batch changes nothing and
46
+ * leaves the store clean.
47
+ */
48
+ put(batch) {
49
+ if (batch.length === 0) return;
50
+ for (const block of batch) {
51
+ const key = compositeKey(block.path, block.blockId);
52
+ if (this.blocks.has(key)) this.engineRef.remove(key);
53
+ this.engineRef.add(key, block);
54
+ this.blocks.set(key, block);
55
+ }
56
+ this.markDirty();
57
+ }
58
+ /**
59
+ * Delete the blocks the selectors match: one block when `blockId` is set,
60
+ * otherwise every block under the path prefix. Returns how many blocks were
61
+ * removed; the store becomes dirty only when that is more than zero.
62
+ */
63
+ async delete(selectors) {
64
+ let removed = 0;
65
+ for await (const sel of toAsyncIterable(selectors)) if (sel.blockId !== void 0) {
66
+ if (this.removeKey(compositeKey(sel.path, sel.blockId))) removed += 1;
67
+ } else for (const key of [...this.keys(sel.path)]) if (this.removeKey(key)) removed += 1;
68
+ if (removed > 0) this.markDirty();
69
+ return removed;
70
+ }
71
+ removeKey(key) {
72
+ if (!this.blocks.has(key)) return false;
73
+ this.engineRef.remove(key);
74
+ this.blocks.delete(key);
75
+ return true;
76
+ }
77
+ /** Drop every block, from the store and the engine. */
78
+ clear() {
79
+ for (const key of this.blocks.keys()) this.engineRef.remove(key);
80
+ this.blocks.clear();
81
+ this.markDirty();
82
+ }
83
+ *keys(pathPrefix) {
84
+ for (const [key, block] of this.blocks) if (pathPrefix === void 0 || matchesPrefix(block.path, pathPrefix)) yield key;
85
+ }
86
+ /** The blocks under `pathPrefix` (all blocks without one), in insertion order. */
87
+ *select(pathPrefix) {
88
+ for (const key of this.keys(pathPrefix)) {
89
+ const block = this.blocks.get(key);
90
+ if (block) yield block;
91
+ }
92
+ }
93
+ /** How many blocks lie under `pathPrefix` (all blocks without one). */
94
+ count(pathPrefix) {
95
+ if (pathPrefix === void 0) return this.blocks.size;
96
+ let n = 0;
97
+ for (const _ of this.keys(pathPrefix)) n += 1;
98
+ return n;
99
+ }
100
+ /** The distinct document paths under `pathPrefix`, in first-seen order. */
101
+ *paths(pathPrefix) {
102
+ const seen = /* @__PURE__ */ new Set();
103
+ for (const block of this.select(pathPrefix)) {
104
+ if (seen.has(block.path)) continue;
105
+ seen.add(block.path);
106
+ yield block.path;
107
+ }
108
+ }
109
+ /**
110
+ * Multi-query search with score fusion. Each query is ranked by the engine,
111
+ * filtered to `pathPrefixes` (when given) and cut to `topK`; the per-block
112
+ * scores are then **summed** across queries, so a block matching more
113
+ * queries ranks higher, and the fused list is cut to `topK` again.
114
+ */
115
+ search(queries, topK, pathPrefixes) {
116
+ const totals = /* @__PURE__ */ new Map();
117
+ const scoped = pathPrefixes !== void 0 && pathPrefixes.length > 0;
118
+ for (const query of queries) {
119
+ let taken = 0;
120
+ for (const { key, score } of this.engineRef.search(query, topK)) {
121
+ if (taken >= topK) break;
122
+ const block = this.blocks.get(key);
123
+ if (!block) continue;
124
+ if (scoped && !pathPrefixes.some((p) => matchesPrefix(block.path, p))) continue;
125
+ taken += 1;
126
+ const hit = totals.get(key);
127
+ if (hit) hit.score += score;
128
+ else totals.set(key, {
129
+ block,
130
+ score
131
+ });
132
+ }
133
+ }
134
+ return [...totals.values()].sort((a, b) => b.score - a.score).slice(0, topK);
135
+ }
136
+ /**
137
+ * Serialize the blocks and the engine state into one JSON document,
138
+ * `{ version: 3, ...engineState, blocks }`. While the store is clean the
139
+ * previous output is returned as is.
140
+ */
141
+ serialize() {
142
+ if (!this.dirty && this.cached !== void 0) return this.cached;
143
+ const out = JSON.stringify({
144
+ version: 3,
145
+ ...this.engineRef.exportState(),
146
+ blocks: [...this.blocks.values()]
147
+ });
148
+ this.cached = out;
149
+ this.dirty = false;
150
+ return out;
151
+ }
152
+ /**
153
+ * Replace the store's content with a document written by {@link serialize}.
154
+ * `restoreEngine` rebuilds the engine from the document's fields. Throws on
155
+ * a document of another version, leaving the store unchanged. The store is
156
+ * clean afterwards and re-serializes to `json` until it changes.
157
+ */
158
+ restore(json, restoreEngine) {
159
+ const parsed = JSON.parse(json);
160
+ if (parsed.version !== 3) throw new Error(`${this.label}: unsupported serialised version ${String(parsed.version)} (expected 3)`);
161
+ const engine = restoreEngine(parsed);
162
+ const blocks = /* @__PURE__ */ new Map();
163
+ for (const block of parsed.blocks ?? []) blocks.set(compositeKey(block.path, block.blockId), block);
164
+ this.engineRef = engine;
165
+ this.blocks = blocks;
166
+ this.cached = json;
167
+ this.dirty = false;
168
+ }
169
+ };
170
+ //#endregion
171
+ export { MEM_BLOCK_STORE_VERSION, MemBlockStore };
172
+
173
+ //# sourceMappingURL=mem-block-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mem-block-store.js","names":[],"sources":["../src/mem-block-store.ts"],"sourcesContent":["import type { BlockReference, DocumentPath, PathSelector } from \"@statewalker/indexer-api\";\nimport { compositeKey, matchesPrefix, toAsyncIterable } from \"@statewalker/indexer-core\";\n\n/** One engine hit: the store key of a block and the engine's native score. */\nexport interface RankedKey {\n key: string;\n score: number;\n}\n\n/**\n * The engine side of a {@link MemBlockStore}: what one search engine\n * (FlexSearch, MiniSearch, …) supplies. The store owns the blocks and calls\n * the engine only to index, unindex and rank them, addressed by the store's\n * key for a block.\n */\nexport interface MemBlockEngine<B> {\n /** Index `block` under `key`. A previous version is always removed first. */\n add(key: string, block: B): void;\n /** Unindex the block under `key`. Called only for keys the store holds. */\n remove(key: string): void;\n /**\n * Rank the indexed blocks for one query, best first. `limit` is the depth\n * the caller wants after path filtering; an engine may return more.\n * Keys the store no longer holds are ignored.\n */\n search(query: string, limit: number): RankedKey[];\n /**\n * The engine's own state as JSON-safe fields, framed with the blocks by\n * {@link MemBlockStore.serialize}. Must not use the names `version` or `blocks`.\n */\n exportState(): Record<string, unknown>;\n}\n\n/** A fused hit from {@link MemBlockStore.search}. */\nexport interface MemBlockHit<B> {\n block: B;\n /** The sum of the block's scores over every query it matched. */\n score: number;\n}\n\nexport interface MemBlockStoreOptions<B> {\n /** Named in errors, e.g. the class of the index that owns the store. */\n label: string;\n engine: MemBlockEngine<B>;\n}\n\n/** The persistence format version written by {@link MemBlockStore.serialize}. */\nexport const MEM_BLOCK_STORE_VERSION = 3;\n\n/**\n * In-memory block store shared by the in-memory search engines.\n *\n * Owns the blocks keyed by `path` + `blockId`, path-prefix selection,\n * enumeration, multi-query score fusion, dirty tracking and the persistence\n * framing; the {@link MemBlockEngine} supplies only add, remove and search.\n *\n * Dirty tracking: the store starts dirty, becomes clean after\n * {@link serialize} or {@link restore}, and becomes dirty again only on a\n * change. Empty batches and deletes that remove nothing are not changes.\n * While clean, `serialize` returns the cached output without asking the\n * engine to export again.\n */\nexport class MemBlockStore<B extends BlockReference> {\n private readonly label: string;\n private engineRef: MemBlockEngine<B>;\n private blocks = new Map<string, B>();\n private dirty = true;\n private cached?: string;\n\n constructor(options: MemBlockStoreOptions<B>) {\n this.label = options.label;\n this.engineRef = options.engine;\n }\n\n /** The engine currently indexing the blocks (replaced by {@link restore}). */\n get engine(): MemBlockEngine<B> {\n return this.engineRef;\n }\n\n /** `true` when the blocks changed since the last serialize or restore. */\n isDirty(): boolean {\n return this.dirty;\n }\n\n private markDirty(): void {\n this.dirty = true;\n this.cached = undefined;\n }\n\n /** The block under `key`, if any. */\n get(key: string): B | undefined {\n return this.blocks.get(key);\n }\n\n /**\n * Add or replace a batch of blocks. An empty batch changes nothing and\n * leaves the store clean.\n */\n put(batch: readonly B[]): void {\n if (batch.length === 0) return;\n for (const block of batch) {\n const key = compositeKey(block.path, block.blockId);\n if (this.blocks.has(key)) this.engineRef.remove(key);\n this.engineRef.add(key, block);\n this.blocks.set(key, block);\n }\n this.markDirty();\n }\n\n /**\n * Delete the blocks the selectors match: one block when `blockId` is set,\n * otherwise every block under the path prefix. Returns how many blocks were\n * removed; the store becomes dirty only when that is more than zero.\n */\n async delete(selectors: PathSelector[] | AsyncIterable<PathSelector>): Promise<number> {\n let removed = 0;\n for await (const sel of toAsyncIterable(selectors)) {\n if (sel.blockId !== undefined) {\n if (this.removeKey(compositeKey(sel.path, sel.blockId))) removed += 1;\n } else {\n for (const key of [...this.keys(sel.path)]) {\n if (this.removeKey(key)) removed += 1;\n }\n }\n }\n if (removed > 0) this.markDirty();\n return removed;\n }\n\n private removeKey(key: string): boolean {\n if (!this.blocks.has(key)) return false;\n this.engineRef.remove(key);\n this.blocks.delete(key);\n return true;\n }\n\n /** Drop every block, from the store and the engine. */\n clear(): void {\n for (const key of this.blocks.keys()) this.engineRef.remove(key);\n this.blocks.clear();\n this.markDirty();\n }\n\n private *keys(pathPrefix?: DocumentPath): Iterable<string> {\n for (const [key, block] of this.blocks) {\n if (pathPrefix === undefined || matchesPrefix(block.path, pathPrefix)) yield key;\n }\n }\n\n /** The blocks under `pathPrefix` (all blocks without one), in insertion order. */\n *select(pathPrefix?: DocumentPath): Iterable<B> {\n for (const key of this.keys(pathPrefix)) {\n const block = this.blocks.get(key);\n if (block) yield block;\n }\n }\n\n /** How many blocks lie under `pathPrefix` (all blocks without one). */\n count(pathPrefix?: DocumentPath): number {\n if (pathPrefix === undefined) return this.blocks.size;\n let n = 0;\n for (const _ of this.keys(pathPrefix)) n += 1;\n return n;\n }\n\n /** The distinct document paths under `pathPrefix`, in first-seen order. */\n *paths(pathPrefix?: DocumentPath): Iterable<DocumentPath> {\n const seen = new Set<DocumentPath>();\n for (const block of this.select(pathPrefix)) {\n if (seen.has(block.path)) continue;\n seen.add(block.path);\n yield block.path;\n }\n }\n\n /**\n * Multi-query search with score fusion. Each query is ranked by the engine,\n * filtered to `pathPrefixes` (when given) and cut to `topK`; the per-block\n * scores are then **summed** across queries, so a block matching more\n * queries ranks higher, and the fused list is cut to `topK` again.\n */\n search(\n queries: readonly string[],\n topK: number,\n pathPrefixes?: DocumentPath[],\n ): MemBlockHit<B>[] {\n const totals = new Map<string, MemBlockHit<B>>();\n const scoped = pathPrefixes !== undefined && pathPrefixes.length > 0;\n for (const query of queries) {\n let taken = 0;\n for (const { key, score } of this.engineRef.search(query, topK)) {\n if (taken >= topK) break;\n const block = this.blocks.get(key);\n if (!block) continue;\n if (scoped && !pathPrefixes.some((p) => matchesPrefix(block.path, p))) continue;\n taken += 1;\n const hit = totals.get(key);\n if (hit) hit.score += score;\n else totals.set(key, { block, score });\n }\n }\n return [...totals.values()].sort((a, b) => b.score - a.score).slice(0, topK);\n }\n\n /**\n * Serialize the blocks and the engine state into one JSON document,\n * `{ version: 3, ...engineState, blocks }`. While the store is clean the\n * previous output is returned as is.\n */\n serialize(): string {\n if (!this.dirty && this.cached !== undefined) return this.cached;\n const out = JSON.stringify({\n version: MEM_BLOCK_STORE_VERSION,\n ...this.engineRef.exportState(),\n blocks: [...this.blocks.values()],\n });\n this.cached = out;\n this.dirty = false;\n return out;\n }\n\n /**\n * Replace the store's content with a document written by {@link serialize}.\n * `restoreEngine` rebuilds the engine from the document's fields. Throws on\n * a document of another version, leaving the store unchanged. The store is\n * clean afterwards and re-serializes to `json` until it changes.\n */\n restore(\n json: string,\n restoreEngine: (state: Record<string, unknown>) => MemBlockEngine<B>,\n ): void {\n const parsed = JSON.parse(json) as { version?: unknown; blocks?: B[] } & Record<\n string,\n unknown\n >;\n if (parsed.version !== MEM_BLOCK_STORE_VERSION) {\n throw new Error(\n `${this.label}: unsupported serialised version ${String(parsed.version)} (expected ${MEM_BLOCK_STORE_VERSION})`,\n );\n }\n const engine = restoreEngine(parsed);\n const blocks = new Map<string, B>();\n for (const block of parsed.blocks ?? []) {\n blocks.set(compositeKey(block.path, block.blockId), block);\n }\n this.engineRef = engine;\n this.blocks = blocks;\n this.cached = json;\n this.dirty = false;\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,58 @@
1
+ import { MemBlockEngine, MemBlockStore } from "./mem-block-store.js";
2
+ import { BlockReference, DocumentPath, PathSelector, PersistableSearchIndex, PersistenceEntry } from "@statewalker/indexer-api";
3
+ import { FullTextBlock, FullTextIndex, FullTextIndexInfo, FulltextQuery, FulltextResult } from "@statewalker/indexer-fulltext";
4
+ //#region src/mem-full-text-index.d.ts
5
+ /** The engine of an in-memory full-text index: a {@link MemBlockEngine} over text blocks. */
6
+ export type MemFullTextEngine = MemBlockEngine<FullTextBlock>;
7
+ /**
8
+ * An in-memory {@link FullTextIndex} over a {@link MemBlockStore}. The store
9
+ * does everything but text indexing; a subclass supplies only the engine
10
+ * adapter (FlexSearch, MiniSearch, …) and how to rebuild it from saved state.
11
+ *
12
+ * Persistence: one entry named `json`, holding the store's framed document.
13
+ */
14
+ export declare abstract class MemFullTextIndex implements FullTextIndex, PersistableSearchIndex<FullTextBlock, FulltextQuery, FulltextResult> {
15
+ protected readonly info: FullTextIndexInfo;
16
+ protected readonly store: MemBlockStore<FullTextBlock>;
17
+ private closed;
18
+ /**
19
+ * @param label named in errors (usually the subclass name)
20
+ * @param engine the engine adapter for a fresh, empty index
21
+ */
22
+ protected constructor(info: FullTextIndexInfo, label: string, engine: MemFullTextEngine);
23
+ /** Rebuild the engine adapter from the fields it exported. */
24
+ protected abstract restoreEngine(state: Record<string, unknown>): MemFullTextEngine;
25
+ /**
26
+ * `true` when the in-memory state has changed since the last serialize or
27
+ * load. Callers (e.g. the persistence layer) may use it to skip writes.
28
+ */
29
+ isDirty(): boolean;
30
+ protected ensureOpen(): void;
31
+ /** The framed JSON document; cached while the index is unchanged. */
32
+ protected serializeState(): string;
33
+ /** Replace the content with a document from {@link serializeState}. */
34
+ protected restoreState(json: string): void;
35
+ getIndexInfo(): Promise<FullTextIndexInfo>;
36
+ search(params: FulltextQuery): AsyncGenerator<FulltextResult>;
37
+ addDocument(blocks: FullTextBlock[]): Promise<void>;
38
+ addDocuments(blocks: Iterable<FullTextBlock[]> | AsyncIterable<FullTextBlock[]>): Promise<void>;
39
+ deleteDocuments(pathSelectors: PathSelector[] | AsyncIterable<PathSelector>): Promise<void>;
40
+ getSize(pathPrefix?: DocumentPath): Promise<number>;
41
+ getDocumentPaths(pathPrefix?: DocumentPath): AsyncGenerator<DocumentPath>;
42
+ getDocumentBlocksRefs(pathPrefix?: DocumentPath): AsyncGenerator<BlockReference>;
43
+ getDocumentsBlocks(pathPrefix?: DocumentPath): AsyncGenerator<FullTextBlock>;
44
+ close(_options?: {
45
+ force?: boolean;
46
+ }): Promise<void>;
47
+ flush(): Promise<void>;
48
+ deleteIndex(): Promise<void>;
49
+ /**
50
+ * Stream the sub-index's state as a single entry named `json`. The composite
51
+ * prefixes it with `${indexName}/${subName}/` before it reaches persistence.
52
+ */
53
+ serialise(): AsyncIterable<PersistenceEntry>;
54
+ /** Restore the sub-index from a previously produced entry set. */
55
+ loadFrom(entries: Iterable<PersistenceEntry>): Promise<void>;
56
+ }
57
+ //#endregion
58
+ //# sourceMappingURL=mem-full-text-index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mem-full-text-index.d.ts","names":[],"sources":["../src/mem-full-text-index.ts"],"mappings":";;;;;YAkBY,oBAAoB,eAAe;;;;;;;;8BASzB,4BACT,eAAe,uBAAuB,eAAe,eAAe;qBAE5D,MAAM;qBACN,OAAO,cAAc;UAChC;;;;;YAMC,YAAa,MAAM,mBAAmB,eAAe,QAAQ;;qBAMnD,cAAc,OAAO,0BAA0B;;;;;EAMlE;YAIU;;YAKA;;YAKA,aAAa;EAIjB,gBAAgB,QAAQ;EAKvB,OAAO,QAAQ,gBAAgB,eAAe;EAS/C,YAAY,QAAQ,kBAAkB;EAYtC,aACJ,QAAQ,SAAS,mBAAmB,cAAc,mBACjD;EAOG,gBACJ,eAAe,iBAAiB,cAAc,gBAC7C;EAKG,QAAQ,aAAa,eAAe;EAKnC,iBAAiB,aAAa,eAAe,eAAe;EAK5D,sBAAsB,aAAa,eAAe,eAAe;EAOjE,mBAAmB,aAAa,eAAe,eAAe;EAO/D,MAAM;IAAa;MAAoB;EAIvC,SAAS;EAKT,eAAe;;;;;EAYd,aAAa,cAAc;;EAK5B,SAAS,SAAS,SAAS,oBAAoB"}
@@ -0,0 +1,130 @@
1
+ import { MemBlockStore } from "./mem-block-store.js";
2
+ import { readEntryBytes, singleChunk, toBytes } from "@statewalker/indexer-core";
3
+ //#region src/mem-full-text-index.ts
4
+ /**
5
+ * An in-memory {@link FullTextIndex} over a {@link MemBlockStore}. The store
6
+ * does everything but text indexing; a subclass supplies only the engine
7
+ * adapter (FlexSearch, MiniSearch, …) and how to rebuild it from saved state.
8
+ *
9
+ * Persistence: one entry named `json`, holding the store's framed document.
10
+ */
11
+ var MemFullTextIndex = class {
12
+ info;
13
+ store;
14
+ closed = false;
15
+ /**
16
+ * @param label named in errors (usually the subclass name)
17
+ * @param engine the engine adapter for a fresh, empty index
18
+ */
19
+ constructor(info, label, engine) {
20
+ this.info = info;
21
+ this.store = new MemBlockStore({
22
+ label,
23
+ engine
24
+ });
25
+ }
26
+ /**
27
+ * `true` when the in-memory state has changed since the last serialize or
28
+ * load. Callers (e.g. the persistence layer) may use it to skip writes.
29
+ */
30
+ isDirty() {
31
+ return this.store.isDirty();
32
+ }
33
+ ensureOpen() {
34
+ if (this.closed) throw new Error("FullTextIndex is closed");
35
+ }
36
+ /** The framed JSON document; cached while the index is unchanged. */
37
+ serializeState() {
38
+ return this.store.serialize();
39
+ }
40
+ /** Replace the content with a document from {@link serializeState}. */
41
+ restoreState(json) {
42
+ this.store.restore(json, (state) => this.restoreEngine(state));
43
+ }
44
+ async getIndexInfo() {
45
+ this.ensureOpen();
46
+ return { ...this.info };
47
+ }
48
+ async *search(params) {
49
+ this.ensureOpen();
50
+ const { queries, paths } = params;
51
+ if (!queries || queries.length === 0) return;
52
+ for (const { block, score } of this.store.search(queries, params.topK ?? 100, paths)) yield {
53
+ path: block.path,
54
+ blockId: block.blockId,
55
+ snippet: block.content,
56
+ score
57
+ };
58
+ }
59
+ async addDocument(blocks) {
60
+ this.ensureOpen();
61
+ this.store.put(blocks.map((b) => ({
62
+ path: b.path,
63
+ blockId: b.blockId,
64
+ content: b.content,
65
+ metadata: b.metadata
66
+ })));
67
+ }
68
+ async addDocuments(blocks) {
69
+ this.ensureOpen();
70
+ for await (const batch of blocks) await this.addDocument(batch);
71
+ }
72
+ async deleteDocuments(pathSelectors) {
73
+ this.ensureOpen();
74
+ await this.store.delete(pathSelectors);
75
+ }
76
+ async getSize(pathPrefix) {
77
+ this.ensureOpen();
78
+ return this.store.count(pathPrefix);
79
+ }
80
+ async *getDocumentPaths(pathPrefix) {
81
+ this.ensureOpen();
82
+ yield* this.store.paths(pathPrefix);
83
+ }
84
+ async *getDocumentBlocksRefs(pathPrefix) {
85
+ this.ensureOpen();
86
+ for (const block of this.store.select(pathPrefix)) yield {
87
+ path: block.path,
88
+ blockId: block.blockId
89
+ };
90
+ }
91
+ async *getDocumentsBlocks(pathPrefix) {
92
+ this.ensureOpen();
93
+ for (const block of this.store.select(pathPrefix)) yield { ...block };
94
+ }
95
+ async close(_options) {
96
+ this.closed = true;
97
+ }
98
+ async flush() {
99
+ this.ensureOpen();
100
+ }
101
+ async deleteIndex() {
102
+ this.ensureOpen();
103
+ this.store.clear();
104
+ this.closed = true;
105
+ }
106
+ /**
107
+ * Stream the sub-index's state as a single entry named `json`. The composite
108
+ * prefixes it with `${indexName}/${subName}/` before it reaches persistence.
109
+ */
110
+ async *serialise() {
111
+ yield {
112
+ name: "json",
113
+ content: singleChunk(toBytes(this.serializeState()))
114
+ };
115
+ }
116
+ /** Restore the sub-index from a previously produced entry set. */
117
+ async loadFrom(entries) {
118
+ this.ensureOpen();
119
+ for (const e of entries) {
120
+ if (e.name !== "json") continue;
121
+ const bytes = await readEntryBytes(e);
122
+ this.restoreState(new TextDecoder().decode(bytes));
123
+ return;
124
+ }
125
+ }
126
+ };
127
+ //#endregion
128
+ export { MemFullTextIndex };
129
+
130
+ //# sourceMappingURL=mem-full-text-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mem-full-text-index.js","names":[],"sources":["../src/mem-full-text-index.ts"],"sourcesContent":["import type {\n BlockReference,\n DocumentPath,\n PathSelector,\n PersistableSearchIndex,\n PersistenceEntry,\n} from \"@statewalker/indexer-api\";\nimport { readEntryBytes, singleChunk, toBytes } from \"@statewalker/indexer-core\";\nimport type {\n FullTextBlock,\n FullTextIndex,\n FullTextIndexInfo,\n FulltextQuery,\n FulltextResult,\n} from \"@statewalker/indexer-fulltext\";\nimport { type MemBlockEngine, MemBlockStore } from \"./mem-block-store.js\";\n\n/** The engine of an in-memory full-text index: a {@link MemBlockEngine} over text blocks. */\nexport type MemFullTextEngine = MemBlockEngine<FullTextBlock>;\n\n/**\n * An in-memory {@link FullTextIndex} over a {@link MemBlockStore}. The store\n * does everything but text indexing; a subclass supplies only the engine\n * adapter (FlexSearch, MiniSearch, …) and how to rebuild it from saved state.\n *\n * Persistence: one entry named `json`, holding the store's framed document.\n */\nexport abstract class MemFullTextIndex\n implements FullTextIndex, PersistableSearchIndex<FullTextBlock, FulltextQuery, FulltextResult>\n{\n protected readonly info: FullTextIndexInfo;\n protected readonly store: MemBlockStore<FullTextBlock>;\n private closed = false;\n\n /**\n * @param label named in errors (usually the subclass name)\n * @param engine the engine adapter for a fresh, empty index\n */\n protected constructor(info: FullTextIndexInfo, label: string, engine: MemFullTextEngine) {\n this.info = info;\n this.store = new MemBlockStore<FullTextBlock>({ label, engine });\n }\n\n /** Rebuild the engine adapter from the fields it exported. */\n protected abstract restoreEngine(state: Record<string, unknown>): MemFullTextEngine;\n\n /**\n * `true` when the in-memory state has changed since the last serialize or\n * load. Callers (e.g. the persistence layer) may use it to skip writes.\n */\n isDirty(): boolean {\n return this.store.isDirty();\n }\n\n protected ensureOpen(): void {\n if (this.closed) throw new Error(\"FullTextIndex is closed\");\n }\n\n /** The framed JSON document; cached while the index is unchanged. */\n protected serializeState(): string {\n return this.store.serialize();\n }\n\n /** Replace the content with a document from {@link serializeState}. */\n protected restoreState(json: string): void {\n this.store.restore(json, (state) => this.restoreEngine(state));\n }\n\n async getIndexInfo(): Promise<FullTextIndexInfo> {\n this.ensureOpen();\n return { ...this.info };\n }\n\n async *search(params: FulltextQuery): AsyncGenerator<FulltextResult> {\n this.ensureOpen();\n const { queries, paths } = params;\n if (!queries || queries.length === 0) return;\n for (const { block, score } of this.store.search(queries, params.topK ?? 100, paths)) {\n yield { path: block.path, blockId: block.blockId, snippet: block.content, score };\n }\n }\n\n async addDocument(blocks: FullTextBlock[]): Promise<void> {\n this.ensureOpen();\n this.store.put(\n blocks.map((b) => ({\n path: b.path,\n blockId: b.blockId,\n content: b.content,\n metadata: b.metadata,\n })),\n );\n }\n\n async addDocuments(\n blocks: Iterable<FullTextBlock[]> | AsyncIterable<FullTextBlock[]>,\n ): Promise<void> {\n this.ensureOpen();\n for await (const batch of blocks) {\n await this.addDocument(batch);\n }\n }\n\n async deleteDocuments(\n pathSelectors: PathSelector[] | AsyncIterable<PathSelector>,\n ): Promise<void> {\n this.ensureOpen();\n await this.store.delete(pathSelectors);\n }\n\n async getSize(pathPrefix?: DocumentPath): Promise<number> {\n this.ensureOpen();\n return this.store.count(pathPrefix);\n }\n\n async *getDocumentPaths(pathPrefix?: DocumentPath): AsyncGenerator<DocumentPath> {\n this.ensureOpen();\n yield* this.store.paths(pathPrefix);\n }\n\n async *getDocumentBlocksRefs(pathPrefix?: DocumentPath): AsyncGenerator<BlockReference> {\n this.ensureOpen();\n for (const block of this.store.select(pathPrefix)) {\n yield { path: block.path, blockId: block.blockId };\n }\n }\n\n async *getDocumentsBlocks(pathPrefix?: DocumentPath): AsyncGenerator<FullTextBlock> {\n this.ensureOpen();\n for (const block of this.store.select(pathPrefix)) {\n yield { ...block };\n }\n }\n\n async close(_options?: { force?: boolean }): Promise<void> {\n this.closed = true;\n }\n\n async flush(): Promise<void> {\n this.ensureOpen();\n // no-op for in-memory\n }\n\n async deleteIndex(): Promise<void> {\n this.ensureOpen();\n this.store.clear();\n this.closed = true;\n }\n\n // --- PersistableSearchIndex ------------------------------------------------\n\n /**\n * Stream the sub-index's state as a single entry named `json`. The composite\n * prefixes it with `${indexName}/${subName}/` before it reaches persistence.\n */\n async *serialise(): AsyncIterable<PersistenceEntry> {\n yield { name: \"json\", content: singleChunk(toBytes(this.serializeState())) };\n }\n\n /** Restore the sub-index from a previously produced entry set. */\n async loadFrom(entries: Iterable<PersistenceEntry>): Promise<void> {\n this.ensureOpen();\n for (const e of entries) {\n if (e.name !== \"json\") continue;\n const bytes = await readEntryBytes(e);\n this.restoreState(new TextDecoder().decode(bytes));\n return;\n }\n }\n}\n"],"mappings":";;;;;;;;;;AA2BA,IAAsB,mBAAtB,MAEA;CACE;CACA;CACA,SAAiB;;;;;CAMjB,YAAsB,MAAyB,OAAe,QAA2B;EACvF,KAAK,OAAO;EACZ,KAAK,QAAQ,IAAI,cAA6B;GAAE;GAAO;EAAO,CAAC;CACjE;;;;;CASA,UAAmB;EACjB,OAAO,KAAK,MAAM,QAAQ;CAC5B;CAEA,aAA6B;EAC3B,IAAI,KAAK,QAAQ,MAAM,IAAI,MAAM,yBAAyB;CAC5D;;CAGA,iBAAmC;EACjC,OAAO,KAAK,MAAM,UAAU;CAC9B;;CAGA,aAAuB,MAAoB;EACzC,KAAK,MAAM,QAAQ,OAAO,UAAU,KAAK,cAAc,KAAK,CAAC;CAC/D;CAEA,MAAM,eAA2C;EAC/C,KAAK,WAAW;EAChB,OAAO,EAAE,GAAG,KAAK,KAAK;CACxB;CAEA,OAAO,OAAO,QAAuD;EACnE,KAAK,WAAW;EAChB,MAAM,EAAE,SAAS,UAAU;EAC3B,IAAI,CAAC,WAAW,QAAQ,WAAW,GAAG;EACtC,KAAK,MAAM,EAAE,OAAO,WAAW,KAAK,MAAM,OAAO,SAAS,OAAO,QAAQ,KAAK,KAAK,GACjF,MAAM;GAAE,MAAM,MAAM;GAAM,SAAS,MAAM;GAAS,SAAS,MAAM;GAAS;EAAM;CAEpF;CAEA,MAAM,YAAY,QAAwC;EACxD,KAAK,WAAW;EAChB,KAAK,MAAM,IACT,OAAO,KAAK,OAAO;GACjB,MAAM,EAAE;GACR,SAAS,EAAE;GACX,SAAS,EAAE;GACX,UAAU,EAAE;EACd,EAAE,CACJ;CACF;CAEA,MAAM,aACJ,QACe;EACf,KAAK,WAAW;EAChB,WAAW,MAAM,SAAS,QACxB,MAAM,KAAK,YAAY,KAAK;CAEhC;CAEA,MAAM,gBACJ,eACe;EACf,KAAK,WAAW;EAChB,MAAM,KAAK,MAAM,OAAO,aAAa;CACvC;CAEA,MAAM,QAAQ,YAA4C;EACxD,KAAK,WAAW;EAChB,OAAO,KAAK,MAAM,MAAM,UAAU;CACpC;CAEA,OAAO,iBAAiB,YAAyD;EAC/E,KAAK,WAAW;EAChB,OAAO,KAAK,MAAM,MAAM,UAAU;CACpC;CAEA,OAAO,sBAAsB,YAA2D;EACtF,KAAK,WAAW;EAChB,KAAK,MAAM,SAAS,KAAK,MAAM,OAAO,UAAU,GAC9C,MAAM;GAAE,MAAM,MAAM;GAAM,SAAS,MAAM;EAAQ;CAErD;CAEA,OAAO,mBAAmB,YAA0D;EAClF,KAAK,WAAW;EAChB,KAAK,MAAM,SAAS,KAAK,MAAM,OAAO,UAAU,GAC9C,MAAM,EAAE,GAAG,MAAM;CAErB;CAEA,MAAM,MAAM,UAA+C;EACzD,KAAK,SAAS;CAChB;CAEA,MAAM,QAAuB;EAC3B,KAAK,WAAW;CAElB;CAEA,MAAM,cAA6B;EACjC,KAAK,WAAW;EAChB,KAAK,MAAM,MAAM;EACjB,KAAK,SAAS;CAChB;;;;;CAQA,OAAO,YAA6C;EAClD,MAAM;GAAE,MAAM;GAAQ,SAAS,YAAY,QAAQ,KAAK,eAAe,CAAC,CAAC;EAAE;CAC7E;;CAGA,MAAM,SAAS,SAAoD;EACjE,KAAK,WAAW;EAChB,KAAK,MAAM,KAAK,SAAS;GACvB,IAAI,EAAE,SAAS,QAAQ;GACvB,MAAM,QAAQ,MAAM,eAAe,CAAC;GACpC,KAAK,aAAa,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,CAAC;GACjD;EACF;CACF;AACF"}
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@statewalker/indexer-mem",
3
- "version": "0.1.5",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "In-memory vector sub-index (MemVectorIndex, brute-force cosine, Arrow persistence) and its VectorProvider.",
6
+ "description": "In-memory sub-indexes: the block store and full-text base behind the FlexSearch and MiniSearch indexes, and the vector sub-index (MemVectorIndex, brute-force cosine, Arrow persistence) with its VectorProvider.",
7
7
  "bugs": {
8
8
  "url": "https://github.com/statewalker/statewalker-search/issues"
9
9
  },
@@ -32,9 +32,10 @@
32
32
  ],
33
33
  "dependencies": {
34
34
  "@uwdata/flechette": "^2.5.0",
35
- "@statewalker/indexer-api": "^0.1.4",
36
- "@statewalker/indexer-core": "^0.1.4",
37
- "@statewalker/indexer-vector": "^0.1.5"
35
+ "@statewalker/indexer-api": "^0.1.5",
36
+ "@statewalker/indexer-core": "^0.2.0",
37
+ "@statewalker/indexer-fulltext": "^0.1.6",
38
+ "@statewalker/indexer-vector": "^0.1.6"
38
39
  },
39
40
  "devDependencies": {
40
41
  "rimraf": "^6.1.3",
package/src/index.ts CHANGED
@@ -1,2 +1,11 @@
1
+ export {
2
+ MEM_BLOCK_STORE_VERSION,
3
+ type MemBlockEngine,
4
+ type MemBlockHit,
5
+ MemBlockStore,
6
+ type MemBlockStoreOptions,
7
+ type RankedKey,
8
+ } from "./mem-block-store.js";
9
+ export { type MemFullTextEngine, MemFullTextIndex } from "./mem-full-text-index.js";
1
10
  export { MemVectorIndex } from "./mem-vector-index.js";
2
11
  export { memVectorProvider } from "./mem-vector-provider.js";
@@ -0,0 +1,251 @@
1
+ import type { BlockReference, DocumentPath, PathSelector } from "@statewalker/indexer-api";
2
+ import { compositeKey, matchesPrefix, toAsyncIterable } from "@statewalker/indexer-core";
3
+
4
+ /** One engine hit: the store key of a block and the engine's native score. */
5
+ export interface RankedKey {
6
+ key: string;
7
+ score: number;
8
+ }
9
+
10
+ /**
11
+ * The engine side of a {@link MemBlockStore}: what one search engine
12
+ * (FlexSearch, MiniSearch, …) supplies. The store owns the blocks and calls
13
+ * the engine only to index, unindex and rank them, addressed by the store's
14
+ * key for a block.
15
+ */
16
+ export interface MemBlockEngine<B> {
17
+ /** Index `block` under `key`. A previous version is always removed first. */
18
+ add(key: string, block: B): void;
19
+ /** Unindex the block under `key`. Called only for keys the store holds. */
20
+ remove(key: string): void;
21
+ /**
22
+ * Rank the indexed blocks for one query, best first. `limit` is the depth
23
+ * the caller wants after path filtering; an engine may return more.
24
+ * Keys the store no longer holds are ignored.
25
+ */
26
+ search(query: string, limit: number): RankedKey[];
27
+ /**
28
+ * The engine's own state as JSON-safe fields, framed with the blocks by
29
+ * {@link MemBlockStore.serialize}. Must not use the names `version` or `blocks`.
30
+ */
31
+ exportState(): Record<string, unknown>;
32
+ }
33
+
34
+ /** A fused hit from {@link MemBlockStore.search}. */
35
+ export interface MemBlockHit<B> {
36
+ block: B;
37
+ /** The sum of the block's scores over every query it matched. */
38
+ score: number;
39
+ }
40
+
41
+ export interface MemBlockStoreOptions<B> {
42
+ /** Named in errors, e.g. the class of the index that owns the store. */
43
+ label: string;
44
+ engine: MemBlockEngine<B>;
45
+ }
46
+
47
+ /** The persistence format version written by {@link MemBlockStore.serialize}. */
48
+ export const MEM_BLOCK_STORE_VERSION = 3;
49
+
50
+ /**
51
+ * In-memory block store shared by the in-memory search engines.
52
+ *
53
+ * Owns the blocks keyed by `path` + `blockId`, path-prefix selection,
54
+ * enumeration, multi-query score fusion, dirty tracking and the persistence
55
+ * framing; the {@link MemBlockEngine} supplies only add, remove and search.
56
+ *
57
+ * Dirty tracking: the store starts dirty, becomes clean after
58
+ * {@link serialize} or {@link restore}, and becomes dirty again only on a
59
+ * change. Empty batches and deletes that remove nothing are not changes.
60
+ * While clean, `serialize` returns the cached output without asking the
61
+ * engine to export again.
62
+ */
63
+ export class MemBlockStore<B extends BlockReference> {
64
+ private readonly label: string;
65
+ private engineRef: MemBlockEngine<B>;
66
+ private blocks = new Map<string, B>();
67
+ private dirty = true;
68
+ private cached?: string;
69
+
70
+ constructor(options: MemBlockStoreOptions<B>) {
71
+ this.label = options.label;
72
+ this.engineRef = options.engine;
73
+ }
74
+
75
+ /** The engine currently indexing the blocks (replaced by {@link restore}). */
76
+ get engine(): MemBlockEngine<B> {
77
+ return this.engineRef;
78
+ }
79
+
80
+ /** `true` when the blocks changed since the last serialize or restore. */
81
+ isDirty(): boolean {
82
+ return this.dirty;
83
+ }
84
+
85
+ private markDirty(): void {
86
+ this.dirty = true;
87
+ this.cached = undefined;
88
+ }
89
+
90
+ /** The block under `key`, if any. */
91
+ get(key: string): B | undefined {
92
+ return this.blocks.get(key);
93
+ }
94
+
95
+ /**
96
+ * Add or replace a batch of blocks. An empty batch changes nothing and
97
+ * leaves the store clean.
98
+ */
99
+ put(batch: readonly B[]): void {
100
+ if (batch.length === 0) return;
101
+ for (const block of batch) {
102
+ const key = compositeKey(block.path, block.blockId);
103
+ if (this.blocks.has(key)) this.engineRef.remove(key);
104
+ this.engineRef.add(key, block);
105
+ this.blocks.set(key, block);
106
+ }
107
+ this.markDirty();
108
+ }
109
+
110
+ /**
111
+ * Delete the blocks the selectors match: one block when `blockId` is set,
112
+ * otherwise every block under the path prefix. Returns how many blocks were
113
+ * removed; the store becomes dirty only when that is more than zero.
114
+ */
115
+ async delete(selectors: PathSelector[] | AsyncIterable<PathSelector>): Promise<number> {
116
+ let removed = 0;
117
+ for await (const sel of toAsyncIterable(selectors)) {
118
+ if (sel.blockId !== undefined) {
119
+ if (this.removeKey(compositeKey(sel.path, sel.blockId))) removed += 1;
120
+ } else {
121
+ for (const key of [...this.keys(sel.path)]) {
122
+ if (this.removeKey(key)) removed += 1;
123
+ }
124
+ }
125
+ }
126
+ if (removed > 0) this.markDirty();
127
+ return removed;
128
+ }
129
+
130
+ private removeKey(key: string): boolean {
131
+ if (!this.blocks.has(key)) return false;
132
+ this.engineRef.remove(key);
133
+ this.blocks.delete(key);
134
+ return true;
135
+ }
136
+
137
+ /** Drop every block, from the store and the engine. */
138
+ clear(): void {
139
+ for (const key of this.blocks.keys()) this.engineRef.remove(key);
140
+ this.blocks.clear();
141
+ this.markDirty();
142
+ }
143
+
144
+ private *keys(pathPrefix?: DocumentPath): Iterable<string> {
145
+ for (const [key, block] of this.blocks) {
146
+ if (pathPrefix === undefined || matchesPrefix(block.path, pathPrefix)) yield key;
147
+ }
148
+ }
149
+
150
+ /** The blocks under `pathPrefix` (all blocks without one), in insertion order. */
151
+ *select(pathPrefix?: DocumentPath): Iterable<B> {
152
+ for (const key of this.keys(pathPrefix)) {
153
+ const block = this.blocks.get(key);
154
+ if (block) yield block;
155
+ }
156
+ }
157
+
158
+ /** How many blocks lie under `pathPrefix` (all blocks without one). */
159
+ count(pathPrefix?: DocumentPath): number {
160
+ if (pathPrefix === undefined) return this.blocks.size;
161
+ let n = 0;
162
+ for (const _ of this.keys(pathPrefix)) n += 1;
163
+ return n;
164
+ }
165
+
166
+ /** The distinct document paths under `pathPrefix`, in first-seen order. */
167
+ *paths(pathPrefix?: DocumentPath): Iterable<DocumentPath> {
168
+ const seen = new Set<DocumentPath>();
169
+ for (const block of this.select(pathPrefix)) {
170
+ if (seen.has(block.path)) continue;
171
+ seen.add(block.path);
172
+ yield block.path;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Multi-query search with score fusion. Each query is ranked by the engine,
178
+ * filtered to `pathPrefixes` (when given) and cut to `topK`; the per-block
179
+ * scores are then **summed** across queries, so a block matching more
180
+ * queries ranks higher, and the fused list is cut to `topK` again.
181
+ */
182
+ search(
183
+ queries: readonly string[],
184
+ topK: number,
185
+ pathPrefixes?: DocumentPath[],
186
+ ): MemBlockHit<B>[] {
187
+ const totals = new Map<string, MemBlockHit<B>>();
188
+ const scoped = pathPrefixes !== undefined && pathPrefixes.length > 0;
189
+ for (const query of queries) {
190
+ let taken = 0;
191
+ for (const { key, score } of this.engineRef.search(query, topK)) {
192
+ if (taken >= topK) break;
193
+ const block = this.blocks.get(key);
194
+ if (!block) continue;
195
+ if (scoped && !pathPrefixes.some((p) => matchesPrefix(block.path, p))) continue;
196
+ taken += 1;
197
+ const hit = totals.get(key);
198
+ if (hit) hit.score += score;
199
+ else totals.set(key, { block, score });
200
+ }
201
+ }
202
+ return [...totals.values()].sort((a, b) => b.score - a.score).slice(0, topK);
203
+ }
204
+
205
+ /**
206
+ * Serialize the blocks and the engine state into one JSON document,
207
+ * `{ version: 3, ...engineState, blocks }`. While the store is clean the
208
+ * previous output is returned as is.
209
+ */
210
+ serialize(): string {
211
+ if (!this.dirty && this.cached !== undefined) return this.cached;
212
+ const out = JSON.stringify({
213
+ version: MEM_BLOCK_STORE_VERSION,
214
+ ...this.engineRef.exportState(),
215
+ blocks: [...this.blocks.values()],
216
+ });
217
+ this.cached = out;
218
+ this.dirty = false;
219
+ return out;
220
+ }
221
+
222
+ /**
223
+ * Replace the store's content with a document written by {@link serialize}.
224
+ * `restoreEngine` rebuilds the engine from the document's fields. Throws on
225
+ * a document of another version, leaving the store unchanged. The store is
226
+ * clean afterwards and re-serializes to `json` until it changes.
227
+ */
228
+ restore(
229
+ json: string,
230
+ restoreEngine: (state: Record<string, unknown>) => MemBlockEngine<B>,
231
+ ): void {
232
+ const parsed = JSON.parse(json) as { version?: unknown; blocks?: B[] } & Record<
233
+ string,
234
+ unknown
235
+ >;
236
+ if (parsed.version !== MEM_BLOCK_STORE_VERSION) {
237
+ throw new Error(
238
+ `${this.label}: unsupported serialised version ${String(parsed.version)} (expected ${MEM_BLOCK_STORE_VERSION})`,
239
+ );
240
+ }
241
+ const engine = restoreEngine(parsed);
242
+ const blocks = new Map<string, B>();
243
+ for (const block of parsed.blocks ?? []) {
244
+ blocks.set(compositeKey(block.path, block.blockId), block);
245
+ }
246
+ this.engineRef = engine;
247
+ this.blocks = blocks;
248
+ this.cached = json;
249
+ this.dirty = false;
250
+ }
251
+ }
@@ -0,0 +1,170 @@
1
+ import type {
2
+ BlockReference,
3
+ DocumentPath,
4
+ PathSelector,
5
+ PersistableSearchIndex,
6
+ PersistenceEntry,
7
+ } from "@statewalker/indexer-api";
8
+ import { readEntryBytes, singleChunk, toBytes } from "@statewalker/indexer-core";
9
+ import type {
10
+ FullTextBlock,
11
+ FullTextIndex,
12
+ FullTextIndexInfo,
13
+ FulltextQuery,
14
+ FulltextResult,
15
+ } from "@statewalker/indexer-fulltext";
16
+ import { type MemBlockEngine, MemBlockStore } from "./mem-block-store.js";
17
+
18
+ /** The engine of an in-memory full-text index: a {@link MemBlockEngine} over text blocks. */
19
+ export type MemFullTextEngine = MemBlockEngine<FullTextBlock>;
20
+
21
+ /**
22
+ * An in-memory {@link FullTextIndex} over a {@link MemBlockStore}. The store
23
+ * does everything but text indexing; a subclass supplies only the engine
24
+ * adapter (FlexSearch, MiniSearch, …) and how to rebuild it from saved state.
25
+ *
26
+ * Persistence: one entry named `json`, holding the store's framed document.
27
+ */
28
+ export abstract class MemFullTextIndex
29
+ implements FullTextIndex, PersistableSearchIndex<FullTextBlock, FulltextQuery, FulltextResult>
30
+ {
31
+ protected readonly info: FullTextIndexInfo;
32
+ protected readonly store: MemBlockStore<FullTextBlock>;
33
+ private closed = false;
34
+
35
+ /**
36
+ * @param label named in errors (usually the subclass name)
37
+ * @param engine the engine adapter for a fresh, empty index
38
+ */
39
+ protected constructor(info: FullTextIndexInfo, label: string, engine: MemFullTextEngine) {
40
+ this.info = info;
41
+ this.store = new MemBlockStore<FullTextBlock>({ label, engine });
42
+ }
43
+
44
+ /** Rebuild the engine adapter from the fields it exported. */
45
+ protected abstract restoreEngine(state: Record<string, unknown>): MemFullTextEngine;
46
+
47
+ /**
48
+ * `true` when the in-memory state has changed since the last serialize or
49
+ * load. Callers (e.g. the persistence layer) may use it to skip writes.
50
+ */
51
+ isDirty(): boolean {
52
+ return this.store.isDirty();
53
+ }
54
+
55
+ protected ensureOpen(): void {
56
+ if (this.closed) throw new Error("FullTextIndex is closed");
57
+ }
58
+
59
+ /** The framed JSON document; cached while the index is unchanged. */
60
+ protected serializeState(): string {
61
+ return this.store.serialize();
62
+ }
63
+
64
+ /** Replace the content with a document from {@link serializeState}. */
65
+ protected restoreState(json: string): void {
66
+ this.store.restore(json, (state) => this.restoreEngine(state));
67
+ }
68
+
69
+ async getIndexInfo(): Promise<FullTextIndexInfo> {
70
+ this.ensureOpen();
71
+ return { ...this.info };
72
+ }
73
+
74
+ async *search(params: FulltextQuery): AsyncGenerator<FulltextResult> {
75
+ this.ensureOpen();
76
+ const { queries, paths } = params;
77
+ if (!queries || queries.length === 0) return;
78
+ for (const { block, score } of this.store.search(queries, params.topK ?? 100, paths)) {
79
+ yield { path: block.path, blockId: block.blockId, snippet: block.content, score };
80
+ }
81
+ }
82
+
83
+ async addDocument(blocks: FullTextBlock[]): Promise<void> {
84
+ this.ensureOpen();
85
+ this.store.put(
86
+ blocks.map((b) => ({
87
+ path: b.path,
88
+ blockId: b.blockId,
89
+ content: b.content,
90
+ metadata: b.metadata,
91
+ })),
92
+ );
93
+ }
94
+
95
+ async addDocuments(
96
+ blocks: Iterable<FullTextBlock[]> | AsyncIterable<FullTextBlock[]>,
97
+ ): Promise<void> {
98
+ this.ensureOpen();
99
+ for await (const batch of blocks) {
100
+ await this.addDocument(batch);
101
+ }
102
+ }
103
+
104
+ async deleteDocuments(
105
+ pathSelectors: PathSelector[] | AsyncIterable<PathSelector>,
106
+ ): Promise<void> {
107
+ this.ensureOpen();
108
+ await this.store.delete(pathSelectors);
109
+ }
110
+
111
+ async getSize(pathPrefix?: DocumentPath): Promise<number> {
112
+ this.ensureOpen();
113
+ return this.store.count(pathPrefix);
114
+ }
115
+
116
+ async *getDocumentPaths(pathPrefix?: DocumentPath): AsyncGenerator<DocumentPath> {
117
+ this.ensureOpen();
118
+ yield* this.store.paths(pathPrefix);
119
+ }
120
+
121
+ async *getDocumentBlocksRefs(pathPrefix?: DocumentPath): AsyncGenerator<BlockReference> {
122
+ this.ensureOpen();
123
+ for (const block of this.store.select(pathPrefix)) {
124
+ yield { path: block.path, blockId: block.blockId };
125
+ }
126
+ }
127
+
128
+ async *getDocumentsBlocks(pathPrefix?: DocumentPath): AsyncGenerator<FullTextBlock> {
129
+ this.ensureOpen();
130
+ for (const block of this.store.select(pathPrefix)) {
131
+ yield { ...block };
132
+ }
133
+ }
134
+
135
+ async close(_options?: { force?: boolean }): Promise<void> {
136
+ this.closed = true;
137
+ }
138
+
139
+ async flush(): Promise<void> {
140
+ this.ensureOpen();
141
+ // no-op for in-memory
142
+ }
143
+
144
+ async deleteIndex(): Promise<void> {
145
+ this.ensureOpen();
146
+ this.store.clear();
147
+ this.closed = true;
148
+ }
149
+
150
+ // --- PersistableSearchIndex ------------------------------------------------
151
+
152
+ /**
153
+ * Stream the sub-index's state as a single entry named `json`. The composite
154
+ * prefixes it with `${indexName}/${subName}/` before it reaches persistence.
155
+ */
156
+ async *serialise(): AsyncIterable<PersistenceEntry> {
157
+ yield { name: "json", content: singleChunk(toBytes(this.serializeState())) };
158
+ }
159
+
160
+ /** Restore the sub-index from a previously produced entry set. */
161
+ async loadFrom(entries: Iterable<PersistenceEntry>): Promise<void> {
162
+ this.ensureOpen();
163
+ for (const e of entries) {
164
+ if (e.name !== "json") continue;
165
+ const bytes = await readEntryBytes(e);
166
+ this.restoreState(new TextDecoder().decode(bytes));
167
+ return;
168
+ }
169
+ }
170
+ }