@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.