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