@kedem/okdb 1.0.4 → 1.1.1
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/README.md +13 -4
- package/bin/okdb.js +1 -1
- package/docs/OKDB-ARCHITECTURE.md +2 -1
- package/docs/manifest.json +1 -0
- package/docs/pipelines.md +2 -0
- package/docs/views.md +513 -0
- package/okdb.js +1 -1
- package/package.json +1 -1
- package/public/sections/queue/parts/queue-buckets.ok.js +1 -1
- package/public/sections/queue/parts/queue-jobs.ok.js +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/public/sections/views/parts/views-overview.ok.js +1 -1
- package/types/features/views.d.ts +235 -10
|
@@ -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
|
|
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
|
package/docs/manifest.json
CHANGED
|
@@ -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,513 @@
|
|
|
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
|
+
## Bucketing
|
|
170
|
+
|
|
171
|
+
Bucketed views partition their aggregations by a time period (or a custom grouping field), producing a per-bucket series of incremental results instead of a single global document.
|
|
172
|
+
|
|
173
|
+
### Bucket config
|
|
174
|
+
|
|
175
|
+
Add a `bucket` object to the view definition:
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
{
|
|
179
|
+
type: string, // source type (required)
|
|
180
|
+
filter: object, // optional
|
|
181
|
+
reduce: object, // required
|
|
182
|
+
bucket: {
|
|
183
|
+
field: string, // required — dot path to the timestamp or grouping field
|
|
184
|
+
preset: 'time', // use preset OR project, not both
|
|
185
|
+
granularity: string, // required — bucket size (see below)
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
#### `preset: 'time'` granularities
|
|
191
|
+
|
|
192
|
+
| `granularity` | Bucket size |
|
|
193
|
+
| ------------- | -------------------- |
|
|
194
|
+
| `minute` | 1 minute |
|
|
195
|
+
| `hour` | 1 hour |
|
|
196
|
+
| `day` | 1 calendar day (UTC) |
|
|
197
|
+
| `week` | ISO week |
|
|
198
|
+
| `month` | Calendar month |
|
|
199
|
+
| `quarter` | Calendar quarter |
|
|
200
|
+
| `year` | Calendar year |
|
|
201
|
+
|
|
202
|
+
The `field` value is interpreted as a Unix timestamp in milliseconds or an ISO date string.
|
|
203
|
+
|
|
204
|
+
#### Custom projection (JavaScript only)
|
|
205
|
+
|
|
206
|
+
Supply a `project` function instead of `preset` to map each field value to an arbitrary string bucket key:
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
bucket: {
|
|
210
|
+
field: 'category',
|
|
211
|
+
granularity: 'category',
|
|
212
|
+
project(value) {
|
|
213
|
+
return String(value ?? 'unknown').toLowerCase();
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Custom projections are not JSON-serializable and cannot be used in synced environments (`BUCKET_PROJECT_NOT_SYNCABLE`).
|
|
219
|
+
|
|
220
|
+
### Create example
|
|
221
|
+
|
|
222
|
+
```js
|
|
223
|
+
await env.views.create('salesByDay', {
|
|
224
|
+
type: 'orders',
|
|
225
|
+
filter: { status: 'completed' },
|
|
226
|
+
reduce: {
|
|
227
|
+
revenue: { $sum: 'amount' },
|
|
228
|
+
count: { $count: true },
|
|
229
|
+
},
|
|
230
|
+
bucket: {
|
|
231
|
+
field: 'completedAt', // Unix ms or ISO date string on each source doc
|
|
232
|
+
preset: 'time',
|
|
233
|
+
granularity: 'day',
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### `range(name, options?)` — per-bucket aggregates
|
|
239
|
+
|
|
240
|
+
Returns a sparse ordered array of bucket entries. Only buckets with at least one document appear.
|
|
241
|
+
|
|
242
|
+
```js
|
|
243
|
+
const rows = env.views.range('salesByDay', {
|
|
244
|
+
from: '2024-01-01', // ISO date / epoch ms / Date — optional lower bound
|
|
245
|
+
to: '2024-03-31', // inclusive upper bound — optional
|
|
246
|
+
granularity: 'day', // must match the view's granularity, or omit
|
|
247
|
+
includePartial: false, // true = include the bucket containing Date.now()
|
|
248
|
+
});
|
|
249
|
+
// → [
|
|
250
|
+
// { bucketKey: '2024-01-01', granularity: 'day', reducers: { revenue: { value: 1200 }, count: { value: 4 } }, refs: {} },
|
|
251
|
+
// { bucketKey: '2024-01-02', granularity: 'day', reducers: { revenue: { value: 890 }, count: { value: 3 } }, refs: {} },
|
|
252
|
+
// ]
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Each entry:
|
|
256
|
+
|
|
257
|
+
- `bucketKey` — canonical string key for the bucket (ISO date string for `preset:'time'`)
|
|
258
|
+
- `granularity` — the view's configured granularity
|
|
259
|
+
- `reducers` — `{ [outputField]: { value: N } }` for scalar reducers; `{ [outputField]: { [groupKey]: { value: N } } }` for `$countBy`
|
|
260
|
+
- `refs` — `{ [refName]: { [subField]: { value: N } } }` for `$ref` sub-views
|
|
261
|
+
|
|
262
|
+
### `listBuckets(name, options)` — enumerate populated buckets
|
|
263
|
+
|
|
264
|
+
Returns an ordered list of bucket keys that have at least one document, with per-bucket counts.
|
|
265
|
+
|
|
266
|
+
```js
|
|
267
|
+
const buckets = env.views.listBuckets('salesByDay', {
|
|
268
|
+
granularity: 'day', // required — must equal the view's configured granularity
|
|
269
|
+
from: '2024-01-01', // optional
|
|
270
|
+
to: '2024-12-31', // optional
|
|
271
|
+
limit: 10, // optional — cap result count
|
|
272
|
+
reverse: true, // optional — true = latest-first
|
|
273
|
+
});
|
|
274
|
+
// → [
|
|
275
|
+
// { bucketKey: '2024-12-31', count: 5 },
|
|
276
|
+
// { bucketKey: '2024-12-30', count: 12 },
|
|
277
|
+
// ]
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Items scoped to a bucket
|
|
281
|
+
|
|
282
|
+
When a reducer has `items: true`, pass a `bucket` option to `.items()` to restrict results to a single bucket:
|
|
283
|
+
|
|
284
|
+
```js
|
|
285
|
+
const view = env.views.get('salesByDay');
|
|
286
|
+
const items = view.count.items({ bucket: { granularity: 'day', key: '2024-01-15' } });
|
|
287
|
+
// → Order[] (only orders in the 2024-01-15 bucket)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Lifecycle
|
|
293
|
+
|
|
294
|
+
### States
|
|
295
|
+
|
|
296
|
+
A view's meta state is available via `views.getMeta(name)`:
|
|
297
|
+
|
|
298
|
+
| State | Meaning |
|
|
299
|
+
| ----------- | ----------------------------------------------------------------------------- |
|
|
300
|
+
| `creating` | Initial bootstrap scan in progress |
|
|
301
|
+
| `ready` | Fully up-to-date; all writes are being processed |
|
|
302
|
+
| `halted` | A reducer threw; new writes are queued until the next write recovers the view |
|
|
303
|
+
| `stopped` | Manually paused; writes that arrive while stopped are replayed on start |
|
|
304
|
+
| `resetting` | Clock regression detected; full rebuild triggered automatically |
|
|
305
|
+
|
|
306
|
+
### Rebuild
|
|
307
|
+
|
|
308
|
+
A full rebuild clears all accumulated state and re-scans the source type from scratch. Triggered automatically on clock regression; also available manually:
|
|
309
|
+
|
|
310
|
+
```js
|
|
311
|
+
await env.views.rebuild('orderStats');
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Stop / start
|
|
315
|
+
|
|
316
|
+
```js
|
|
317
|
+
await env.views.stop('orderStats'); // pause processing; definition is preserved
|
|
318
|
+
await env.views.start('orderStats'); // resume; catches up optimistically or rebuilds
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
When a view is stopped and later started:
|
|
322
|
+
|
|
323
|
+
- If no writes arrived while stopped, the view resumes instantly.
|
|
324
|
+
- If only _new_ documents were added (no modifications or removals), an optimistic catch-up is applied.
|
|
325
|
+
- Otherwise a full rebuild is performed.
|
|
326
|
+
|
|
327
|
+
### Remove
|
|
328
|
+
|
|
329
|
+
```js
|
|
330
|
+
await env.views.remove('orderStats');
|
|
331
|
+
|
|
332
|
+
// If the view owns an auto-created index, you must decide what happens to it:
|
|
333
|
+
await env.views.remove('orderStats', { managedIndexes: 'drop' }); // drop the index
|
|
334
|
+
await env.views.remove('orderStats', { managedIndexes: 'keep' }); // retain the index
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## Custom reducers
|
|
340
|
+
|
|
341
|
+
Register a custom reducer before creating a view that uses it. Custom reducer names must start with `$`.
|
|
342
|
+
|
|
343
|
+
```js
|
|
344
|
+
env.views.registerReducer('$product', {
|
|
345
|
+
apply(state, before, after, opts) {
|
|
346
|
+
// state — current accumulated value (null on first call)
|
|
347
|
+
// before — previous doc (null for inserts), post-filter, post-map
|
|
348
|
+
// after — new doc (null for deletes), post-filter, post-map
|
|
349
|
+
// opts — the value from the spec: { $product: opts }
|
|
350
|
+
state = state ?? 1;
|
|
351
|
+
if (before !== null && typeof before[opts] === 'number') state /= before[opts];
|
|
352
|
+
if (after !== null && typeof after[opts] === 'number') state *= after[opts];
|
|
353
|
+
return state;
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
await env.views.create('priceProduct', {
|
|
358
|
+
type: 'items',
|
|
359
|
+
reduce: { product: { $product: 'multiplier' } },
|
|
360
|
+
});
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## JavaScript API
|
|
366
|
+
|
|
367
|
+
All methods are on `env.views` (`OKDBEnv#views`):
|
|
368
|
+
|
|
369
|
+
```js
|
|
370
|
+
const env = db.env('myEnv');
|
|
371
|
+
|
|
372
|
+
// Create
|
|
373
|
+
await env.views.create(name, definition) // → { name }
|
|
374
|
+
|
|
375
|
+
// Read
|
|
376
|
+
env.views.get(name) // → output object | null (synchronous)
|
|
377
|
+
env.views.getMeta(name) // → { state, clock, error, refs } | null
|
|
378
|
+
await env.views.getDefinition(name) // → stored definition | null
|
|
379
|
+
await env.views.list() // → string[]
|
|
380
|
+
|
|
381
|
+
// Bucketing (bucketed views only)
|
|
382
|
+
env.views.range(name, options?) // → BucketEntry[] | null (synchronous)
|
|
383
|
+
env.views.listBuckets(name, options) // → { bucketKey, count }[] (synchronous)
|
|
384
|
+
|
|
385
|
+
// Lifecycle
|
|
386
|
+
await env.views.rebuild(name)
|
|
387
|
+
await env.views.stop(name)
|
|
388
|
+
await env.views.start(name)
|
|
389
|
+
await env.views.remove(name, options?)
|
|
390
|
+
|
|
391
|
+
// Extension
|
|
392
|
+
env.views.registerReducer(name, { apply })
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+
## HTTP API
|
|
398
|
+
|
|
399
|
+
Views are scoped per environment. The `{env}` segment is the environment name (e.g. `default`).
|
|
400
|
+
|
|
401
|
+
| Method | Path | Description |
|
|
402
|
+
| -------- | ---------------------------------------- | ------------------------------------------------ |
|
|
403
|
+
| `GET` | `/api/env/{env}/views` | List view names |
|
|
404
|
+
| `POST` | `/api/env/{env}/views` | Create a view |
|
|
405
|
+
| `GET` | `/api/env/{env}/views/{name}` | Get current view output |
|
|
406
|
+
| `GET` | `/api/env/{env}/views/{name}/meta` | Get view state/meta |
|
|
407
|
+
| `GET` | `/api/env/{env}/views/{name}/definition` | Get stored definition |
|
|
408
|
+
| `DELETE` | `/api/env/{env}/views/{name}` | Remove a view |
|
|
409
|
+
| `POST` | `/api/env/{env}/views/{name}/rebuild` | Rebuild from scratch |
|
|
410
|
+
| `POST` | `/api/env/{env}/views/{name}/stop` | Stop processing |
|
|
411
|
+
| `POST` | `/api/env/{env}/views/{name}/start` | Start / resume |
|
|
412
|
+
| `GET` | `/api/env/{env}/views/{name}/range` | Per-bucket aggregates (bucketed views only) |
|
|
413
|
+
| `GET` | `/api/env/{env}/views/{name}/buckets` | List populated bucket keys (bucketed views only) |
|
|
414
|
+
| `POST` | `/api/env/{env}/views/{name}/items` | Paginated source items for a view or sub-scope |
|
|
415
|
+
|
|
416
|
+
**Create body (standard view):**
|
|
417
|
+
|
|
418
|
+
```json
|
|
419
|
+
{
|
|
420
|
+
"name": "orderStats",
|
|
421
|
+
"type": "orders",
|
|
422
|
+
"filter": { "status": "completed" },
|
|
423
|
+
"reduce": {
|
|
424
|
+
"total": { "$sum": "amount" },
|
|
425
|
+
"count": { "$count": true }
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
**Create body (bucketed view):**
|
|
431
|
+
|
|
432
|
+
```json
|
|
433
|
+
{
|
|
434
|
+
"name": "salesByDay",
|
|
435
|
+
"type": "orders",
|
|
436
|
+
"filter": { "status": "completed" },
|
|
437
|
+
"reduce": {
|
|
438
|
+
"revenue": { "$sum": "amount" },
|
|
439
|
+
"count": { "$count": true }
|
|
440
|
+
},
|
|
441
|
+
"bucket": {
|
|
442
|
+
"field": "completedAt",
|
|
443
|
+
"preset": "time",
|
|
444
|
+
"granularity": "day"
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
**Delete with managed indexes:**
|
|
450
|
+
|
|
451
|
+
```json
|
|
452
|
+
{ "managedIndexes": "drop" }
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
## MCP
|
|
458
|
+
|
|
459
|
+
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`, `get_view_range`, `list_view_buckets`, `list_view_items`.
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## Views vs materializers
|
|
464
|
+
|
|
465
|
+
| | Views | Materializer engine |
|
|
466
|
+
| -------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
467
|
+
| **What it produces** | A single reduced document (aggregates) | A populated target collection (projections) |
|
|
468
|
+
| **Definition style** | Declarative (`reduce`, `filter`, `map`) | Imperative function returning ops |
|
|
469
|
+
| **Rebuild** | `views.rebuild(name)` | `engine.api.rebuild()` |
|
|
470
|
+
| **Access** | `env.views.get(name)` — always synchronous | Read the target type via `env.get(...)` |
|
|
471
|
+
| **Use when** | You need live counters, sums, averages, or group statistics | You need a derived collection with one document per source document |
|
|
472
|
+
|
|
473
|
+
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.
|
|
474
|
+
|
|
475
|
+
See [pipelines.md](./pipelines.md) for materializer documentation.
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## Sync behavior
|
|
480
|
+
|
|
481
|
+
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.
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
485
|
+
## Error codes
|
|
486
|
+
|
|
487
|
+
| Code | Cause |
|
|
488
|
+
| --------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
489
|
+
| `VIEW_INVALID_NAME` | Name is empty, contains whitespace, or is not a string |
|
|
490
|
+
| `VIEW_INVALID_DEFINITION` | Definition is not an object |
|
|
491
|
+
| `VIEW_MISSING_TYPE` | `type` field missing or not a string |
|
|
492
|
+
| `VIEW_MISSING_REDUCE` | `reduce` field missing or empty |
|
|
493
|
+
| `VIEW_TYPE_NOT_REGISTERED` | The source type does not exist in this environment |
|
|
494
|
+
| `VIEW_ALREADY_EXISTS` | A view with that name already exists |
|
|
495
|
+
| `VIEW_NOT_FOUND` | No view with that name |
|
|
496
|
+
| `VIEW_HAS_ORPHANED_INDEXES` | Removing the view would leave auto-created indexes with no owner; pass `managedIndexes` option |
|
|
497
|
+
| `VIEW_REF_UNSUPPORTED_REDUCER` | `$min`/`$max` used inside a `$ref` sub-view |
|
|
498
|
+
| `VIEW_INVALID_REDUCER_NAME` | Custom reducer name does not start with `$` |
|
|
499
|
+
| `VIEW_REDUCER_CONFLICT` | Custom reducer name conflicts with a built-in |
|
|
500
|
+
| `VIEW_INVALID_REDUCER` | Custom reducer missing an `apply` function |
|
|
501
|
+
| `BUCKET_CONFIG_INVALID` | `bucket` config is missing required fields, uses an unsupported preset, or has an invalid granularity |
|
|
502
|
+
| `BUCKET_PROJECT_NOT_SYNCABLE` | `bucket.project` is a function and cannot be used in a synced environment; use `preset:'time'` |
|
|
503
|
+
| `BUCKET_INVALID_TIME_VALUE` | A document's bucket field could not be parsed as a time value |
|
|
504
|
+
| `VIEW_NOT_BUCKETED` | `range()` or `listBuckets()` was called on a view that has no `bucket` config |
|
|
505
|
+
| `VIEW_RANGE_GRANULARITY_MISMATCH` | The `granularity` option does not match the view's configured granularity |
|
|
506
|
+
| `VIEW_RANGE_GRANULARITY_REQUIRED` | `listBuckets()` was called without a `granularity` option |
|
|
507
|
+
| `VIEW_ITEMS_SCOPE_INVALID` | `scope` is not one of `view`, `reducer`, `ref`, `ref-reducer` |
|
|
508
|
+
| `VIEW_ITEMS_REDUCER_NOT_FOUND` | The named reducer does not exist on the view |
|
|
509
|
+
| `VIEW_ITEMS_REF_NOT_FOUND` | The named `$ref` slot does not exist on the view |
|
|
510
|
+
| `VIEW_ITEMS_GROUP_NOT_FOUND` | The named group key does not exist on the `$countBy` reducer |
|
|
511
|
+
| `VIEW_ITEMS_NOT_AVAILABLE` | `items()` was requested but the reducer does not have `items: true` |
|
|
512
|
+
| `VIEW_ITEMS_LIMIT_INVALID` | `limit` is not a non-negative integer |
|
|
513
|
+
| `VIEW_ITEMS_OFFSET_INVALID` | `offset` is not a non-negative integer |
|