@kedem/okdb 1.0.4 → 1.1.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.
@@ -639,7 +639,8 @@ env (not the default env):
639
639
  cron scheduling, token-bucket rate limiting (buckets), and per-job result storage
640
640
  - Workers claim jobs (`STATUS.PENDING → CLAIMED`) and mark them done/failed
641
641
  - Reconciler handles stale claims, retry scheduling, cron advancement
642
- - Stats (`OKDBQueueStats`) tracked per-bucket in a separate LMDB sub-db
642
+ - Stats are maintained by a `~queue_stats` view (`$count` + `$countBy`
643
+ reductions over `~queue_jobs`) — auto-updated by the views processor
643
644
 
644
645
  The queue is OKDB-native: jobs survive crashes, job history is queryable,
645
646
  all mutations go through the queue env's change log, and queue state replicates
@@ -17,6 +17,7 @@
17
17
  { "file": "sync.md", "label": "Sync" },
18
18
  { "file": "fts.md", "label": "Full-Text Search" },
19
19
  { "file": "embeddings.md", "label": "Embeddings" },
20
+ { "file": "views.md", "label": "Materialized Views" },
20
21
  { "file": "pipelines.md", "label": "Pipelines" },
21
22
  { "file": "queue.md", "label": "Queue" },
22
23
  { "file": "files.md", "label": "Files" },
package/docs/pipelines.md CHANGED
@@ -360,6 +360,8 @@ Step 10 adds a `materializer` engine type for deterministic source→target coll
360
360
 
361
361
  Unlike the generic `processor`, a materializer has an **explicit target type** and a **return-ops contract** that enables safe rebuild.
362
362
 
363
+ > **Views vs materializers** — if you need live aggregates (counts, sums, averages, grouped statistics) choose [views](./views.md): define a `reduce` spec and read the result synchronously via `env.views.get()`. If you need a _derived collection_ — one document per source document, with custom field mapping — use a materializer: the function returns ops that the engine applies to the target type, which is then queryable like any other type.
364
+
363
365
  Key differences from `processor`:
364
366
 
365
367
  | Concern | `processor` | `materializer` |
package/docs/views.md ADDED
@@ -0,0 +1,351 @@
1
+ # Materialized Views
2
+
3
+ Views are reactive, incrementally-maintained aggregations over a registered type. Every write to the source type is processed immediately and the result is always available synchronously via `.get()`. No polling, no background jobs, no query-time computation.
4
+
5
+ ---
6
+
7
+ ## Quick start
8
+
9
+ ```js
10
+ const db = new OKDB('./mydb');
11
+ await db.open();
12
+ await db.registerType('orders');
13
+
14
+ const env = db.env('default');
15
+
16
+ // Create a view
17
+ await env.views.create('orderStats', {
18
+ type: 'orders',
19
+ filter: { status: 'completed' },
20
+ reduce: {
21
+ total: { $sum: 'amount' },
22
+ count: { $count: true },
23
+ avgValue: { $avg: 'amount' },
24
+ byRegion: { $countBy: 'region' },
25
+ },
26
+ });
27
+
28
+ // Read the live result — synchronous, O(1)
29
+ const stats = env.views.get('orderStats');
30
+ // → {
31
+ // total: { value: 142500 },
32
+ // count: { value: 312 },
33
+ // avgValue: { value: 456.7 },
34
+ // byRegion: { EU: { value: 140 }, US: { value: 172 } }
35
+ // }
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Definition shape
41
+
42
+ ```js
43
+ {
44
+ type: string, // required — source type (must be registered)
45
+ filter: object, // optional — sift-style filter applied before reducers
46
+ map: object, // optional — declarative per-doc transform applied before reducers
47
+ reduce: object, // required, non-empty — output-field → reducer spec
48
+ }
49
+ ```
50
+
51
+ ### `filter`
52
+
53
+ Uses [sift](https://github.com/crcn/sift.js) query operators (MongoDB-style). Only documents that pass the filter contribute to the view.
54
+
55
+ ```js
56
+ filter: { status: 'active', amount: { $gte: 100 } }
57
+ ```
58
+
59
+ ### `map`
60
+
61
+ A declarative transform merged onto each source document before reducers run. The original document fields are preserved — map fields are added/overwritten on top.
62
+
63
+ | Operator | Syntax | Description |
64
+ | ------------ | --------------------------------------------------------------- | ----------------------------------------------------------------------------- |
65
+ | Field rename | `{ storeName: 'name' }` | Copy field to output key; dot paths supported: `'address.city'` |
66
+ | FK lookup | `{ region: { $ref: ['regions', '$regionId', 'label'] } }` | FK ref and target field both support dot paths |
67
+ | Concatenate | `{ label: { $concat: ['$city', ' – ', '$name'] } }` | `$`-prefixed tokens are field refs (dot paths ok); others are literals |
68
+ | Coalesce | `{ display: { $coalesce: ['$nickname', '$name', 'Unknown'] } }` | First non-null wins; `$`-prefixed tokens are field refs (dot paths supported) |
69
+
70
+ ```js
71
+ // Example: enrich each store with its region name via a FK lookup,
72
+ // then group-count by that enriched field
73
+ map: {
74
+ regionName: { $ref: ['regions', '$regionId', 'name'] },
75
+ },
76
+ reduce: {
77
+ byRegion: { $countBy: 'regionName' },
78
+ }
79
+ ```
80
+
81
+ ### `reduce`
82
+
83
+ Each key in `reduce` becomes an output field. The value is a reducer spec:
84
+
85
+ | Reducer | Spec | Output | Notes |
86
+ | ---------- | ----------------------- | ------------------------------ | ---------------------------------------------------- |
87
+ | `$count` | `{ $count: true }` | `{ value: N }` | Counts matching docs |
88
+ | `$sum` | `{ $sum: 'field' }` | `{ value: N }` | Sums a numeric field; non-numeric values are ignored |
89
+ | `$avg` | `{ $avg: 'field' }` | `{ value: N }` | Running average; `value` is `0` when no docs match |
90
+ | `$min` | `{ $min: 'field' }` | `{ value: X }` | Index-backed: auto-creates an index on the field |
91
+ | `$max` | `{ $max: 'field' }` | `{ value: X }` | Index-backed: auto-creates an index on the field |
92
+ | `$countBy` | `{ $countBy: 'field' }` | `{ [groupKey]: { value: N } }` | Groups docs by a field value |
93
+
94
+ Field paths in all reducers support dot notation for nested access: `{ $sum: 'metrics.revenue' }`, `{ $countBy: 'address.country' }`. This is consistent with how indexes handle nested fields.
95
+
96
+ `$min` and `$max` require an index on the named field. The view engine creates one automatically if none exists; it is removed when the view is removed (unless another consumer owns it).
97
+
98
+ ---
99
+
100
+ ## Output shape
101
+
102
+ `views.get(name)` returns an object with one key per entry in `reduce`. The shape depends on the reducer:
103
+
104
+ - **Scalar reducers** (`$count`, `$sum`, `$avg`, `$min`, `$max`): `{ value: N }`
105
+ - **Grouped reducer** (`$countBy`): `{ [groupKey]: { value: N } }`
106
+ - **`$ref` slots**: a nested object with the same shape as a top-level `reduce` result (see [$ref aggregations](#ref-aggregations))
107
+
108
+ ```js
109
+ const result = env.views.get('orderStats');
110
+ result.total.value; // → 142500 (number)
111
+ result.avgValue.value; // → 456.7 (number)
112
+ result.byRegion.EU.value; // → 140 (number)
113
+ ```
114
+
115
+ ### `items: true`
116
+
117
+ Add `items: true` to any reducer spec to enable a `.items()` method on the result group. Calling it returns the live source documents that contribute to that aggregation bucket.
118
+
119
+ ```js
120
+ reduce: {
121
+ byRegion: { $countBy: 'region', items: true },
122
+ total: { $sum: 'amount', items: true },
123
+ }
124
+
125
+ // Access source docs for a specific group
126
+ const euOrders = result.byRegion.EU.items(); // → Order[]
127
+
128
+ // Scalar items() returns all matching docs
129
+ const allOrders = result.total.items(); // → Order[]
130
+ ```
131
+
132
+ ---
133
+
134
+ ## `$ref` aggregations
135
+
136
+ 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.
137
+
138
+ ```js
139
+ await env.views.create('storeStats', {
140
+ type: 'stores',
141
+ filter: { active: true },
142
+ reduce: {
143
+ storeCount: { $count: true },
144
+
145
+ // Aggregate the related 'orders' type
146
+ orders: {
147
+ $ref: {
148
+ type: 'orders', // child type
149
+ key: 'storeId', // FK field on the child pointing to the parent key
150
+ filter: { status: 'completed' },
151
+ reduce: {
152
+ count: { $count: true },
153
+ revenue: { $sum: 'amount' },
154
+ },
155
+ },
156
+ },
157
+ },
158
+ });
159
+
160
+ const stats = env.views.get('storeStats');
161
+ stats.orders.count.value; // → total completed orders across all active stores
162
+ stats.orders.revenue.value; // → total revenue
163
+ ```
164
+
165
+ `$ref` reducers support the same built-in reducers as top-level `reduce`, except `$min` and `$max` (index-backed reducers are not supported inside `$ref`).
166
+
167
+ ---
168
+
169
+ ## Lifecycle
170
+
171
+ ### States
172
+
173
+ A view's meta state is available via `views.getMeta(name)`:
174
+
175
+ | State | Meaning |
176
+ | ----------- | ----------------------------------------------------------------------------- |
177
+ | `creating` | Initial bootstrap scan in progress |
178
+ | `ready` | Fully up-to-date; all writes are being processed |
179
+ | `halted` | A reducer threw; new writes are queued until the next write recovers the view |
180
+ | `stopped` | Manually paused; writes that arrive while stopped are replayed on start |
181
+ | `resetting` | Clock regression detected; full rebuild triggered automatically |
182
+
183
+ ### Rebuild
184
+
185
+ A full rebuild clears all accumulated state and re-scans the source type from scratch. Triggered automatically on clock regression; also available manually:
186
+
187
+ ```js
188
+ await env.views.rebuild('orderStats');
189
+ ```
190
+
191
+ ### Stop / start
192
+
193
+ ```js
194
+ await env.views.stop('orderStats'); // pause processing; definition is preserved
195
+ await env.views.start('orderStats'); // resume; catches up optimistically or rebuilds
196
+ ```
197
+
198
+ When a view is stopped and later started:
199
+
200
+ - If no writes arrived while stopped, the view resumes instantly.
201
+ - If only _new_ documents were added (no modifications or removals), an optimistic catch-up is applied.
202
+ - Otherwise a full rebuild is performed.
203
+
204
+ ### Remove
205
+
206
+ ```js
207
+ await env.views.remove('orderStats');
208
+
209
+ // If the view owns an auto-created index, you must decide what happens to it:
210
+ await env.views.remove('orderStats', { managedIndexes: 'drop' }); // drop the index
211
+ await env.views.remove('orderStats', { managedIndexes: 'keep' }); // retain the index
212
+ ```
213
+
214
+ ---
215
+
216
+ ## Custom reducers
217
+
218
+ Register a custom reducer before creating a view that uses it. Custom reducer names must start with `$`.
219
+
220
+ ```js
221
+ env.views.registerReducer('$product', {
222
+ apply(state, before, after, opts) {
223
+ // state — current accumulated value (null on first call)
224
+ // before — previous doc (null for inserts), post-filter, post-map
225
+ // after — new doc (null for deletes), post-filter, post-map
226
+ // opts — the value from the spec: { $product: opts }
227
+ state = state ?? 1;
228
+ if (before !== null && typeof before[opts] === 'number') state /= before[opts];
229
+ if (after !== null && typeof after[opts] === 'number') state *= after[opts];
230
+ return state;
231
+ },
232
+ });
233
+
234
+ await env.views.create('priceProduct', {
235
+ type: 'items',
236
+ reduce: { product: { $product: 'multiplier' } },
237
+ });
238
+ ```
239
+
240
+ ---
241
+
242
+ ## JavaScript API
243
+
244
+ All methods are on `env.views` (`OKDBEnv#views`):
245
+
246
+ ```js
247
+ const env = db.env('myEnv');
248
+
249
+ // Create
250
+ await env.views.create(name, definition) // → { name }
251
+
252
+ // Read
253
+ env.views.get(name) // → output object | null (synchronous)
254
+ env.views.getMeta(name) // → { state, clock, error, refs } | null
255
+ await env.views.getDefinition(name) // → stored definition | null
256
+ await env.views.list() // → string[]
257
+
258
+ // Lifecycle
259
+ await env.views.rebuild(name)
260
+ await env.views.stop(name)
261
+ await env.views.start(name)
262
+ await env.views.remove(name, options?)
263
+
264
+ // Extension
265
+ env.views.registerReducer(name, { apply })
266
+ ```
267
+
268
+ ---
269
+
270
+ ## HTTP API
271
+
272
+ Views are scoped per environment. The `{env}` segment is the environment name (e.g. `default`).
273
+
274
+ | Method | Path | Description |
275
+ | -------- | ---------------------------------------- | ----------------------- |
276
+ | `GET` | `/api/env/{env}/views` | List view names |
277
+ | `POST` | `/api/env/{env}/views` | Create a view |
278
+ | `GET` | `/api/env/{env}/views/{name}` | Get current view output |
279
+ | `GET` | `/api/env/{env}/views/{name}/meta` | Get view state/meta |
280
+ | `GET` | `/api/env/{env}/views/{name}/definition` | Get stored definition |
281
+ | `DELETE` | `/api/env/{env}/views/{name}` | Remove a view |
282
+ | `POST` | `/api/env/{env}/views/{name}/rebuild` | Rebuild from scratch |
283
+ | `POST` | `/api/env/{env}/views/{name}/stop` | Stop processing |
284
+ | `POST` | `/api/env/{env}/views/{name}/start` | Start / resume |
285
+
286
+ **Create body:**
287
+
288
+ ```json
289
+ {
290
+ "name": "orderStats",
291
+ "type": "orders",
292
+ "filter": { "status": "completed" },
293
+ "reduce": {
294
+ "total": { "$sum": "amount" },
295
+ "count": { "$count": true }
296
+ }
297
+ }
298
+ ```
299
+
300
+ **Delete with managed indexes:**
301
+
302
+ ```json
303
+ { "managedIndexes": "drop" }
304
+ ```
305
+
306
+ ---
307
+
308
+ ## MCP
309
+
310
+ Views are available as the `views` capability. Actions: `list_views`, `create_view`, `get_view`, `get_view_meta`, `get_view_definition`, `remove_view`, `rebuild_view`, `stop_view`, `start_view`.
311
+
312
+ ---
313
+
314
+ ## Views vs materializers
315
+
316
+ | | Views | Materializer engine |
317
+ | -------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- |
318
+ | **What it produces** | A single reduced document (aggregates) | A populated target collection (projections) |
319
+ | **Definition style** | Declarative (`reduce`, `filter`, `map`) | Imperative function returning ops |
320
+ | **Rebuild** | `views.rebuild(name)` | `engine.api.rebuild()` |
321
+ | **Access** | `env.views.get(name)` — always synchronous | Read the target type via `env.get(...)` |
322
+ | **Use when** | You need live counters, sums, averages, or group statistics | You need a derived collection with one document per source document |
323
+
324
+ Use views for aggregations (how many, how much, grouped by). Use a materializer engine when you need a structurally transformed copy of a collection that other queries or features can read from directly.
325
+
326
+ See [pipelines.md](./pipelines.md) for materializer documentation.
327
+
328
+ ---
329
+
330
+ ## Sync behavior
331
+
332
+ View definitions are stored in the `~views` system type within their environment and sync to connected peers like any other document. On startup and after each sync reconcile pass, OKDB activates any view definitions that arrived from remote peers but are not yet live locally.
333
+
334
+ ---
335
+
336
+ ## Error codes
337
+
338
+ | Code | Cause |
339
+ | ------------------------------ | ---------------------------------------------------------------------------------------------- |
340
+ | `VIEW_INVALID_NAME` | Name is empty, contains whitespace, or is not a string |
341
+ | `VIEW_INVALID_DEFINITION` | Definition is not an object |
342
+ | `VIEW_MISSING_TYPE` | `type` field missing or not a string |
343
+ | `VIEW_MISSING_REDUCE` | `reduce` field missing or empty |
344
+ | `VIEW_TYPE_NOT_REGISTERED` | The source type does not exist in this environment |
345
+ | `VIEW_ALREADY_EXISTS` | A view with that name already exists |
346
+ | `VIEW_NOT_FOUND` | No view with that name |
347
+ | `VIEW_HAS_ORPHANED_INDEXES` | Removing the view would leave auto-created indexes with no owner; pass `managedIndexes` option |
348
+ | `VIEW_REF_UNSUPPORTED_REDUCER` | `$min`/`$max` used inside a `$ref` sub-view |
349
+ | `VIEW_INVALID_REDUCER_NAME` | Custom reducer name does not start with `$` |
350
+ | `VIEW_REDUCER_CONFLICT` | Custom reducer name conflicts with a built-in |
351
+ | `VIEW_INVALID_REDUCER` | Custom reducer missing an `apply` function |