@kedem/okdb 1.6.1 → 1.6.2

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.
@@ -63,7 +63,9 @@ independent compaction, encryption, and sync control.
63
63
  queue/ ← Queue jobs/payloads/stats (sync: on)
64
64
  embeddings/ ← Embedding doc_status + models (sync: on)
65
65
  vectors/ ← HNSW graph files + LMDB snapshots (sync: off)
66
- fts_<type>_<name>/ ← Per-FTS-index LMDB env (sync: off, rebuildable)
66
+ ~fts/
67
+ <env>/ ← Shared FTS env: posting lists + dicts (sync: off, rebuildable)
68
+ <env>_docs/ ← Shared FTS env: LZ4-compressed forward index
67
69
  blobs/ ← File attachment BLOBs
68
70
  ```
69
71
 
@@ -309,19 +311,27 @@ GET /api/env/:env/ref-violations/:type → violations for a specific type
309
311
 
310
312
  ### 3.4 FTS Environments
311
313
 
312
- Each FTS index gets its own LMDB environment:
314
+ All FTS data for one OKDB env lives in a pair of shared LMDB environments. Individual indexes are distinguished by an auto-increment `ftsId` used as a key prefix — no new files per index.
313
315
 
314
316
  ```
315
- fts_<type>_<name>/
316
- ├── post dupSort=true token → docKey (inverted posting list)
317
- └── docs docKey[tokens] (forward index for update/delete)
317
+ ~fts/<env>/ ← uncompressed env
318
+ ├── post dupSort [ftsId, token]docId (live "add" tier)
319
+ ├── postDel dupSort [ftsId, token] docId (live "delete" tombstones)
320
+ ├── frozen [ftsId, token] → Roaring blob (compacted tier)
321
+ ├── dict [type, 'k', docKey] → docId (docKey ↔ docId interning)
322
+ └── tokenDict ['k', token] → tokenId (token ↔ tokenId interning)
323
+
324
+ ~fts/<env>_docs/ ← LZ4-compressed env
325
+ └── docs [ftsId, docId] → tokenId[] (forward index)
318
326
  ```
319
327
 
320
- - Always `sync: false` — fully rebuildable from the default env's type data.
321
- - FTS metadata (field config, status, created/updated timestamps) is stored in
322
- `default/types` under `ftsIndexes[name]` this IS synced.
323
- - On inline writes, `putSync`/`removeSync` are used so results are immediately
324
- visible within the same event loop tick.
328
+ - **Two-tier writes.** Hot path lands in the live tier (`post` / `postDel`)O(1) dupSort puts. The compactor periodically merges live entries into per-token Roaring bitmaps in `frozen` and clears the live tier.
329
+ - **Reads** merge all three tiers: `frozen liveAdds liveDeletes`. Roaring's set ops keep this essentially free.
330
+ - **ID interning.** `dict` maps (type, docKey) → uint32 docIds so posting-list values and forward-index keys are 4 bytes instead of full strings. `tokenDict` maps tokens uint32 tokenIds so forward-index values are integer arrays instead of token strings.
331
+ - Always `sync: false` fully rebuildable from the env's type data.
332
+ - FTS metadata (field config, status, created/updated timestamps, storage `format` version) is stored in `<env>/types` under `ftsIndexes[name]` — this IS synced. The storage format auto-migrates on open by triggering a rebuild for any index whose format is older than the current code.
333
+ - Writes are async via a background processor (one per type). `fts.flush(type)` waits for the processor and runs the compactor.
334
+ - `fts.compactStorage(env)` reclaims slack inside the FTS data.mdb files (LMDB never shrinks files in place). `env.compact()` runs both the main-data and FTS compactions in one operation.
325
335
 
326
336
  ### 3.5 Vectors Environment
327
337
 
@@ -177,14 +177,27 @@ const keys = okdb.fts.search('articles', 'main', 'hello');
177
177
 
178
178
  ### `flush(type)`
179
179
 
180
- `fts.flush(type)` awaits the background processor catching up to the current write position. Returns immediately if no processor is running or if already caught up. Use this instead of `setTimeout` workarounds in tests or after bulk imports.
180
+ `fts.flush(type)` awaits the background processor catching up to the current write position, then runs the FTS compactor to roll live-tier entries into the compacted Roaring bitmap tier. Returns immediately if there's nothing to do.
181
181
 
182
182
  ### list() response
183
183
 
184
- `fts.list(type)` returns an entry per registered index. Each entry includes `processorState` and `lag` — per-type fields reflecting the single processor that drives all indexes on that type:
184
+ `fts.list(type)` returns an entry per registered index. Each entry includes:
185
185
 
186
- - `processorState`: `'building' | 'online' | 'error' | 'waiting' | null` — `null` if no processor is running (no writes have occurred yet).
187
- - `lag`: number of write operations the processor has not yet indexed. `0` means fully caught up. `null` if no processor is running.
186
+ - `processorState`: `'building' | 'online' | 'error' | 'waiting' | null` — `null` if no processor is running (no writes have occurred yet). Same value for every entry of the same type.
187
+ - `lag`: number of write operations the processor has not yet indexed. `0` means fully caught up. `null` if no processor.
188
+ - `sizeBytes`: approximate per-index payload (frozen blobs + live entries + forward index scoped by this index's `ftsId`). Excludes env-shared dictionaries.
189
+
190
+ ### Storage architecture
191
+
192
+ FTS uses a two-tier inverted index. New writes land in a small **live** tier (LMDB dupSort) on the hot write path. A background **compactor** periodically merges live entries into the **frozen** tier — one Roaring bitmap per token. Reads merge all tiers transparently.
193
+
194
+ Documents and tokens are interned to uint32 IDs (`dict` and `tokenDict` sub-DBs) so posting lists and the forward index store compact integer arrays rather than strings.
195
+
196
+ Each registered index carries a storage `format` integer; older formats auto-rebuild on open. See [FTS](./fts.md) for the full schema and the storage-size APIs (`fts.list(type)[].sizeBytes`, `fts.envSize()`).
197
+
198
+ ### Reclaiming FTS slack
199
+
200
+ LMDB never shrinks `data.mdb` in place — freed pages are reused by future writes but the file stays at its peak high-water mark. `env.compact()` reclaims that slack for both the primary env and the FTS envs in one pass. `fts.compactStorage(env)` is the FTS-only equivalent if you want to reclaim just FTS without disturbing primary storage.
188
201
 
189
202
  ---
190
203
 
package/docs/fts.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Full-Text Search
2
2
 
3
- OKDB includes a built-in full-text search engine backed by an LMDB inverted index. Each FTS index lives in its own LMDB environment (separate directory) so it can be independently compacted or rebuilt without touching user data.
3
+ OKDB includes a built-in full-text search engine. All FTS data for an environment lives in two shared LMDB environments — one inverted/uncompressed (posting lists + dictionaries), one compressed (forward index). Indexes are differentiated by an auto-increment `ftsId` used as a key prefix inside the shared envs, so adding indexes adds no new files and no per-index LMDB overhead.
4
4
 
5
5
  ---
6
6
 
@@ -21,16 +21,19 @@ await okdb.fts.register('articles', 'content', {
21
21
  The index builds from existing data immediately after registration. Wait for it:
22
22
 
23
23
  ```javascript
24
- await okdb.fts.ready('articles', 'content');
24
+ await okdb.fts.ready('articles');
25
25
  ```
26
26
 
27
27
  ### Multiple FTS indexes on one type
28
28
 
29
- You can have several FTS indexes on the same type, each covering different fields or using different tokenizer settings:
29
+ You can have several FTS indexes on the same type, each covering different fields or using different tokenizer settings. All of them share the env-level storage — adding a second index does not double your disk usage.
30
30
 
31
31
  ```javascript
32
32
  await okdb.fts.register('products', 'name', { fields: ['name'] });
33
- await okdb.fts.register('products', 'description', { fields: ['description'], tokenizer: { minTokenLength: 3 } });
33
+ await okdb.fts.register('products', 'description', {
34
+ fields: ['description'],
35
+ tokenizer: { minTokenLength: 3 },
36
+ });
34
37
  ```
35
38
 
36
39
  ---
@@ -44,14 +47,13 @@ for (const { key, value } of results) {
44
47
  }
45
48
  ```
46
49
 
47
- `ftsQuery` returns records that contain **any** of the query tokens (OR semantics). Results are not ranked by relevance they are returned in primary-key order.
50
+ `ftsQuery` returns records that contain all of the query tokens by default (AND). Use `options.mode: 'or'` for OR semantics, and `options.prefix: true` for prefix matching.
48
51
 
49
52
  ### Combining FTS with field filters
50
53
 
51
54
  Pass a sift-compatible filter as the fourth argument to narrow results:
52
55
 
53
56
  ```javascript
54
- // Articles matching the query that are also published
55
57
  const results = okdb.ftsQuery('articles', 'content', 'database performance', {
56
58
  status: 'published',
57
59
  });
@@ -62,7 +64,8 @@ const results = okdb.ftsQuery('articles', 'content', 'database performance', {
62
64
  ```javascript
63
65
  const results = okdb.ftsQuery('articles', 'content', 'hello world', null, {
64
66
  limit: 20,
65
- offset: 0,
67
+ mode: 'and', // 'and' (default) | 'or'
68
+ prefix: false, // enable prefix matching on tokens
66
69
  });
67
70
  ```
68
71
 
@@ -77,9 +80,28 @@ okdb.fts.has('articles', 'content'); // → boolean
77
80
  // Get status
78
81
  okdb.fts.status('articles', 'content'); // → 'creating' | 'ready' | null
79
82
 
80
- // List all FTS indexes on a type
83
+ // List all FTS indexes on a type — includes runtime state
81
84
  okdb.fts.list('articles');
82
- // → [{ name, status, config, created, updated, sizeBytes }]
85
+ // → [{
86
+ // name, status, config, created, updated, error,
87
+ // processorState, // per-type async processor state
88
+ // lag, // writes not yet indexed (0 = caught up)
89
+ // sizeBytes, // approximate bytes for this index (excludes shared dicts)
90
+ // }]
91
+
92
+ // Env-level storage summary across all FTS indexes
93
+ okdb.fts.envSize();
94
+ // → {
95
+ // diskBytes, // ~fts/<env>/ + ~fts/<env>_docs/ data.mdb file sizes
96
+ // payloadBytes, // actual stored bytes (indexes + shared dicts)
97
+ // indexPayloadBytes, // payload attributable to indexes only
98
+ // sharedDictBytes, // shared docKey↔docId and token↔tokenId mappings
99
+ // slackBytes, // LMDB free pages (reclaimable via env.compact)
100
+ // indexes: [{ type, name, sizeBytes }],
101
+ // }
102
+
103
+ // Reclaim FTS slack — usually called via env.compact() which does both data and FTS
104
+ await okdb.fts.compactStorage();
83
105
 
84
106
  // Drop an index
85
107
  await okdb.fts.drop('articles', 'content');
@@ -87,31 +109,102 @@ await okdb.fts.drop('articles', 'content');
87
109
 
88
110
  ---
89
111
 
90
- ## How it works
112
+ ## Storage architecture
91
113
 
92
- OKDB's FTS engine is a simple inverted posting list:
114
+ OKDB's FTS uses a two-tier inverted index with Roaring bitmaps and ID interning. The names below are the actual LMDB sub-DB names in `~fts/<envName>/data.mdb`:
93
115
 
94
- 1. On every write, the changed record's indexed fields are tokenized
95
- 2. Removed tokens are deleted from the posting list (`post` sub-db)
96
- 3. Added tokens are inserted (`token → docKey` pairs in a `dupSort` LMDB sub-db)
97
- 4. A forward index (`docs` sub-db: `docKey → [tokens]`) tracks which tokens each doc holds, enabling correct updates
116
+ ```
117
+ ~fts/<envName>/data.mdb (uncompressed env)
118
+ post dupSort [ftsId, token]docId (live "add" tier)
119
+ postDel dupSort [ftsId, token] docId (live "delete" tombstones)
120
+ frozen [ftsId, token] → Roaring blob (compacted tier)
121
+ dict [type, 'k', docKey] → docId (docKey ↔ docId mapping)
122
+ [type, 'i', docId] → docKey
123
+ [type, 'n'] → counter
124
+ tokenDict ['k', token] → tokenId (token ↔ tokenId mapping)
125
+ ['i', tokenId] → token
126
+ ['n'] → counter
127
+
128
+ ~fts/<envName>_docs/data.mdb (LZ4-compressed env)
129
+ docs [ftsId, docId] → tokenId[] (forward index)
130
+ ```
98
131
 
99
- Token processing (default):
132
+ **Why these pieces:**
100
133
 
101
- - Split on whitespace and punctuation
102
- - Lowercase
103
- - Discard tokens shorter than `minTokenLength` (default 2) or longer than `maxTokenLength` (default 64)
104
- - Numbers kept by default (`keepNumbers: true`)
134
+ | Sub-DB | Purpose | Notes |
135
+ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
136
+ | `post` / `postDel` | Live tier every write goes here first. dupSort makes inserts O(1). | Compactor periodically merges these into `frozen`. |
137
+ | `frozen` | Compacted tier one Roaring bitmap per (ftsId, token) holds all matching docIds. | 10–50× smaller than the equivalent dupSort entries for dense postings. |
138
+ | `dict` | Per-type docKey ↔ docId interning. uint32 docIds replace string keys in posting lists. | One mapping per type, shared across all that type's indexes. |
139
+ | `tokenDict` | Env-wide token ↔ tokenId interning. uint32 tokenIds replace token strings in `docs`. | "the" is the same word in every collection — one map for the whole env. |
140
+ | `docs` | Forward index — used by the indexer to diff old vs new tokens on every write. LZ4-compressed because token arrays compress well. | Read on every doc update, not on search. |
105
141
 
106
- ### Storage layout
142
+ **Reads** merge all three tiers: `frozen ∪ liveAdds − liveDeletes`. Roaring's set operations make this essentially free.
107
143
 
144
+ **Writes** only touch the live tier — never the frozen tier. The hot path is one dupSort `putSync` per added token.
145
+
146
+ ### Compaction
147
+
148
+ The compactor periodically rolls live entries into the frozen tier. It runs in two situations:
149
+
150
+ 1. **Automatically after `fts.flush(type)`** — keeps the live tier small after a known burst of writes.
151
+ 2. **Explicitly via `fts.compactStorage(env)` or `env.compact()`** — also reclaims `data.mdb` slack by rewriting the file (LMDB never shrinks files in place; freed pages are reused but the high-water mark stays).
152
+
153
+ After a stress run of 13k docs across 5 indexes, `env.compact()` typically reclaims ~75% of the FTS file size in a few seconds, with no impact on search.
154
+
155
+ ### Storage format versioning
156
+
157
+ Each registered index carries a `format` integer in its metadata. The schema has evolved (legacy → docId interning → two-tier Roaring → token interning); on every open, OKDB checks each index's format and auto-rebuilds any whose format is older than the current code. This is transparent — no manual migration needed. The rebuild reads from the type's primary data, which is the source of truth.
158
+
159
+ ---
160
+
161
+ ## Async indexing
162
+
163
+ FTS writes are **always async** — the FTS index is updated by a background processor after each write commits. Secondary indexes (field indexes, geo indexes) update synchronously inside the same LMDB transaction as the write, so they are immediately consistent. FTS isn't.
164
+
165
+ ```javascript
166
+ await okdb.put('articles', 'a1', { title: 'Hello world' });
167
+ // At this point a1 is in the primary data but NOT YET in the FTS index.
168
+
169
+ await okdb.fts.flush('articles');
170
+ // Now it is — and the live tier has been compacted into the frozen tier.
171
+
172
+ const results = okdb.fts.search('articles', 'main', 'hello'); // includes a1
108
173
  ```
109
- ~fts/<envName>/<type>/<indexName>/
110
- post dupSort=true token → docKey (inverted list)
111
- docs docKey → tokens (forward index)
112
- ```
113
174
 
114
- FTS environments are `sync: false` — they are never replicated. On a new node they rebuild from synced type data automatically.
175
+ ### `ready(type)`
176
+
177
+ `fts.ready(type)` resolves when all FTS indexes registered on `type` have completed their initial build. The optional second argument is accepted for backward compatibility but ignored — readiness is per-type, not per-index.
178
+
179
+ ### `flush(type)`
180
+
181
+ `fts.flush(type)` awaits the background processor catching up to the current write position, then runs the compactor. Returns immediately if there's nothing to do.
182
+
183
+ ### Processor state in `list()`
184
+
185
+ `processorState` and `lag` are per-type fields reflecting the single background processor that drives all of a type's indexes. Both have the same value on every entry of the same type.
186
+
187
+ - `processorState`: `'building' | 'online' | 'error' | 'waiting' | null` — `null` if no writes have happened yet.
188
+ - `lag`: count of writes the processor hasn't indexed yet. `0` means caught up.
189
+
190
+ ---
191
+
192
+ ## Tokenizer
193
+
194
+ Default behaviour:
195
+
196
+ - Splits on whitespace and punctuation
197
+ - Lowercases (when `toLower: true`, default)
198
+ - Discards tokens shorter than `minTokenLength` (default 2) or longer than `maxTokenLength` (default 64)
199
+ - Keeps numbers by default (`keepNumbers: true`)
200
+
201
+ You can override per-index in the `register()` config.
202
+
203
+ ---
204
+
205
+ ## Sync behaviour
206
+
207
+ FTS environments are not replicated. On a new node they rebuild from synced type data automatically — the source of truth is the primary data plus the change log, both of which are sync'd.
115
208
 
116
209
  ---
117
210
 
@@ -122,7 +215,7 @@ await okdb.ensureType('articles');
122
215
  await okdb.fts.register('articles', 'search', {
123
216
  fields: ['title', 'body', 'tags'],
124
217
  });
125
- await okdb.fts.ready('articles', 'search');
218
+ await okdb.fts.ready('articles');
126
219
 
127
220
  await okdb.put('articles', 'a1', {
128
221
  title: 'Getting started with LMDB',
@@ -130,7 +223,8 @@ await okdb.put('articles', 'a1', {
130
223
  tags: ['database', 'lmdb', 'storage'],
131
224
  });
132
225
 
133
- const results = okdb.ftsQuery('articles', 'search', 'LMDB key value');
226
+ await okdb.fts.flush('articles');
227
+ const results = okdb.fts.search('articles', 'search', 'LMDB key value');
134
228
  // → includes 'a1'
135
229
  ```
136
230
 
package/docs/http-api.md CHANGED
@@ -461,6 +461,7 @@ GET /api/:env/type/:type/index/:idx/count?start=<json>&end=<json> → count in
461
461
  ## Full-text search endpoints
462
462
 
463
463
  ```http
464
+ GET /api/:env/fts/size → FTS storage summary for the env
464
465
  GET /api/:env/type/:type/fts → list FTS indexes
465
466
  POST /api/:env/type/:type/fts/:name → register a new FTS index
466
467
  DELETE /api/:env/type/:type/fts/:name → drop an FTS index
@@ -469,26 +470,40 @@ POST /api/:env/type/:type/fts/:name/search → search an FTS index
469
470
  POST /api/:env/type/:type/fts/flush → flush FTS processor (await catch-up)
470
471
  ```
471
472
 
473
+ ### GET /api/:env/fts/size — env-level storage summary
474
+
475
+ Returns a single object summarizing FTS storage for the environment. Useful for "how much disk is FTS costing me" overviews and for spotting reclaimable slack.
476
+
477
+ | Field | Type | Description |
478
+ | ------------------- | -------- | -------------------------------------------------------------------------------------------- |
479
+ | `diskBytes` | `number` | Sum of `~fts/<env>/` + `~fts/<env>_docs/` data.mdb file sizes. LMDB high-water mark. |
480
+ | `payloadBytes` | `number` | Actual stored bytes (sum of all index payloads + shared dictionaries). |
481
+ | `indexPayloadBytes` | `number` | Payload attributable to indexes only (excludes shared dictionaries). |
482
+ | `sharedDictBytes` | `number` | Bytes used by the env-shared docKey↔docId and token↔tokenId dictionaries. |
483
+ | `slackBytes` | `number` | `diskBytes - payloadBytes`. Free LMDB pages — reclaimable by future writes or `env.compact`. |
484
+ | `indexes` | `array` | Per-index breakdown: `[{ type, name, sizeBytes }, ...]`. |
485
+
472
486
  ### GET /api/:env/type/:type/fts — list response
473
487
 
474
488
  Each entry in the returned array includes:
475
489
 
476
- | Field | Type | Description |
477
- | ---------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
478
- | `name` | `string` | Index name |
479
- | `status` | `'creating' \| 'resetting' \| 'ready' \| 'waiting' \| null` | Per-index lifecycle status |
480
- | `config` | `object \| null` | Index configuration as registered |
481
- | `created` | `number \| null` | HLC clock at registration time |
482
- | `updated` | `number \| null` | HLC clock at last config update |
483
- | `error` | `object \| null` | Last per-index error, if any |
484
- | `processorState` | `'building' \| 'online' \| 'error' \| 'waiting' \| null` | State of the background FTS processor for this type. `null` if no processor is running. |
485
- | `lag` | `number \| null` | Number of writes the processor has not yet indexed. `0` = fully caught up. `null` if no processor. |
490
+ | Field | Type | Description |
491
+ | ---------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
492
+ | `name` | `string` | Index name |
493
+ | `status` | `'creating' \| 'resetting' \| 'ready' \| 'waiting' \| null` | Per-index lifecycle status |
494
+ | `config` | `object \| null` | Index configuration as registered |
495
+ | `created` | `number \| null` | HLC clock at registration time |
496
+ | `updated` | `number \| null` | HLC clock at last config update |
497
+ | `error` | `object \| null` | Last per-index error, if any |
498
+ | `processorState` | `'building' \| 'online' \| 'error' \| 'waiting' \| null` | State of the background FTS processor for this type. `null` if no processor is running. |
499
+ | `lag` | `number \| null` | Number of writes the processor has not yet indexed. `0` = fully caught up. `null` if no processor. |
500
+ | `sizeBytes` | `number` | Approximate per-index payload (frozen blobs + live entries + forward index scoped by this `ftsId`). Excludes env-shared dictionaries. |
486
501
 
487
502
  `processorState` and `lag` are per-type fields — they reflect the single background processor that drives all FTS indexes on a type and are identical on every entry of the same type.
488
503
 
489
504
  ### POST /api/:env/type/:type/fts/flush — flush processor
490
505
 
491
- Waits for the background FTS processor to catch up to the current write position for this type. Responds `204 No Content` when fully caught up. This is a no-op if no processor is running or if the processor is already caught up.
506
+ Waits for the background FTS processor to catch up to the current write position for this type, then runs the FTS compactor to roll live-tier entries into the compacted Roaring bitmap tier. Responds `204 No Content` when fully caught up. This is a no-op if no processor is running or if the processor is already caught up.
492
507
 
493
508
  - **Permission:** `schema:read`
494
509
  - **Idempotent:** yes
@@ -195,4 +195,4 @@ await okdb.fts.flush('articles'); // wait for processor to index the write
195
195
  const results = okdb.fts.search('articles', 'main', 'hello'); // guaranteed to include a1
196
196
  ```
197
197
 
198
- `fts.flush()` is a no-op if the processor is already caught up. It is safe to call unconditionally.
198
+ `fts.flush()` is a no-op if the processor is already caught up. It is safe to call unconditionally. In addition to waiting for the processor, `flush()` runs the FTS compactor — rolling live-tier entries into the compacted Roaring bitmap tier — so search performance and storage stay stable under bursty writes. See [Full-Text Search](./fts.md) for the storage architecture.