@kedem/okdb 1.3.0 → 1.5.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/docs/http-api.md CHANGED
@@ -349,6 +349,8 @@ POST /api/:env/type/:type/query → query records
349
349
  POST /api/:env/transaction → atomic batch writes
350
350
  ```
351
351
 
352
+ **Hybrid search:** The `options` body field now accepts `fts` and `vector` sub-objects alongside `index`. See the querying guide for full documentation. When `fts` or `vector` is provided, the route automatically awaits the async result.
353
+
352
354
  ```bash
353
355
  # Get a record
354
356
  curl http://localhost:8080/api/default/type/users/item/alice \
package/docs/querying.md CHANGED
@@ -192,6 +192,116 @@ const open = okdb.countByIndex('orders', ['status', 'createdAt'], {
192
192
 
193
193
  ---
194
194
 
195
+ ## Hybrid queries
196
+
197
+ `query()` accepts `fts` and `vector` options to combine full-text search, semantic similarity, and index constraints in a single call. All signals compose — the result is a unified ranked list with sift post-filtering applied last.
198
+
199
+ ### FTS signal
200
+
201
+ ```javascript
202
+ // Full-text search — returns results ordered by relevance score
203
+ const results = await okdb.query(
204
+ 'articles',
205
+ {},
206
+ {
207
+ fts: { name: 'articles_fts', query: 'database indexing' },
208
+ limit: 20,
209
+ },
210
+ );
211
+ // Each result has: { key, value, ftsScore, numTerms, maxScore }
212
+ ```
213
+
214
+ ### FTS + index constraint
215
+
216
+ The index acts as a **hard constraint** — only documents present in the index range are returned. It does not affect ranking.
217
+
218
+ ```javascript
219
+ // FTS results, but only from published articles
220
+ const results = await okdb.query(
221
+ 'articles',
222
+ {},
223
+ {
224
+ fts: { name: 'articles_fts', query: 'database indexing' },
225
+ index: { fields: ['status'], prefix: ['published'] },
226
+ limit: 20,
227
+ },
228
+ );
229
+ ```
230
+
231
+ ### FTS + sift post-filter
232
+
233
+ Sift filter is always applied last, after FTS and index narrowing:
234
+
235
+ ```javascript
236
+ const results = await okdb.query(
237
+ 'articles',
238
+ { category: 'tech' },
239
+ {
240
+ fts: { name: 'articles_fts', query: 'indexing' },
241
+ limit: 20,
242
+ },
243
+ );
244
+ ```
245
+
246
+ ### FTS + vector (hybrid semantic + keyword)
247
+
248
+ When both `fts` and `vector` are provided, candidates from each signal are combined and re-ranked using **Reciprocal Rank Fusion (RRF)**. RRF works across incomparable score scales — no normalization needed.
249
+
250
+ ```javascript
251
+ const results = await okdb.query(
252
+ 'articles',
253
+ {},
254
+ {
255
+ fts: { name: 'articles_fts', query: 'database' },
256
+ vector: { engine: 'articles_vec', query: 'fast persistent storage', limit: 50 },
257
+ limit: 20,
258
+ },
259
+ );
260
+ // Each result has: { key, value, score (RRF), ftsScore?, vectorScore? }
261
+ ```
262
+
263
+ ### Signal semantics
264
+
265
+ | Signal | Ordering | Can enumerate all matches | Notes |
266
+ | -------- | ---------------- | ------------------------- | ------------------------------------- |
267
+ | `index` | Field value | Yes | Hard constraint only — never reorders |
268
+ | `fts` | Relevance score | Yes | Posting list intersection |
269
+ | `vector` | Similarity score | No — K-bounded | ANN search, approximate |
270
+
271
+ **Important:** `query()` returns a **Promise** when `fts` or `vector` is present (vector search requires async embedding inference). Without these options, `query()` remains synchronous.
272
+
273
+ ### FTS options
274
+
275
+ | Option | Type | Default | Description |
276
+ | -------- | --------------- | -------------------- | --------------------------------------------- |
277
+ | `name` | string | required | Registered FTS index name |
278
+ | `query` | string | `''` | Text to search |
279
+ | `mode` | `'and'`\|`'or'` | `'and'` | AND: all terms required. OR: any term matches |
280
+ | `prefix` | boolean | false | Enable prefix matching on last token |
281
+ | `limit` | number | `max(limit×10, 200)` | FTS candidate pool size |
282
+
283
+ ### Vector options
284
+
285
+ | Option | Type | Default | Description |
286
+ | -------- | ------ | ------------------- | ------------------------- |
287
+ | `engine` | string | required | Vector search engine name |
288
+ | `query` | string | required | Semantic query text |
289
+ | `limit` | number | `max(limit×4, 100)` | ANN candidate pool size |
290
+
291
+ ### Nested index form
292
+
293
+ The `index` option accepts a nested object for clarity:
294
+
295
+ ```javascript
296
+ // Nested form (preferred for hybrid queries)
297
+ { index: { fields: ['status'], prefix: ['active'] } }
298
+
299
+ // Flat form (still works — backward compatible)
300
+ { index: ['status'], prefix: ['active'] }
301
+ ```
302
+
303
+ ---
304
+
195
305
  ## Geo query
196
306
 
197
307
  OKDB has a built-in geospatial index based on geohash. Register a geo index on a field that stores `{ lat, lon }` (or an `[lat, lon]` array):
package/docs/views.md CHANGED
@@ -131,6 +131,201 @@ const allOrders = result.total.items(); // → Order[]
131
131
 
132
132
  ---
133
133
 
134
+ ## `$group` reducer
135
+
136
+ `$group` partitions source documents by a field (or compound key) and runs named sub-reducers within each partition. The result is an object keyed by the group value, with each group holding a nested reduced document.
137
+
138
+ ### Syntax
139
+
140
+ ```js
141
+ reduce: {
142
+ outputField: {
143
+ $group: {
144
+ by: 'field' | ['field1', 'field2'], // group key — single field or compound
145
+ filter: object, // optional — sift-style, applied within $group
146
+ reduce: {
147
+ subReducerName: { $count: true },
148
+ revenue: { $sum: 'amount' },
149
+ avg: { $avg: 'price' },
150
+ // $countBy also works inside $group
151
+ },
152
+ },
153
+ },
154
+ }
155
+ ```
156
+
157
+ ### Output shape
158
+
159
+ Each populated group key maps to a nested object with one entry per sub-reducer:
160
+
161
+ ```js
162
+ {
163
+ groupKey1: {
164
+ revenue: { value: 1200 },
165
+ count: { value: 4 },
166
+ },
167
+ groupKey2: {
168
+ revenue: { value: 800 },
169
+ count: { value: 3 },
170
+ },
171
+ }
172
+ ```
173
+
174
+ Empty groups (count/sum reaching 0) are removed automatically — they are never present as `{ value: 0 }`.
175
+
176
+ ```js
177
+ const stats = env.views.get('orderStats');
178
+ stats.byProduct.p1.revenue.value; // → 1200
179
+ stats.byProduct.p1.count.value; // → 4
180
+ ```
181
+
182
+ ### Compound key
183
+
184
+ Pass an array to `by` to build a key from multiple fields. The fields are joined with a `\x00` separator:
185
+
186
+ ```js
187
+ $group: {
188
+ by: ['product_id', 'region'],
189
+ reduce: { count: { $count: true } },
190
+ }
191
+
192
+ // Key for product_id='p1', region='EU': 'p1\x00EU'
193
+ stats.byProductRegion['p1\x00EU'].count.value;
194
+ ```
195
+
196
+ ### Filter semantics
197
+
198
+ Filters are evaluated at three levels:
199
+
200
+ | Level | Where it lives | What it filters |
201
+ | ------------------ | ----------------------------- | -------------------------------------------------------------------------------- |
202
+ | View filter | `definition.filter` | Applied before map; docs failing it never reach any reducer |
203
+ | `$group.filter` | `$group: { filter: {...} }` | Applied within `$group`, post-map; docs failing it don't contribute to any group |
204
+ | Sub-reducer filter | Not supported inside `$group` | Sub-reducers within a group share the group's filter |
205
+
206
+ ### Limitations
207
+
208
+ - `$min` and `$max` are **not supported** inside `$group`. They require an index-backed sub-DB per group key. Attempting to use them throws at view-creation time: `$min and $max are not supported inside $group reducers`.
209
+ - Nested `$group` (a `$group` whose sub-reducer is also a `$group`) is not supported. Use a compound `by` array instead.
210
+
211
+ ### Example
212
+
213
+ ```js
214
+ await env.views.create('lineItemStats', {
215
+ type: 'line_items',
216
+ reduce: {
217
+ byProduct: {
218
+ $group: {
219
+ by: 'product_id',
220
+ reduce: {
221
+ revenue: { $sum: 'amount' },
222
+ count: { $count: true },
223
+ },
224
+ },
225
+ },
226
+ },
227
+ });
228
+
229
+ const stats = env.views.get('lineItemStats');
230
+ // stats.byProduct = {
231
+ // 'p1': { revenue: { value: 1500 }, count: { value: 3 } },
232
+ // 'p2': { revenue: { value: 800 }, count: { value: 2 } },
233
+ // }
234
+ ```
235
+
236
+ `$group` also works inside `$ref.reduce`:
237
+
238
+ ```js
239
+ reduce: {
240
+ lineItems: {
241
+ $ref: {
242
+ type: 'line_items',
243
+ key: 'order_id',
244
+ reduce: {
245
+ byProduct: {
246
+ $group: {
247
+ by: 'product_id',
248
+ reduce: { total: { $sum: 'amount' } },
249
+ },
250
+ },
251
+ },
252
+ },
253
+ },
254
+ }
255
+
256
+ stats.lineItems.byProduct.p1.total.value; // → aggregate revenue for product p1
257
+ ```
258
+
259
+ ---
260
+
261
+ ## Live map `$ref`
262
+
263
+ A map field with `live: true` on its `$ref` spec is a **live enrichment**: when the referenced document changes, all source documents pointing to it are automatically re-evaluated through the view.
264
+
265
+ ### Syntax
266
+
267
+ ```js
268
+ map: {
269
+ vendorName: { $ref: ['vendors', '$vendor_id', 'name'], live: true },
270
+ }
271
+ ```
272
+
273
+ The array form is identical to the snapshot `$ref` — the only difference is the `live: true` flag.
274
+
275
+ ### Snapshot vs live semantics
276
+
277
+ | Mode | Behavior |
278
+ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
279
+ | Snapshot (default, no `live: true`) | The referenced field is resolved once when the source doc is written. Later changes to the referenced doc are not reflected in the view. |
280
+ | Live (`live: true`) | OKDB maintains an inverse index mapping each referenced doc key to all source docs that point to it. When the referenced doc changes, every source doc in that fan-out set is re-processed through the filter, map, and reducers. |
281
+
282
+ ```js
283
+ // Snapshot — p1's vendor change does NOT update the view
284
+ map: { vendor: { $ref: ['products', '$product_id', 'vendor'] } }
285
+
286
+ // Live — p1's vendor change re-evaluates all line_items with product_id='p1'
287
+ map: { vendor: { $ref: ['products', '$product_id', 'vendor'], live: true } }
288
+ ```
289
+
290
+ ### Cost considerations
291
+
292
+ Each live map field writes two extra sub-DBs per view:
293
+
294
+ - An **inverse index** (`liveMapInverse:<viewName>:<field>`) — maps each referenced doc key to the set of source doc keys that point to it.
295
+ - A **values cache** (`liveMapValues:<viewName>:<field>`) — caches the last resolved value per source doc to produce a correct `before` snapshot when the referenced doc changes.
296
+
297
+ When a referenced doc is updated, OKDB re-processes every source doc in the inverse index for that field. For a referenced type with many source documents per entry (high fan-out), this multiplies write cost:
298
+
299
+ - **Low fan-out** (e.g., a `products` table where each product has a few line items): `live: true` is fine.
300
+ - **High fan-out** (e.g., a `users` table where each user has thousands of events): prefer snapshot mode or move the enrichment to a materializer pipeline.
301
+
302
+ ### Example
303
+
304
+ ```js
305
+ await db.registerType('products');
306
+ await db.registerType('line_items');
307
+
308
+ await env.views.create('salesByVendor', {
309
+ type: 'line_items',
310
+ map: { vendor: { $ref: ['products', '$product_id', 'vendor'], live: true } },
311
+ reduce: { byVendor: { $countBy: 'vendor' } },
312
+ });
313
+
314
+ await db.put('products', 'p1', { vendor: 'ACME' });
315
+ await db.put('line_items', 'li1', { product_id: 'p1', amount: 100 });
316
+ await db.put('line_items', 'li2', { product_id: 'p1', amount: 200 });
317
+
318
+ // salesByVendor.byVendor = { ACME: { value: 2 } }
319
+
320
+ await db.put('products', 'p1', { vendor: 'NewCo' }); // rename the vendor
321
+
322
+ // After propagation:
323
+ // salesByVendor.byVendor = { NewCo: { value: 2 } }
324
+ // ACME bucket is removed; both li1 and li2 moved to NewCo automatically.
325
+ ```
326
+
327
+ ---
328
+
134
329
  ## `$ref` aggregations
135
330
 
136
331
  A `$ref` reducer aggregates documents from a _related_ type, scoped to the filtered parent documents. The child type must have a foreign-key field pointing back to the parent type's key.