@statewalker/indexer-sqlite 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +239 -2
- package/dist/block-ids.d.ts +20 -0
- package/dist/block-ids.d.ts.map +1 -0
- package/dist/block-ids.js +47 -0
- package/dist/block-ids.js.map +1 -0
- package/dist/create-sqlite-indexer.d.ts +18 -0
- package/dist/create-sqlite-indexer.d.ts.map +1 -0
- package/dist/create-sqlite-indexer.js +152 -0
- package/dist/create-sqlite-indexer.js.map +1 -0
- package/dist/enumeration.d.ts +18 -0
- package/dist/enumeration.d.ts.map +1 -0
- package/dist/enumeration.js +42 -0
- package/dist/enumeration.js.map +1 -0
- package/dist/fts-provider.d.ts +6 -0
- package/dist/fts-provider.d.ts.map +1 -0
- package/dist/fts-provider.js +143 -0
- package/dist/fts-provider.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/ngram-provider.d.ts +12 -0
- package/dist/ngram-provider.d.ts.map +1 -0
- package/dist/ngram-provider.js +142 -0
- package/dist/ngram-provider.js.map +1 -0
- package/dist/path-sql.d.ts +15 -0
- package/dist/path-sql.d.ts.map +1 -0
- package/dist/path-sql.js +23 -0
- package/dist/path-sql.js.map +1 -0
- package/dist/types.d.ts +24 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/vector-provider.d.ts +12 -0
- package/dist/vector-provider.d.ts.map +1 -0
- package/dist/vector-provider.js +114 -0
- package/dist/vector-provider.js.map +1 -0
- package/package.json +61 -3
- package/src/block-ids.ts +75 -0
- package/src/create-sqlite-indexer.ts +218 -0
- package/src/enumeration.ts +54 -0
- package/src/fts-provider.ts +170 -0
- package/src/index.ts +9 -0
- package/src/ngram-provider.ts +153 -0
- package/src/path-sql.ts +17 -0
- package/src/types.ts +26 -0
- package/src/vector-provider.ts +132 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikhail Kotelnikov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,240 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @statewalker/indexer-sqlite
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## What it is
|
|
4
|
+
|
|
5
|
+
An `Indexer` whose full-text (FTS5), vector and n-gram/grep sub-indexes all live in one SQLite
|
|
6
|
+
`Db` from `@statewalker/db-api`: `node:sqlite` on Node.js, sqlite-wasm in a browser worker, or a
|
|
7
|
+
Durable Object's `ctx.storage.sql`. Each modality is a provider you register; an Index combines
|
|
8
|
+
any number of named sub-indexes and fuses their results by reciprocal rank fusion (RRF).
|
|
9
|
+
|
|
10
|
+
## Why it exists
|
|
11
|
+
|
|
12
|
+
One SQLite file holds every modality of every Index, so there is nothing to save or load: a new
|
|
13
|
+
indexer over the same database lists the indexes and searches the old data. Inside a Durable
|
|
14
|
+
Object it runs on the object's own SQLite storage, so a cold start opens the indexes where they
|
|
15
|
+
are, with nothing to load. The n-gram sub-index answers exact substring and regex queries (`fooBar(`, `\bTODO\b`)
|
|
16
|
+
that word-based full-text search cannot.
|
|
17
|
+
|
|
18
|
+
## How to use
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pnpm add @statewalker/indexer-sqlite
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `createSqliteIndexer({ db, providers }): Promise<Indexer>` — `db` is a `Db` from
|
|
25
|
+
`@statewalker/db-sqlite-node`, `@statewalker/db-sqlite-do` or another `@statewalker/db-sqlite*`
|
|
26
|
+
driver. Pass one provider per modality your indexes use; for all three:
|
|
27
|
+
`[newSqliteFullTextProvider(), newSqliteVectorProvider(), newSqliteNgramProvider()]`. A
|
|
28
|
+
sub-index type with no provider fails `createIndex` and `getIndex` (see below).
|
|
29
|
+
- The indexer never closes `db`. `indexer.close()` closes the open indexes; close the `Db`
|
|
30
|
+
yourself.
|
|
31
|
+
- Sub-indexes are configured, queried and read with the modality packages:
|
|
32
|
+
`@statewalker/indexer-fulltext`, `@statewalker/indexer-vector`, `@statewalker/indexer-ngram`
|
|
33
|
+
(including its `grep` helper).
|
|
34
|
+
- `SqliteModalityProvider`, `openBlockIds`, `newEnumeration` and `pathPrefixSql` are exported for
|
|
35
|
+
writing another provider.
|
|
36
|
+
|
|
37
|
+
## Examples
|
|
38
|
+
|
|
39
|
+
Node.js, with a file database and all three modalities:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { newNodeSqliteDb } from "@statewalker/db-sqlite-node";
|
|
43
|
+
import { newFullTextAccess, setFullTextConfig } from "@statewalker/indexer-fulltext";
|
|
44
|
+
import { grep, newNgramAccess, setNgramConfig } from "@statewalker/indexer-ngram";
|
|
45
|
+
import { newVectorAccess, setVectorConfig } from "@statewalker/indexer-vector";
|
|
46
|
+
import {
|
|
47
|
+
createSqliteIndexer,
|
|
48
|
+
newSqliteFullTextProvider,
|
|
49
|
+
newSqliteNgramProvider,
|
|
50
|
+
newSqliteVectorProvider,
|
|
51
|
+
} from "@statewalker/indexer-sqlite";
|
|
52
|
+
|
|
53
|
+
const db = await newNodeSqliteDb({ path: "./search.sqlite" });
|
|
54
|
+
const indexer = await createSqliteIndexer({
|
|
55
|
+
db,
|
|
56
|
+
providers: [newSqliteFullTextProvider(), newSqliteVectorProvider(), newSqliteNgramProvider()],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const params = { name: "docs" };
|
|
60
|
+
setFullTextConfig(params, "text", { language: "en" });
|
|
61
|
+
setVectorConfig(params, "emb", { dimensionality: 3, model: "my-model" });
|
|
62
|
+
setNgramConfig(params, "code", { store: "fts5" });
|
|
63
|
+
const index = await indexer.createIndex(params);
|
|
64
|
+
|
|
65
|
+
const text = newFullTextAccess("text");
|
|
66
|
+
const emb = newVectorAccess("emb");
|
|
67
|
+
const code = newNgramAccess("code");
|
|
68
|
+
const block = { path: "/src/a.ts" as const, blockId: "1" };
|
|
69
|
+
const content = "function fooBar() {\n return running(fooBar);\n}\n";
|
|
70
|
+
await text.get(index).addDocument([{ ...block, content }]);
|
|
71
|
+
await emb.get(index).addDocument([{ ...block, embedding: Float32Array.of(0.1, 0.9, 0) }]);
|
|
72
|
+
await code.get(index).addDocument([{ ...block, content }]);
|
|
73
|
+
|
|
74
|
+
// One fused search over all three sub-indexes.
|
|
75
|
+
const request = { topK: 10 };
|
|
76
|
+
text.setQuery(request, { queries: ["run"] }); // Porter stemming: matches "running"
|
|
77
|
+
emb.setQuery(request, { embeddings: [Float32Array.of(0, 1, 0)] });
|
|
78
|
+
code.setQuery(request, { pattern: "fooBar" });
|
|
79
|
+
for await (const r of index.search(request)) {
|
|
80
|
+
console.log(r.path, r.blockId, r.score, code.getResult(r)?.count); // RRF score; 2 matches
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// grep: every match with offsets and lines, plus whether the search was cut short.
|
|
84
|
+
const { hits, truncated } = await grep(index, "code", "fooBar\\(", { regex: true });
|
|
85
|
+
console.log(hits[0]?.matches, truncated); // [{ start: 9, end: 16, line: 1 }] false
|
|
86
|
+
|
|
87
|
+
await indexer.close();
|
|
88
|
+
await db.close();
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Inside a Durable Object (`newDoSqliteDb` is synchronous; the indexer is created on first use):
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { DurableObject } from "cloudflare:workers";
|
|
95
|
+
import { newDoSqliteDb } from "@statewalker/db-sqlite-do";
|
|
96
|
+
import type { Indexer } from "@statewalker/indexer-api";
|
|
97
|
+
import {
|
|
98
|
+
createSqliteIndexer,
|
|
99
|
+
newSqliteFullTextProvider,
|
|
100
|
+
newSqliteNgramProvider,
|
|
101
|
+
newSqliteVectorProvider,
|
|
102
|
+
} from "@statewalker/indexer-sqlite";
|
|
103
|
+
|
|
104
|
+
export class Search extends DurableObject {
|
|
105
|
+
db = newDoSqliteDb(this.ctx.storage);
|
|
106
|
+
#indexer?: Promise<Indexer>;
|
|
107
|
+
|
|
108
|
+
indexer(): Promise<Indexer> {
|
|
109
|
+
this.#indexer ??= createSqliteIndexer({
|
|
110
|
+
db: this.db,
|
|
111
|
+
providers: [newSqliteFullTextProvider(), newSqliteVectorProvider(), newSqliteNgramProvider()],
|
|
112
|
+
});
|
|
113
|
+
return this.#indexer;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
In Node.js tests, `newDoSqliteDb(newFakeDoStorage())` (from `@statewalker/db-sqlite-do/fake`)
|
|
119
|
+
gives a `Db` with the Durable Object's limits.
|
|
120
|
+
|
|
121
|
+
## Internals
|
|
122
|
+
|
|
123
|
+
### Tables are prefixed per index and sub-index, and names are case-distinct
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
__indexer_manifest(name, config) one row per Index: its sub-index configs
|
|
127
|
+
idx_<index>__blocks(id INTEGER PRIMARY KEY, path, block_id) shared by every sub-index
|
|
128
|
+
idx_<index>__<sub>_fts FTS5 table, rowid = block id
|
|
129
|
+
idx_<index>__<sub>_vec_* the vector engine's tables
|
|
130
|
+
idx_<index>__<sub>_ng_* the n-gram engine's tables
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Index and sub-index names are escaped into the prefixes, upper-case letters included: SQLite table names ignore case, so `Docs` and `docs` become distinct prefixes and are independent indexes. The vector and n-gram sub-indexes
|
|
134
|
+
compute size and listings by joining the engine's own id table with the blocks table, so nothing
|
|
135
|
+
lists a block the engine does not hold, and there is no second write that a crash could separate
|
|
136
|
+
from the engine's write.
|
|
137
|
+
|
|
138
|
+
### Why blocks get integer ids
|
|
139
|
+
|
|
140
|
+
The engines (`@statewalker/db-sqlite-index`) index integer ids, not `{path, blockId}` pairs. The
|
|
141
|
+
blocks table maps one to the other, and its rows are kept until `deleteIndex`, even after a
|
|
142
|
+
block is deleted from every sub-index: an id stays stable for its block, so an engine never sees
|
|
143
|
+
one id reused for different content. Re-adding a `{path, blockId}` replaces its content; it never
|
|
144
|
+
creates a second entry.
|
|
145
|
+
|
|
146
|
+
### Why each modality uses its own table prefix
|
|
147
|
+
|
|
148
|
+
The vector and n-gram engines both create a `{prefix}meta` table with different columns. Each
|
|
149
|
+
provider therefore appends its own suffix (`fts`, `vec_`, `ng_`) to the sub-index prefix; a new
|
|
150
|
+
provider must do the same.
|
|
151
|
+
|
|
152
|
+
### Path scope uses `substr`, not `LIKE`, and one JSON parameter
|
|
153
|
+
|
|
154
|
+
A path prefix matches on component boundaries (`/foo` matches `/foo` and `/foo/x`, never
|
|
155
|
+
`/foobar`) and case-sensitively (`/Docs` does not match `/docs/a`). SQLite's `LIKE` ignores ASCII
|
|
156
|
+
case, and `PRAGMA case_sensitive_like` is not authorised in a Durable Object, so the condition is
|
|
157
|
+
built with `substr`. All prefixes are passed as a single JSON parameter (`json_each`), because
|
|
158
|
+
a Durable Object rejects statements with more than 100 bound variables; any number of `paths` works.
|
|
159
|
+
|
|
160
|
+
### Full text runs on FTS5 with stemming for exactly "en"
|
|
161
|
+
|
|
162
|
+
- Only the exact language `"en"` gets the Porter stemmer (`porter unicode61 remove_diacritics
|
|
163
|
+
2`). Every other value, including `"en-US"` and unknown codes, gets `unicode61
|
|
164
|
+
remove_diacritics 2` without stemming; no language throws.
|
|
165
|
+
- Each query string is split into words; every word with a letter or a digit is double-quoted
|
|
166
|
+
and the words are OR-joined, so FTS5 syntax in user text (`"`, `*`, `AND`, `NEAR(`, `-`) is
|
|
167
|
+
searched as plain words and never raises `fts5: syntax error`. Text with no letters or digits
|
|
168
|
+
finds nothing.
|
|
169
|
+
- `score = -bm25(...)`; the snippet is `snippet(<table>, 0, '', '', '…', 16)`.
|
|
170
|
+
- Several `queries` in one sub-query **add up** (SUM): a block matching more of them ranks
|
|
171
|
+
higher. The PGlite and DuckDB indexers keep each block's best single query instead, so the same
|
|
172
|
+
request can rank differently there. Each query is cut to `topK` before summing, so a block just
|
|
173
|
+
below the cut-off in one query loses that part of its score.
|
|
174
|
+
|
|
175
|
+
### Vector sub-indexes use cosine scores and do not store block metadata
|
|
176
|
+
|
|
177
|
+
Cosine metric; `score = 1 − distance`. Several embeddings in one query keep each block's best
|
|
178
|
+
score. The engine commits an `upsert` in transactions of 200 vectors: if a call fails part-way,
|
|
179
|
+
the earlier batches stay written.
|
|
180
|
+
`VectorBlock.metadata` is not stored. `getDocumentsBlocks` returns the cosine-normalised vectors, not the embeddings that were added.
|
|
181
|
+
|
|
182
|
+
### N-gram sub-indexes answer grep, with a bounded candidate budget
|
|
183
|
+
|
|
184
|
+
- `store` is `"fts5"` (default) or `"postings"`; both give the same results.
|
|
185
|
+
- `caseSensitive` defaults to `true`, as in grep. A literal pattern is matched exactly; with
|
|
186
|
+
`regex: true` it is a JavaScript regex without the `u` flag, and `\p{…}` / `\P{…}` are rejected
|
|
187
|
+
with a `SyntaxError`.
|
|
188
|
+
- Matches stay inside one block. Offsets are UTF-16 code units of the stored text (lone
|
|
189
|
+
surrogates are stored as U+FFFD), `end` exclusive; `line` is 1-based, counted from `\n`.
|
|
190
|
+
- `score = count`, the number of matches in the block. The engine returns its first `topK`
|
|
191
|
+
matching blocks (default 100) in ascending id order; the provider then ranks those by count.
|
|
192
|
+
When `topK` or the engine's candidate budget (10,000 candidates) cut the search short, `grep`
|
|
193
|
+
returns `truncated: true` and the hits are the ones found so far, not necessarily the blocks with
|
|
194
|
+
the most matches.
|
|
195
|
+
- `NgramBlock.metadata` is not stored (the full-text sub-index does store it).
|
|
196
|
+
- A `paths`-scoped search spends the candidate budget on out-of-scope blocks too: the engine
|
|
197
|
+
filters by path after it cuts to the budget, so a narrow scope in a large index can return few
|
|
198
|
+
hits together with `truncated: true` although more matches exist in scope.
|
|
199
|
+
- `addDocument` hands the engine at most 500 blocks per write transaction; each batch commits on
|
|
200
|
+
its own, so if a later batch fails the earlier ones stay written.
|
|
201
|
+
|
|
202
|
+
### Dependencies: the Db, the engines and the modality contracts
|
|
203
|
+
|
|
204
|
+
- `@statewalker/db-api` — the `Db` it runs on.
|
|
205
|
+
- `@statewalker/db-sqlite` — `inTransaction` (FTS writes).
|
|
206
|
+
- `@statewalker/db-sqlite-index` — the vector (`./vector`) and n-gram (`./ngram`) engines.
|
|
207
|
+
- `@statewalker/indexer-api`, `@statewalker/indexer-core` — the `Indexer` contract, RRF
|
|
208
|
+
composition, shared helpers.
|
|
209
|
+
- `@statewalker/indexer-fulltext`, `@statewalker/indexer-vector`, `@statewalker/indexer-ngram` —
|
|
210
|
+
the modality contracts the providers implement.
|
|
211
|
+
|
|
212
|
+
## What will surprise you
|
|
213
|
+
|
|
214
|
+
- **A wrapped `Db` loses the transaction hook.** Pass the driver's `Db` itself, not an object
|
|
215
|
+
that forwards to it. With a wrapper, creating an Index with a vector or n-gram sub-index, or
|
|
216
|
+
the first write to a full-text sub-index, rejects with:
|
|
217
|
+
`inTransaction: this Db has no SQLite transaction hook. It is not a @statewalker/db-sqlite* Db,
|
|
218
|
+
or it wraps one (a wrapper object loses the hook - pass the driver's Db itself), or it is the
|
|
219
|
+
handle of a running transaction (nesting is not supported).`
|
|
220
|
+
- **Never call an indexer, an Index or a sub-index from inside your own
|
|
221
|
+
`inTransaction(db, …)` callback.** The sub-indexes run their own transactions and queries on
|
|
222
|
+
`db`; on Node.js and in the browser those wait for your transaction to end, and it waits for
|
|
223
|
+
them. The call hangs, with no error.
|
|
224
|
+
- **A provider's `open` and `drop` must not call back into the indexer** (`getIndex`,
|
|
225
|
+
`createIndex`): the indexer runs them inside its own one-at-a-time queue, and a nested call waits
|
|
226
|
+
for itself forever.
|
|
227
|
+
- **Durable Object limits apply to every write.** A single string or BLOB value over about 2 MB
|
|
228
|
+
rejects with `string or blob too big`, so very large blocks must be split before indexing. An
|
|
229
|
+
operation that runs past the platform's deadline (15 s on celld) is retried rather than failed;
|
|
230
|
+
keep each `addDocument` call modest.
|
|
231
|
+
- **`truncated: true` is easy to miss.** `for await (const hit of sub.search(q))` drops the
|
|
232
|
+
summary the generator returns; use `grep` to read it.
|
|
233
|
+
- **Re-opening needs the same providers.** `getIndex` on an Index with a sub-index whose type has
|
|
234
|
+
no registered provider throws `Cannot load index: sub-index "<name>" requires modality
|
|
235
|
+
"<type>" but only [...] are registered.`; pass `{ onMissingProvider: "warn" }` to open it
|
|
236
|
+
without that sub-index.
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Db } from "@statewalker/db-api";
|
|
2
|
+
import { BlockReference, PathSelector } from "@statewalker/indexer-api";
|
|
3
|
+
//#region src/block-ids.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The Index-wide `{path, blockId} ↔ integer id` table shared by every sub-index.
|
|
6
|
+
* Every method takes the `Db` to run on, so it works on `tx` inside `inTransaction`.
|
|
7
|
+
* Rows are never deleted before `deleteIndex`: an id stays stable for its block.
|
|
8
|
+
*/
|
|
9
|
+
export interface BlockIds {
|
|
10
|
+
readonly table: string;
|
|
11
|
+
/** Ids for `refs`, in order; missing rows are created. */
|
|
12
|
+
ensure(db: Db, refs: readonly BlockReference[]): Promise<number[]>;
|
|
13
|
+
/** Ids of every known block matched by any selector (path prefix, or exact block). */
|
|
14
|
+
select(db: Db, selectors: readonly PathSelector[]): Promise<number[]>;
|
|
15
|
+
/** References of `ids`; unknown ids are absent. */
|
|
16
|
+
refs(db: Db, ids: readonly number[]): Promise<Map<number, BlockReference>>;
|
|
17
|
+
}
|
|
18
|
+
export declare function openBlockIds(db: Db, table: string): Promise<BlockIds>;
|
|
19
|
+
//#endregion
|
|
20
|
+
//# sourceMappingURL=block-ids.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"block-ids.d.ts","names":[],"sources":["../src/block-ids.ts"],"mappings":";;;;;;;;iBAUiB;WACN;;EAET,OAAO,IAAI,IAAI,eAAe,mBAAmB;;EAEjD,OAAO,IAAI,IAAI,oBAAoB,iBAAiB;;EAEpD,KAAK,IAAI,IAAI,yBAAyB,QAAQ,YAAY;;wBAGtC,aAAa,IAAI,IAAI,gBAAgB,QAAQ"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { pathPrefixSql } from "./path-sql.js";
|
|
2
|
+
import { compositeKey } from "@statewalker/indexer-core";
|
|
3
|
+
//#region src/block-ids.ts
|
|
4
|
+
async function openBlockIds(db, table) {
|
|
5
|
+
await db.exec(`CREATE TABLE IF NOT EXISTS ${table} (id INTEGER PRIMARY KEY, path TEXT NOT NULL, block_id TEXT NOT NULL, UNIQUE (path, block_id))`);
|
|
6
|
+
return {
|
|
7
|
+
table,
|
|
8
|
+
async ensure(tx, refs) {
|
|
9
|
+
if (refs.length === 0) return [];
|
|
10
|
+
const json = JSON.stringify(refs.map((r) => [r.path, r.blockId]));
|
|
11
|
+
await tx.query(`INSERT INTO ${table} (path, block_id) SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]') FROM json_each(?) WHERE true ON CONFLICT (path, block_id) DO NOTHING`, [json]);
|
|
12
|
+
const rows = await tx.query(`SELECT b.id, b.path, b.block_id FROM ${table} b JOIN json_each(?) j ON b.path = json_extract(j.value, '$[0]') AND b.block_id = json_extract(j.value, '$[1]')`, [json]);
|
|
13
|
+
const byKey = new Map(rows.map((r) => [compositeKey(r.path, r.block_id), Number(r.id)]));
|
|
14
|
+
return refs.map((r) => {
|
|
15
|
+
const id = byKey.get(compositeKey(r.path, r.blockId));
|
|
16
|
+
if (id === void 0) throw new Error(`block id missing for ${r.path}#${r.blockId}`);
|
|
17
|
+
return id;
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
async select(tx, selectors) {
|
|
21
|
+
const ids = /* @__PURE__ */ new Set();
|
|
22
|
+
for (const sel of selectors) {
|
|
23
|
+
const where = sel.blockId !== void 0 ? {
|
|
24
|
+
sql: "b.path = ? AND b.block_id = ?",
|
|
25
|
+
params: [sel.path, sel.blockId]
|
|
26
|
+
} : pathPrefixSql("b.path", [sel.path]);
|
|
27
|
+
const rows = await tx.query(`SELECT b.id FROM ${table} b WHERE ${where.sql}`, where.params);
|
|
28
|
+
for (const r of rows) ids.add(Number(r.id));
|
|
29
|
+
}
|
|
30
|
+
return [...ids];
|
|
31
|
+
},
|
|
32
|
+
async refs(tx, ids) {
|
|
33
|
+
const out = /* @__PURE__ */ new Map();
|
|
34
|
+
if (ids.length === 0) return out;
|
|
35
|
+
const rows = await tx.query(`SELECT id, path, block_id FROM ${table} WHERE id IN (SELECT value FROM json_each(?))`, [JSON.stringify(ids)]);
|
|
36
|
+
for (const r of rows) out.set(Number(r.id), {
|
|
37
|
+
path: r.path,
|
|
38
|
+
blockId: r.block_id
|
|
39
|
+
});
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
export { openBlockIds };
|
|
46
|
+
|
|
47
|
+
//# sourceMappingURL=block-ids.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"block-ids.js","names":[],"sources":["../src/block-ids.ts"],"sourcesContent":["import type { Db } from \"@statewalker/db-api\";\nimport type { BlockReference, DocumentPath, PathSelector } from \"@statewalker/indexer-api\";\nimport { compositeKey } from \"@statewalker/indexer-core\";\nimport { pathPrefixSql } from \"./path-sql.js\";\n\n/**\n * The Index-wide `{path, blockId} ↔ integer id` table shared by every sub-index.\n * Every method takes the `Db` to run on, so it works on `tx` inside `inTransaction`.\n * Rows are never deleted before `deleteIndex`: an id stays stable for its block.\n */\nexport interface BlockIds {\n readonly table: string;\n /** Ids for `refs`, in order; missing rows are created. */\n ensure(db: Db, refs: readonly BlockReference[]): Promise<number[]>;\n /** Ids of every known block matched by any selector (path prefix, or exact block). */\n select(db: Db, selectors: readonly PathSelector[]): Promise<number[]>;\n /** References of `ids`; unknown ids are absent. */\n refs(db: Db, ids: readonly number[]): Promise<Map<number, BlockReference>>;\n}\n\nexport async function openBlockIds(db: Db, table: string): Promise<BlockIds> {\n await db.exec(\n `CREATE TABLE IF NOT EXISTS ${table} (id INTEGER PRIMARY KEY, path TEXT NOT NULL, block_id TEXT NOT NULL, UNIQUE (path, block_id))`,\n );\n return {\n table,\n async ensure(tx, refs) {\n if (refs.length === 0) return [];\n const json = JSON.stringify(refs.map((r) => [r.path, r.blockId]));\n // `WHERE true` disambiguates `SELECT … ON CONFLICT` for the SQLite parser.\n await tx.query(\n `INSERT INTO ${table} (path, block_id) SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]') FROM json_each(?) WHERE true ON CONFLICT (path, block_id) DO NOTHING`,\n [json],\n );\n const rows = await tx.query<{ id: number; path: string; block_id: string }>(\n `SELECT b.id, b.path, b.block_id FROM ${table} b JOIN json_each(?) j ON b.path = json_extract(j.value, '$[0]') AND b.block_id = json_extract(j.value, '$[1]')`,\n [json],\n );\n const byKey = new Map(\n rows.map((r) => [compositeKey(r.path as DocumentPath, r.block_id), Number(r.id)]),\n );\n return refs.map((r) => {\n const id = byKey.get(compositeKey(r.path, r.blockId));\n if (id === undefined) throw new Error(`block id missing for ${r.path}#${r.blockId}`);\n return id;\n });\n },\n async select(tx, selectors) {\n const ids = new Set<number>();\n for (const sel of selectors) {\n const where =\n sel.blockId !== undefined\n ? { sql: \"b.path = ? AND b.block_id = ?\", params: [sel.path, sel.blockId] }\n : pathPrefixSql(\"b.path\", [sel.path]);\n const rows = await tx.query<{ id: number }>(\n `SELECT b.id FROM ${table} b WHERE ${where.sql}`,\n where.params,\n );\n for (const r of rows) ids.add(Number(r.id));\n }\n return [...ids];\n },\n async refs(tx, ids) {\n const out = new Map<number, BlockReference>();\n if (ids.length === 0) return out;\n const rows = await tx.query<{ id: number; path: string; block_id: string }>(\n `SELECT id, path, block_id FROM ${table} WHERE id IN (SELECT value FROM json_each(?))`,\n [JSON.stringify(ids)],\n );\n for (const r of rows)\n out.set(Number(r.id), { path: r.path as DocumentPath, blockId: r.block_id });\n return out;\n },\n };\n}\n"],"mappings":";;;AAoBA,eAAsB,aAAa,IAAQ,OAAkC;CAC3E,MAAM,GAAG,KACP,8BAA8B,MAAM,+FACtC;CACA,OAAO;EACL;EACA,MAAM,OAAO,IAAI,MAAM;GACrB,IAAI,KAAK,WAAW,GAAG,OAAO,CAAC;GAC/B,MAAM,OAAO,KAAK,UAAU,KAAK,KAAK,MAAM,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;GAEhE,MAAM,GAAG,MACP,eAAe,MAAM,yJACrB,CAAC,IAAI,CACP;GACA,MAAM,OAAO,MAAM,GAAG,MACpB,wCAAwC,MAAM,kHAC9C,CAAC,IAAI,CACP;GACA,MAAM,QAAQ,IAAI,IAChB,KAAK,KAAK,MAAM,CAAC,aAAa,EAAE,MAAsB,EAAE,QAAQ,GAAG,OAAO,EAAE,EAAE,CAAC,CAAC,CAClF;GACA,OAAO,KAAK,KAAK,MAAM;IACrB,MAAM,KAAK,MAAM,IAAI,aAAa,EAAE,MAAM,EAAE,OAAO,CAAC;IACpD,IAAI,OAAO,KAAA,GAAW,MAAM,IAAI,MAAM,wBAAwB,EAAE,KAAK,GAAG,EAAE,SAAS;IACnF,OAAO;GACT,CAAC;EACH;EACA,MAAM,OAAO,IAAI,WAAW;GAC1B,MAAM,sBAAM,IAAI,IAAY;GAC5B,KAAK,MAAM,OAAO,WAAW;IAC3B,MAAM,QACJ,IAAI,YAAY,KAAA,IACZ;KAAE,KAAK;KAAiC,QAAQ,CAAC,IAAI,MAAM,IAAI,OAAO;IAAE,IACxE,cAAc,UAAU,CAAC,IAAI,IAAI,CAAC;IACxC,MAAM,OAAO,MAAM,GAAG,MACpB,oBAAoB,MAAM,WAAW,MAAM,OAC3C,MAAM,MACR;IACA,KAAK,MAAM,KAAK,MAAM,IAAI,IAAI,OAAO,EAAE,EAAE,CAAC;GAC5C;GACA,OAAO,CAAC,GAAG,GAAG;EAChB;EACA,MAAM,KAAK,IAAI,KAAK;GAClB,MAAM,sBAAM,IAAI,IAA4B;GAC5C,IAAI,IAAI,WAAW,GAAG,OAAO;GAC7B,MAAM,OAAO,MAAM,GAAG,MACpB,kCAAkC,MAAM,gDACxC,CAAC,KAAK,UAAU,GAAG,CAAC,CACtB;GACA,KAAK,MAAM,KAAK,MACd,IAAI,IAAI,OAAO,EAAE,EAAE,GAAG;IAAE,MAAM,EAAE;IAAsB,SAAS,EAAE;GAAS,CAAC;GAC7E,OAAO;EACT;CACF;AACF"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { SqliteModalityProvider } from "./types.js";
|
|
2
|
+
import { Db } from "@statewalker/db-api";
|
|
3
|
+
import { Indexer } from "@statewalker/indexer-api";
|
|
4
|
+
//#region src/create-sqlite-indexer.d.ts
|
|
5
|
+
export interface SqliteIndexerOptions {
|
|
6
|
+
/** The database. The indexer never closes it. */
|
|
7
|
+
db: Db;
|
|
8
|
+
/** One provider per modality type, e.g. FTS5, vector, n-gram. */
|
|
9
|
+
providers: SqliteModalityProvider[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `Indexer` over one SQLite `Db`. Keeps a manifest row per Index, one shared
|
|
13
|
+
* `{indexPrefix}blocks` id table per Index, and opens each sub-index through
|
|
14
|
+
* its provider. Search fuses sub-indexes by RRF (`createCompositeIndex`).
|
|
15
|
+
*/
|
|
16
|
+
export declare function createSqliteIndexer(options: SqliteIndexerOptions): Promise<Indexer>;
|
|
17
|
+
//#endregion
|
|
18
|
+
//# sourceMappingURL=create-sqlite-indexer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-sqlite-indexer.d.ts","names":[],"sources":["../src/create-sqlite-indexer.ts"],"mappings":";;;;iBAaiB;;EAEf,IAAI;;EAEJ,WAAW;;;;;;;wBAqBS,oBAAoB,SAAS,uBAAuB,QAAQ"}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { openBlockIds } from "./block-ids.js";
|
|
2
|
+
import { createCompositeIndex, createSerialiser } from "@statewalker/indexer-core";
|
|
3
|
+
//#region src/create-sqlite-indexer.ts
|
|
4
|
+
const MANIFEST_TABLE = "__indexer_manifest";
|
|
5
|
+
/** Escapes everything but lower-case letters and digits: SQLite table names fold case, so "Docs" and "docs" must not collide. */
|
|
6
|
+
const sanitizePrefix = (name) => name.replace(/[^a-z0-9]/g, (ch) => `_${ch.charCodeAt(0)}_`);
|
|
7
|
+
const indexPrefix = (name) => `idx_${sanitizePrefix(name)}__`;
|
|
8
|
+
/**
|
|
9
|
+
* `Indexer` over one SQLite `Db`. Keeps a manifest row per Index, one shared
|
|
10
|
+
* `{indexPrefix}blocks` id table per Index, and opens each sub-index through
|
|
11
|
+
* its provider. Search fuses sub-indexes by RRF (`createCompositeIndex`).
|
|
12
|
+
*/
|
|
13
|
+
async function createSqliteIndexer(options) {
|
|
14
|
+
const { db } = options;
|
|
15
|
+
const providers = new Map(options.providers.map((p) => [p.type, p]));
|
|
16
|
+
const indexes = /* @__PURE__ */ new Map();
|
|
17
|
+
const manifest = /* @__PURE__ */ new Map();
|
|
18
|
+
const runExclusive = createSerialiser();
|
|
19
|
+
let closed = false;
|
|
20
|
+
await db.exec(`CREATE TABLE IF NOT EXISTS ${MANIFEST_TABLE} (name TEXT PRIMARY KEY, config TEXT NOT NULL)`);
|
|
21
|
+
for (const row of await db.query(`SELECT name, config FROM ${MANIFEST_TABLE}`)) manifest.set(row.name, JSON.parse(row.config));
|
|
22
|
+
const ensureOpen = () => {
|
|
23
|
+
if (closed) throw new Error("Indexer is closed");
|
|
24
|
+
};
|
|
25
|
+
const contextFor = (name, sub, blockIds) => ({
|
|
26
|
+
db,
|
|
27
|
+
prefix: `${indexPrefix(name)}${sanitizePrefix(sub)}_`,
|
|
28
|
+
blockIds
|
|
29
|
+
});
|
|
30
|
+
async function writeManifest(name, entry) {
|
|
31
|
+
await db.query(`INSERT INTO ${MANIFEST_TABLE} (name, config) VALUES (?, ?) ON CONFLICT (name) DO UPDATE SET config = excluded.config`, [name, JSON.stringify(entry)]);
|
|
32
|
+
manifest.set(name, entry);
|
|
33
|
+
}
|
|
34
|
+
async function dropIndexTables(name, subIndexes) {
|
|
35
|
+
const blockIds = await openBlockIds(db, `${indexPrefix(name)}blocks`);
|
|
36
|
+
for (const [sub, spec] of Object.entries(subIndexes)) await providers.get(spec.type)?.drop(contextFor(name, sub, blockIds));
|
|
37
|
+
await db.exec(`DROP TABLE IF EXISTS ${blockIds.table}`);
|
|
38
|
+
await db.query(`DELETE FROM ${MANIFEST_TABLE} WHERE name = ?`, [name]);
|
|
39
|
+
manifest.delete(name);
|
|
40
|
+
}
|
|
41
|
+
async function openIndex(name, subIndexes, options) {
|
|
42
|
+
const onMissing = options?.onMissingProvider ?? "throw";
|
|
43
|
+
const blockIds = await openBlockIds(db, `${indexPrefix(name)}blocks`);
|
|
44
|
+
const bindings = [];
|
|
45
|
+
const effective = {};
|
|
46
|
+
for (const [sub, saved] of Object.entries(subIndexes)) {
|
|
47
|
+
const action = options?.subIndexes?.[sub];
|
|
48
|
+
if (action && "skip" in action && action.skip === true) {
|
|
49
|
+
effective[sub] = saved;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const ctx = contextFor(name, sub, blockIds);
|
|
53
|
+
const spec = action && !("skip" in action) ? action : saved;
|
|
54
|
+
const provider = providers.get(spec.type);
|
|
55
|
+
if (provider && spec !== saved) await providers.get(saved.type)?.drop(ctx);
|
|
56
|
+
if (!provider) {
|
|
57
|
+
if (onMissing === "throw") throw new Error(`Cannot load index: sub-index "${sub}" requires modality "${spec.type}" but only [${[...providers.keys()].map((t) => `"${t}"`).join(", ")}] are registered.`);
|
|
58
|
+
globalThis.console?.warn(`Sub-index "${sub}" (type "${spec.type}") has no provider; skipping.`);
|
|
59
|
+
effective[sub] = saved;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const { type, ...config } = spec;
|
|
63
|
+
bindings.push({
|
|
64
|
+
name: sub,
|
|
65
|
+
type,
|
|
66
|
+
config,
|
|
67
|
+
index: await provider.open(ctx, config)
|
|
68
|
+
});
|
|
69
|
+
effective[sub] = spec;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
index: createCompositeIndex({
|
|
73
|
+
name,
|
|
74
|
+
bindings,
|
|
75
|
+
onDeleteIndex: async () => {
|
|
76
|
+
for (const [sub, spec] of Object.entries(manifest.get(name)?.subIndexes ?? {})) await providers.get(spec.type)?.drop(contextFor(name, sub, blockIds));
|
|
77
|
+
await db.exec(`DROP TABLE IF EXISTS ${blockIds.table}`);
|
|
78
|
+
await db.query(`DELETE FROM ${MANIFEST_TABLE} WHERE name = ?`, [name]);
|
|
79
|
+
manifest.delete(name);
|
|
80
|
+
indexes.delete(name);
|
|
81
|
+
}
|
|
82
|
+
}),
|
|
83
|
+
effective
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
async getIndexNames() {
|
|
88
|
+
ensureOpen();
|
|
89
|
+
return [...manifest.keys()].map((name) => ({ name }));
|
|
90
|
+
},
|
|
91
|
+
createIndex(params) {
|
|
92
|
+
return runExclusive(async () => {
|
|
93
|
+
ensureOpen();
|
|
94
|
+
const { name, subIndexes, overwrite, metadata } = params;
|
|
95
|
+
if (!subIndexes || Object.keys(subIndexes).length === 0) throw new Error("Cannot create index without any subIndexes");
|
|
96
|
+
const existing = manifest.get(name);
|
|
97
|
+
if (existing || indexes.has(name)) {
|
|
98
|
+
if (!overwrite) throw new Error(`Index "${name}" already exists`);
|
|
99
|
+
await indexes.get(name)?.close().catch(() => void 0);
|
|
100
|
+
indexes.delete(name);
|
|
101
|
+
await dropIndexTables(name, existing?.subIndexes ?? {});
|
|
102
|
+
}
|
|
103
|
+
const { index, effective } = await openIndex(name, subIndexes);
|
|
104
|
+
await writeManifest(name, { subIndexes: effective });
|
|
105
|
+
if (metadata !== void 0) index.metadata = metadata;
|
|
106
|
+
indexes.set(name, index);
|
|
107
|
+
return index;
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
getIndex(name, options) {
|
|
111
|
+
return runExclusive(async () => {
|
|
112
|
+
ensureOpen();
|
|
113
|
+
const cached = indexes.get(name);
|
|
114
|
+
if (cached) return cached;
|
|
115
|
+
const entry = manifest.get(name);
|
|
116
|
+
if (!entry) return null;
|
|
117
|
+
const { index, effective } = await openIndex(name, entry.subIndexes, options);
|
|
118
|
+
if (JSON.stringify(effective) !== JSON.stringify(entry.subIndexes)) await writeManifest(name, { subIndexes: effective });
|
|
119
|
+
indexes.set(name, index);
|
|
120
|
+
return index;
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
async hasIndex(name) {
|
|
124
|
+
ensureOpen();
|
|
125
|
+
return manifest.has(name);
|
|
126
|
+
},
|
|
127
|
+
async deleteIndex(name) {
|
|
128
|
+
ensureOpen();
|
|
129
|
+
const live = indexes.get(name);
|
|
130
|
+
if (live) {
|
|
131
|
+
await live.deleteIndex();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const entry = manifest.get(name);
|
|
135
|
+
if (entry) await dropIndexTables(name, entry.subIndexes);
|
|
136
|
+
},
|
|
137
|
+
async flush() {
|
|
138
|
+
ensureOpen();
|
|
139
|
+
for (const index of indexes.values()) await index.flush();
|
|
140
|
+
},
|
|
141
|
+
async close() {
|
|
142
|
+
if (closed) return;
|
|
143
|
+
closed = true;
|
|
144
|
+
for (const index of indexes.values()) await index.close().catch(() => void 0);
|
|
145
|
+
indexes.clear();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
export { createSqliteIndexer };
|
|
151
|
+
|
|
152
|
+
//# sourceMappingURL=create-sqlite-indexer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-sqlite-indexer.js","names":[],"sources":["../src/create-sqlite-indexer.ts"],"sourcesContent":["import type { Db } from \"@statewalker/db-api\";\nimport type {\n AnySubIndexBinding,\n CreateIndexParams,\n GetIndexOptions,\n Index,\n Indexer,\n IndexInfo,\n} from \"@statewalker/indexer-api\";\nimport { createCompositeIndex, createSerialiser } from \"@statewalker/indexer-core\";\nimport { type BlockIds, openBlockIds } from \"./block-ids.js\";\nimport type { SqliteModalityContext, SqliteModalityProvider } from \"./types.js\";\n\nexport interface SqliteIndexerOptions {\n /** The database. The indexer never closes it. */\n db: Db;\n /** One provider per modality type, e.g. FTS5, vector, n-gram. */\n providers: SqliteModalityProvider[];\n}\n\ntype SubSpec = { type: string } & Record<string, unknown>;\ninterface IndexManifest {\n subIndexes: Record<string, SubSpec>;\n}\n\nconst MANIFEST_TABLE = \"__indexer_manifest\";\n\n/** Escapes everything but lower-case letters and digits: SQLite table names fold case, so \"Docs\" and \"docs\" must not collide. */\nconst sanitizePrefix = (name: string) =>\n name.replace(/[^a-z0-9]/g, (ch) => `_${ch.charCodeAt(0)}_`);\n\nconst indexPrefix = (name: string) => `idx_${sanitizePrefix(name)}__`;\n\n/**\n * `Indexer` over one SQLite `Db`. Keeps a manifest row per Index, one shared\n * `{indexPrefix}blocks` id table per Index, and opens each sub-index through\n * its provider. Search fuses sub-indexes by RRF (`createCompositeIndex`).\n */\nexport async function createSqliteIndexer(options: SqliteIndexerOptions): Promise<Indexer> {\n const { db } = options;\n const providers = new Map(options.providers.map((p) => [p.type, p]));\n const indexes = new Map<string, Index>();\n const manifest = new Map<string, IndexManifest>();\n const runExclusive = createSerialiser();\n let closed = false;\n\n await db.exec(\n `CREATE TABLE IF NOT EXISTS ${MANIFEST_TABLE} (name TEXT PRIMARY KEY, config TEXT NOT NULL)`,\n );\n for (const row of await db.query<{ name: string; config: string }>(\n `SELECT name, config FROM ${MANIFEST_TABLE}`,\n )) {\n manifest.set(row.name, JSON.parse(row.config) as IndexManifest);\n }\n\n const ensureOpen = () => {\n if (closed) throw new Error(\"Indexer is closed\");\n };\n\n const contextFor = (name: string, sub: string, blockIds: BlockIds): SqliteModalityContext => ({\n db,\n prefix: `${indexPrefix(name)}${sanitizePrefix(sub)}_`,\n blockIds,\n });\n\n async function writeManifest(name: string, entry: IndexManifest): Promise<void> {\n await db.query(\n `INSERT INTO ${MANIFEST_TABLE} (name, config) VALUES (?, ?) ON CONFLICT (name) DO UPDATE SET config = excluded.config`,\n [name, JSON.stringify(entry)],\n );\n manifest.set(name, entry);\n }\n\n async function dropIndexTables(name: string, subIndexes: Record<string, SubSpec>): Promise<void> {\n const blockIds = await openBlockIds(db, `${indexPrefix(name)}blocks`);\n for (const [sub, spec] of Object.entries(subIndexes)) {\n await providers.get(spec.type)?.drop(contextFor(name, sub, blockIds));\n }\n await db.exec(`DROP TABLE IF EXISTS ${blockIds.table}`);\n await db.query(`DELETE FROM ${MANIFEST_TABLE} WHERE name = ?`, [name]);\n manifest.delete(name);\n }\n\n async function openIndex(\n name: string,\n subIndexes: Record<string, SubSpec>,\n options?: GetIndexOptions,\n ): Promise<{ index: Index; effective: Record<string, SubSpec> }> {\n const onMissing = options?.onMissingProvider ?? \"throw\";\n const blockIds = await openBlockIds(db, `${indexPrefix(name)}blocks`);\n const bindings: AnySubIndexBinding[] = [];\n const effective: Record<string, SubSpec> = {};\n\n for (const [sub, saved] of Object.entries(subIndexes)) {\n const action = options?.subIndexes?.[sub];\n if (action && \"skip\" in action && action.skip === true) {\n effective[sub] = saved;\n continue;\n }\n const ctx = contextFor(name, sub, blockIds);\n const spec = action && !(\"skip\" in action) ? (action as SubSpec) : saved;\n const provider = providers.get(spec.type);\n if (provider && spec !== saved) await providers.get(saved.type)?.drop(ctx);\n if (!provider) {\n if (onMissing === \"throw\") {\n throw new Error(\n `Cannot load index: sub-index \"${sub}\" requires modality \"${spec.type}\" but only [${[\n ...providers.keys(),\n ]\n .map((t) => `\"${t}\"`)\n .join(\", \")}] are registered.`,\n );\n }\n const g = globalThis as { console?: { warn(...args: unknown[]): void } };\n g.console?.warn(`Sub-index \"${sub}\" (type \"${spec.type}\") has no provider; skipping.`);\n effective[sub] = saved;\n continue;\n }\n const { type, ...config } = spec;\n bindings.push({ name: sub, type, config, index: await provider.open(ctx, config) });\n effective[sub] = spec;\n }\n\n const index = createCompositeIndex({\n name,\n bindings,\n onDeleteIndex: async () => {\n // Bound sub-indexes dropped themselves; skipped ones still own tables.\n for (const [sub, spec] of Object.entries(manifest.get(name)?.subIndexes ?? {})) {\n await providers.get(spec.type)?.drop(contextFor(name, sub, blockIds));\n }\n await db.exec(`DROP TABLE IF EXISTS ${blockIds.table}`);\n await db.query(`DELETE FROM ${MANIFEST_TABLE} WHERE name = ?`, [name]);\n manifest.delete(name);\n indexes.delete(name);\n },\n });\n return { index, effective };\n }\n\n return {\n async getIndexNames(): Promise<IndexInfo[]> {\n ensureOpen();\n return [...manifest.keys()].map((name) => ({ name }));\n },\n\n createIndex(params: CreateIndexParams): Promise<Index> {\n return runExclusive(async () => {\n ensureOpen();\n const { name, subIndexes, overwrite, metadata } = params;\n if (!subIndexes || Object.keys(subIndexes).length === 0) {\n throw new Error(\"Cannot create index without any subIndexes\");\n }\n const existing = manifest.get(name);\n if (existing || indexes.has(name)) {\n if (!overwrite) throw new Error(`Index \"${name}\" already exists`);\n await indexes\n .get(name)\n ?.close()\n .catch(() => undefined);\n indexes.delete(name);\n await dropIndexTables(name, existing?.subIndexes ?? {});\n }\n const { index, effective } = await openIndex(name, subIndexes);\n await writeManifest(name, { subIndexes: effective });\n if (metadata !== undefined) (index as { metadata?: unknown }).metadata = metadata;\n indexes.set(name, index);\n return index;\n });\n },\n\n getIndex(name: string, options?: GetIndexOptions): Promise<Index | null> {\n // Serialised with createIndex so concurrent first opens open once\n // (a second reinit would otherwise drop tables the first just wrote).\n return runExclusive(async () => {\n ensureOpen();\n const cached = indexes.get(name);\n if (cached) return cached;\n const entry = manifest.get(name);\n if (!entry) return null;\n const { index, effective } = await openIndex(name, entry.subIndexes, options);\n if (JSON.stringify(effective) !== JSON.stringify(entry.subIndexes)) {\n await writeManifest(name, { subIndexes: effective });\n }\n indexes.set(name, index);\n return index;\n });\n },\n\n async hasIndex(name: string): Promise<boolean> {\n ensureOpen();\n return manifest.has(name);\n },\n\n async deleteIndex(name: string): Promise<void> {\n ensureOpen();\n const live = indexes.get(name);\n if (live) {\n await live.deleteIndex();\n return;\n }\n const entry = manifest.get(name);\n if (entry) await dropIndexTables(name, entry.subIndexes);\n },\n\n async flush(): Promise<void> {\n ensureOpen();\n for (const index of indexes.values()) await index.flush();\n },\n\n async close(): Promise<void> {\n if (closed) return;\n closed = true;\n for (const index of indexes.values()) await index.close().catch(() => undefined);\n indexes.clear();\n },\n } satisfies Indexer;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { BlockIds } from "./block-ids.js";
|
|
2
|
+
import { Db } from "@statewalker/db-api";
|
|
3
|
+
import { BlockReference, DocumentPath } from "@statewalker/indexer-api";
|
|
4
|
+
//#region src/enumeration.d.ts
|
|
5
|
+
export interface BlockEntry extends BlockReference {
|
|
6
|
+
id: number;
|
|
7
|
+
}
|
|
8
|
+
/** `SearchIndex` enumeration over a sub-index's member ids joined with the blocks table. */
|
|
9
|
+
export interface Enumeration {
|
|
10
|
+
size(prefix?: DocumentPath): Promise<number>;
|
|
11
|
+
entries(prefix?: DocumentPath): Promise<BlockEntry[]>;
|
|
12
|
+
paths(prefix?: DocumentPath): AsyncGenerator<DocumentPath>;
|
|
13
|
+
refs(prefix?: DocumentPath): AsyncGenerator<BlockReference>;
|
|
14
|
+
}
|
|
15
|
+
/** `memberIdsSql` is a `SELECT … AS id` listing the sub-index's ids, e.g. `SELECT rowid AS id FROM t`. */
|
|
16
|
+
export declare function newEnumeration(db: Db, blockIds: BlockIds, memberIdsSql: string): Enumeration;
|
|
17
|
+
//#endregion
|
|
18
|
+
//# sourceMappingURL=enumeration.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"enumeration.d.ts","names":[],"sources":["../src/enumeration.ts"],"mappings":";;;;iBAKiB,mBAAmB;EAClC;;;iBAIe;EACf,KAAK,SAAS,eAAe;EAC7B,QAAQ,SAAS,eAAe,QAAQ;EACxC,MAAM,SAAS,eAAe,eAAe;EAC7C,KAAK,SAAS,eAAe,eAAe;;;wBAI9B,eAAe,IAAI,IAAI,UAAU,UAAU,uBAAuB"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { pathPrefixSql } from "./path-sql.js";
|
|
2
|
+
//#region src/enumeration.ts
|
|
3
|
+
/** `memberIdsSql` is a `SELECT … AS id` listing the sub-index's ids, e.g. `SELECT rowid AS id FROM t`. */
|
|
4
|
+
function newEnumeration(db, blockIds, memberIdsSql) {
|
|
5
|
+
const from = (prefix) => {
|
|
6
|
+
const scope = pathPrefixSql("b.path", prefix ? [prefix] : []);
|
|
7
|
+
return {
|
|
8
|
+
sql: `FROM (${memberIdsSql}) m JOIN ${blockIds.table} b ON b.id = m.id WHERE ${scope.sql}`,
|
|
9
|
+
params: scope.params
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
return {
|
|
13
|
+
async size(prefix) {
|
|
14
|
+
const f = from(prefix);
|
|
15
|
+
const rows = await db.query(`SELECT COUNT(*) AS n ${f.sql}`, f.params);
|
|
16
|
+
return Number(rows[0]?.n ?? 0);
|
|
17
|
+
},
|
|
18
|
+
async entries(prefix) {
|
|
19
|
+
const f = from(prefix);
|
|
20
|
+
return (await db.query(`SELECT b.id, b.path, b.block_id ${f.sql} ORDER BY b.id`, f.params)).map((r) => ({
|
|
21
|
+
id: Number(r.id),
|
|
22
|
+
path: r.path,
|
|
23
|
+
blockId: r.block_id
|
|
24
|
+
}));
|
|
25
|
+
},
|
|
26
|
+
async *paths(prefix) {
|
|
27
|
+
const f = from(prefix);
|
|
28
|
+
const rows = await db.query(`SELECT DISTINCT b.path ${f.sql}`, f.params);
|
|
29
|
+
for (const r of rows) yield r.path;
|
|
30
|
+
},
|
|
31
|
+
async *refs(prefix) {
|
|
32
|
+
for (const e of await this.entries(prefix)) yield {
|
|
33
|
+
path: e.path,
|
|
34
|
+
blockId: e.blockId
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { newEnumeration };
|
|
41
|
+
|
|
42
|
+
//# sourceMappingURL=enumeration.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"enumeration.js","names":[],"sources":["../src/enumeration.ts"],"sourcesContent":["import type { Db } from \"@statewalker/db-api\";\nimport type { BlockReference, DocumentPath } from \"@statewalker/indexer-api\";\nimport type { BlockIds } from \"./block-ids.js\";\nimport { pathPrefixSql } from \"./path-sql.js\";\n\nexport interface BlockEntry extends BlockReference {\n id: number;\n}\n\n/** `SearchIndex` enumeration over a sub-index's member ids joined with the blocks table. */\nexport interface Enumeration {\n size(prefix?: DocumentPath): Promise<number>;\n entries(prefix?: DocumentPath): Promise<BlockEntry[]>;\n paths(prefix?: DocumentPath): AsyncGenerator<DocumentPath>;\n refs(prefix?: DocumentPath): AsyncGenerator<BlockReference>;\n}\n\n/** `memberIdsSql` is a `SELECT … AS id` listing the sub-index's ids, e.g. `SELECT rowid AS id FROM t`. */\nexport function newEnumeration(db: Db, blockIds: BlockIds, memberIdsSql: string): Enumeration {\n const from = (prefix?: DocumentPath) => {\n const scope = pathPrefixSql(\"b.path\", prefix ? [prefix] : []);\n return {\n sql: `FROM (${memberIdsSql}) m JOIN ${blockIds.table} b ON b.id = m.id WHERE ${scope.sql}`,\n params: scope.params,\n };\n };\n return {\n async size(prefix) {\n const f = from(prefix);\n const rows = await db.query<{ n: number }>(`SELECT COUNT(*) AS n ${f.sql}`, f.params);\n return Number(rows[0]?.n ?? 0);\n },\n async entries(prefix) {\n const f = from(prefix);\n const rows = await db.query<{ id: number; path: string; block_id: string }>(\n `SELECT b.id, b.path, b.block_id ${f.sql} ORDER BY b.id`,\n f.params,\n );\n return rows.map((r) => ({\n id: Number(r.id),\n path: r.path as DocumentPath,\n blockId: r.block_id,\n }));\n },\n async *paths(prefix) {\n const f = from(prefix);\n const rows = await db.query<{ path: string }>(`SELECT DISTINCT b.path ${f.sql}`, f.params);\n for (const r of rows) yield r.path as DocumentPath;\n },\n async *refs(prefix) {\n for (const e of await this.entries(prefix)) yield { path: e.path, blockId: e.blockId };\n },\n };\n}\n"],"mappings":";;;AAkBA,SAAgB,eAAe,IAAQ,UAAoB,cAAmC;CAC5F,MAAM,QAAQ,WAA0B;EACtC,MAAM,QAAQ,cAAc,UAAU,SAAS,CAAC,MAAM,IAAI,CAAC,CAAC;EAC5D,OAAO;GACL,KAAK,SAAS,aAAa,WAAW,SAAS,MAAM,0BAA0B,MAAM;GACrF,QAAQ,MAAM;EAChB;CACF;CACA,OAAO;EACL,MAAM,KAAK,QAAQ;GACjB,MAAM,IAAI,KAAK,MAAM;GACrB,MAAM,OAAO,MAAM,GAAG,MAAqB,wBAAwB,EAAE,OAAO,EAAE,MAAM;GACpF,OAAO,OAAO,KAAK,EAAE,EAAE,KAAK,CAAC;EAC/B;EACA,MAAM,QAAQ,QAAQ;GACpB,MAAM,IAAI,KAAK,MAAM;GAKrB,QAAO,MAJY,GAAG,MACpB,mCAAmC,EAAE,IAAI,iBACzC,EAAE,MACJ,EAAA,CACY,KAAK,OAAO;IACtB,IAAI,OAAO,EAAE,EAAE;IACf,MAAM,EAAE;IACR,SAAS,EAAE;GACb,EAAE;EACJ;EACA,OAAO,MAAM,QAAQ;GACnB,MAAM,IAAI,KAAK,MAAM;GACrB,MAAM,OAAO,MAAM,GAAG,MAAwB,0BAA0B,EAAE,OAAO,EAAE,MAAM;GACzF,KAAK,MAAM,KAAK,MAAM,MAAM,EAAE;EAChC;EACA,OAAO,KAAK,QAAQ;GAClB,KAAK,MAAM,KAAK,MAAM,KAAK,QAAQ,MAAM,GAAG,MAAM;IAAE,MAAM,EAAE;IAAM,SAAS,EAAE;GAAQ;EACvF;CACF;AACF"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { SqliteModalityProvider } from "./types.js";
|
|
2
|
+
import { FullTextConfig, FullTextIndex } from "@statewalker/indexer-fulltext";
|
|
3
|
+
//#region src/fts-provider.d.ts
|
|
4
|
+
export declare function newSqliteFullTextProvider(): SqliteModalityProvider<FullTextConfig, FullTextIndex>;
|
|
5
|
+
//#endregion
|
|
6
|
+
//# sourceMappingURL=fts-provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fts-provider.d.ts","names":[],"sources":["../src/fts-provider.ts"],"mappings":";;;wBAmCgB,6BAA6B,uBAAuB,gBAAgB"}
|