@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/bin/okdb.js +1 -1
- package/docs/http-api.md +2 -0
- package/docs/querying.md +110 -0
- package/docs/views.md +195 -0
- package/okdb.js +1 -1
- package/package.json +1 -1
- package/public/sections/views/modals/create-view-modal.ok.js +1 -1
- package/public/sections/views/parts/view-detail.ok.js +1 -1
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.
|