@kedem/okdb 1.4.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/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.
|