@owlmeans/state 0.1.18-rc.7 → 0.1.18-rc.9
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 +85 -61
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/state/SKILL.md +109 -56
- package/build/errors.d.ts +12 -5
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +16 -13
- package/build/errors.js.map +1 -1
- package/build/helper.d.ts +16 -0
- package/build/helper.d.ts.map +1 -0
- package/build/helper.js +14 -0
- package/build/helper.js.map +1 -0
- package/build/index.d.ts +2 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -2
- package/build/index.js.map +1 -1
- package/build/resource.d.ts +11 -4
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +296 -208
- package/build/resource.js.map +1 -1
- package/build/types.d.ts +90 -50
- package/build/types.d.ts.map +1 -1
- package/build/utils/model.d.ts +22 -2
- package/build/utils/model.d.ts.map +1 -1
- package/build/utils/model.js +26 -22
- package/build/utils/model.js.map +1 -1
- package/package.json +3 -3
- package/src/errors.ts +16 -13
- package/src/helper.ts +17 -0
- package/src/index.ts +2 -2
- package/src/resource.ts +361 -244
- package/src/types.ts +97 -55
- package/src/utils/model.ts +48 -28
- package/tests/resource.spec.ts +297 -90
- package/build/.gitkeep +0 -0
- package/build/consts.d.ts +0 -3
- package/build/consts.d.ts.map +0 -1
- package/build/consts.js +0 -3
- package/build/consts.js.map +0 -1
- package/build/utils/criteria.d.ts +0 -20
- package/build/utils/criteria.d.ts.map +0 -1
- package/build/utils/criteria.js +0 -252
- package/build/utils/criteria.js.map +0 -1
- package/build/utils/index.d.ts +0 -3
- package/build/utils/index.d.ts.map +0 -1
- package/build/utils/index.js +0 -3
- package/build/utils/index.js.map +0 -1
- package/src/consts.ts +0 -3
- package/src/utils/criteria.ts +0 -252
- package/src/utils/index.ts +0 -3
- package/tests/criteria.spec.ts +0 -121
package/README.md
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
# @owlmeans/state
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The framework's client store: an in-memory `Resource` with live subscriptions.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
- `
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
|
|
7
|
+
- `appendStateResource(context, alias, config?)` registers a store ON the context, so a screen, a
|
|
8
|
+
service and a guard all reach the same records through the same container
|
|
9
|
+
- Reads and writes are the ordinary `Resource<T>` vocabulary — `get`, `load`, `list`, `count`,
|
|
10
|
+
`create`, `update`, `save`, `delete`, `take`, `purge` — with the same criteria language the
|
|
11
|
+
server resources speak
|
|
12
|
+
- `watch` follows one record and `query` follows a live set; both hand their listener a value
|
|
13
|
+
synchronously, which is what lets React render from them without a loading frame
|
|
14
|
+
- A subscription READS the store. Watching an id the store knows nothing about creates nothing;
|
|
15
|
+
the model it answers with is `empty`
|
|
11
16
|
|
|
12
17
|
## Installation
|
|
13
18
|
|
|
@@ -17,91 +22,110 @@ bun add @owlmeans/state
|
|
|
17
22
|
|
|
18
23
|
## Usage
|
|
19
24
|
|
|
20
|
-
|
|
25
|
+
Register a store per record type, and name it once with the type attached:
|
|
21
26
|
|
|
22
27
|
```typescript
|
|
23
|
-
import {
|
|
28
|
+
import { appendStateResource, stateAlias } from '@owlmeans/state'
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
export const TASKS = stateAlias<Task>('tasks')
|
|
31
|
+
|
|
32
|
+
export const makeContext = <C extends Config, T extends Context<C>>(cfg: C): T => {
|
|
33
|
+
const context = makeClientContext<C, T>(cfg)
|
|
34
|
+
appendStateResource<C, T, Task>(context, TASKS)
|
|
35
|
+
|
|
36
|
+
return context
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const tasks = context.getStateResource(TASKS) // StateResource<Task>
|
|
27
40
|
```
|
|
28
41
|
|
|
29
|
-
|
|
42
|
+
Every client context already carries one default `state` resource, so a store is only registered
|
|
43
|
+
when records of different kinds must not share an id space.
|
|
44
|
+
|
|
45
|
+
Read it from React with the hooks in [`@owlmeans/client`](../client):
|
|
30
46
|
|
|
31
47
|
```typescript
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const [unsubscribe] = resource.subscribe({
|
|
38
|
-
id: DEFAULT_ID,
|
|
39
|
-
listener: (models) => {
|
|
40
|
-
const [model] = models
|
|
41
|
-
console.log('project updated:', model.record)
|
|
42
|
-
}
|
|
43
|
-
})
|
|
48
|
+
import { useStoreList, useStoreModel } from '@owlmeans/client'
|
|
49
|
+
|
|
50
|
+
const task = useStoreModel<Task>(id, 'tasks') // one record, live
|
|
51
|
+
const open = useStoreList<Task>({ query: { status: 'open' }, resource: 'tasks' })
|
|
44
52
|
```
|
|
45
53
|
|
|
46
|
-
|
|
54
|
+
Write through the resource, or through the model a subscription handed you:
|
|
47
55
|
|
|
48
56
|
```typescript
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
const tasks = context.getStateResource(TASKS)
|
|
58
|
+
|
|
59
|
+
await tasks.save(record) // create or replace
|
|
60
|
+
await tasks.replace(fromTheServer) // write these, drop everything else
|
|
61
|
+
await tasks.purge({ status: 'done' })
|
|
62
|
+
await tasks.clear()
|
|
63
|
+
|
|
64
|
+
model.update({ status: 'done' }) // merge and write in one step
|
|
52
65
|
```
|
|
53
66
|
|
|
54
67
|
## API
|
|
55
68
|
|
|
56
|
-
### `createStateResource<T>(alias?): StateResource<T>`
|
|
69
|
+
### `createStateResource<T>(alias?, config?): StateResource<T>`
|
|
57
70
|
|
|
58
|
-
|
|
71
|
+
The bare factory, when the resource is registered by hand. `appendStateResource` is the usual way.
|
|
59
72
|
|
|
60
|
-
### `
|
|
73
|
+
### `StateConfig<T>`
|
|
61
74
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
- `match(criteria?): Promise<T[]>` — the records the criteria accepts, as a plain array
|
|
68
|
-
- `list(criteria?, opts?)` — the `Resource` envelope `{ items, pager }`. Unpaged unless a pager is
|
|
69
|
-
given, so `list()` returns everything
|
|
75
|
+
| Field | Meaning |
|
|
76
|
+
|-------|---------|
|
|
77
|
+
| `id` | The field records are keyed by. Defaults to `id` |
|
|
78
|
+
| `single` | The resource holds exactly ONE record, which needs no id — the current user, the active session, a wizard being filled in |
|
|
79
|
+
| `default` | What `StateModel.record` shows while the model is empty |
|
|
70
80
|
|
|
71
|
-
###
|
|
81
|
+
### `StateResource<T>` (extends `Resource<T>`, `PubSubResource<StateEvent<T>>`)
|
|
72
82
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
- `replace(records)` — write every record given and drop every record the list does not name, which
|
|
84
|
+
is the shape of "the server just told us what exists"
|
|
85
|
+
- `clear()` — drop everything
|
|
86
|
+
- `watch(id, listener): () => void` — follow one record. `undefined` addresses the one record of a
|
|
87
|
+
`single` resource and throws `StateConfigError.NonSingle` on any other
|
|
88
|
+
- `query(where, listener, opts?): () => void` — follow a live set, re-evaluated on every write that
|
|
89
|
+
changes the answer. `undefined` matches everything
|
|
90
|
+
- `publish(event, channel?)` / `subscribe(handler, opts?)` — the change stream. Every write
|
|
91
|
+
announces itself as a `StateEvent` on the default channel
|
|
79
92
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
query: { status: 'open' },
|
|
84
|
-
listener: models => { /* re-runs on every write that changes the answer */ }
|
|
85
|
-
})
|
|
86
|
-
```
|
|
93
|
+
Reads are unpaged: `list()` returns the whole store, and `list(where, { page })` without a `size`
|
|
94
|
+
is refused rather than silently answering with everything. Writes take no `ttl` — nothing here
|
|
95
|
+
expires.
|
|
87
96
|
|
|
88
97
|
### `StateModel<T>`
|
|
89
98
|
|
|
90
|
-
- `
|
|
91
|
-
|
|
92
|
-
- `
|
|
93
|
-
- `
|
|
99
|
+
- `id` / `empty` / `record` — `empty` is what "nothing loaded yet" looks like; `record` is the
|
|
100
|
+
configured `default` while it is true
|
|
101
|
+
- `update(patch)` — merge and write in one step
|
|
102
|
+
- `commit()` — write what `record` currently holds, including a default not yet stored
|
|
103
|
+
- `clear()` — delete the record
|
|
94
104
|
|
|
95
|
-
|
|
105
|
+
`record` is a snapshot: assigning into it changes nothing anyone else can see. `update` is how a
|
|
106
|
+
change reaches the store and every other subscriber.
|
|
96
107
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
### `stateAlias<T>(alias)`
|
|
109
|
+
|
|
110
|
+
An alias that remembers the record type it addresses, so `getStateResource(TASKS)` is typed without
|
|
111
|
+
repeating `<Task>` at every call site. It is the plain string at runtime.
|
|
112
|
+
|
|
113
|
+
### `StateConfigError`
|
|
114
|
+
|
|
115
|
+
`NonSingle` — a record was addressed without an id on a resource that holds many.
|
|
116
|
+
`NoId` — a write carried no value for the id field, and nothing here mints one.
|
|
117
|
+
|
|
118
|
+
## Criteria
|
|
119
|
+
|
|
120
|
+
The criteria language, the operators and the in-memory engine (`matchCriteria`, `filterRecords`,
|
|
121
|
+
`sortRecords`, `firstMatch`, `applyQuery`) all live in
|
|
122
|
+
[`@owlmeans/resource`](../resource) — one filter object means the same thing whether it is
|
|
123
|
+
evaluated here or by a relational store.
|
|
100
124
|
|
|
101
125
|
## Related Packages
|
|
102
126
|
|
|
103
|
-
- [`@owlmeans/resource`](../resource) — `Resource<T>`
|
|
104
|
-
- [`@owlmeans/client`](../client) — `useStoreModel` / `useStoreList` React hooks
|
|
127
|
+
- [`@owlmeans/resource`](../resource) — the `Resource<T>` contract and the criteria engine
|
|
128
|
+
- [`@owlmeans/client`](../client) — `useStoreModel` / `useStoreList` React hooks
|
|
105
129
|
|
|
106
130
|
<!-- owlmeans:agent-guidance:start -->
|
|
107
131
|
## Agent guidance
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/state",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-
|
|
4
|
+
"version": "0.1.18-rc.9",
|
|
5
|
+
"generatedAt": "2026-09-01T16:28:56.989Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: state
|
|
3
|
-
description: How to use @owlmeans/state — appendStateResource() to register a client state resource on a context, useStoreModel/useStoreList to read it from React, live
|
|
3
|
+
description: How to use @owlmeans/state — appendStateResource() to register a client state resource on a context, useStoreModel/useStoreList to read it from React, watch/query live subscriptions, and the StateModel commit semantics. Auto-invoked when importing state primitives or building client-side application state.
|
|
4
4
|
user-invocable: false
|
|
5
5
|
---
|
|
6
6
|
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
|
|
@@ -8,22 +8,28 @@ user-invocable: false
|
|
|
8
8
|
# @owlmeans/state
|
|
9
9
|
|
|
10
10
|
**Layer:** Core
|
|
11
|
-
**Install:** `"@owlmeans/state": "^0.1.18-rc.
|
|
11
|
+
**Install:** `"@owlmeans/state": "^0.1.18-rc.9"` in `dependencies`
|
|
12
12
|
|
|
13
13
|
The framework's client store. A state resource is a `Resource` like any other, registered **on the
|
|
14
14
|
context** — which is what separates it from a store held beside the app: a screen, a service and a
|
|
15
|
-
guard all reach the same records through the same container.
|
|
15
|
+
guard all reach the same records through the same container. Reads and writes are the resource
|
|
16
|
+
vocabulary of [[resource]]; `watch` and `query` are the live half.
|
|
16
17
|
|
|
17
18
|
## Key Exports
|
|
18
19
|
|
|
19
20
|
| Export | Description |
|
|
20
21
|
|--------|-------------|
|
|
21
|
-
| `appendStateResource<C, T>(context, alias?)` | Register a state resource on the context |
|
|
22
|
-
| `createStateResource<T>(alias?)` | The bare factory, when you register it yourself |
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
22
|
+
| `appendStateResource<C, T, R>(context, alias?, cfg?)` | Register a state resource on the context |
|
|
23
|
+
| `createStateResource<T>(alias?, cfg?)` | The bare factory, when you register it yourself |
|
|
24
|
+
| `stateAlias<T>(alias)` | Name a store once with the record type attached — `StateAlias<T>` |
|
|
25
|
+
| `StateResource<T>` | The resource interface — full CRUD plus `replace`, `clear`, `watch`, `query`, `publish`/`subscribe` |
|
|
26
|
+
| `StateModel<T>` | The subscribed wrapper — `id`, `empty`, `record`, `update`, `commit`, `clear` |
|
|
27
|
+
| `StateConfig<T>` | How the store is keyed — `id`, `single`, `default` |
|
|
28
|
+
| `StateEvent<T>` | What a change looks like on the wire — `{ type: 'set' \| 'remove', records }` |
|
|
29
|
+
| `StateConfigError` | `NonSingle` (no id on a many-record store), `NoId` (a write with no key) |
|
|
30
|
+
|
|
31
|
+
The criteria evaluator (`matchCriteria`, `filterRecords`, `sortRecords`, `applyQuery`) lives in
|
|
32
|
+
**`@owlmeans/resource`** — the same engine the store runs on, for filtering a list you already hold.
|
|
27
33
|
|
|
28
34
|
The React hooks live in **`@owlmeans/client`** — `useStoreModel`, `useStoreList`. They are not
|
|
29
35
|
re-exported by `@owlmeans/web-client`, so import them from `@owlmeans/client` directly.
|
|
@@ -35,21 +41,31 @@ no setup at all. Register a named one per entity when records of different kinds
|
|
|
35
41
|
id space:
|
|
36
42
|
|
|
37
43
|
```typescript
|
|
38
|
-
import { appendStateResource } from '@owlmeans/state'
|
|
44
|
+
import { appendStateResource, stateAlias } from '@owlmeans/state'
|
|
39
45
|
|
|
40
|
-
export const
|
|
46
|
+
export const TASKS = stateAlias<Task>('task-state')
|
|
41
47
|
|
|
42
48
|
export const makeContext = <C extends Config, T extends Context<C>>(cfg: C): T => {
|
|
43
|
-
const context =
|
|
44
|
-
appendStateResource<C, T>(context,
|
|
45
|
-
// A child context must inherit THIS factory, or it is built without the resource above.
|
|
46
|
-
context.makeContext = makeContext as typeof context.makeContext
|
|
49
|
+
const context = makeClientContext<C, T>(cfg)
|
|
50
|
+
appendStateResource<C, T, Task>(context, TASKS)
|
|
47
51
|
return context
|
|
48
52
|
}
|
|
49
53
|
```
|
|
50
54
|
|
|
51
|
-
Reach it with `context.getStateResource
|
|
52
|
-
|
|
55
|
+
Reach it with `context.getStateResource(TASKS)` — a `StateAlias<T>` carries the record type, so the
|
|
56
|
+
accessor is typed without repeating `<Task>` at every call site. `getStateResource()` with no alias
|
|
57
|
+
answers the context's own default store (`state`), so always name the alias for a store you
|
|
58
|
+
appended. `appendStateResource` is idempotent: appending the same alias twice keeps the resource
|
|
59
|
+
already there, so a setup that runs more than once does not drop what the store has collected.
|
|
60
|
+
|
|
61
|
+
`StateConfig` decides how the store is keyed and what it shows before anything is loaded — all of it
|
|
62
|
+
optional:
|
|
63
|
+
|
|
64
|
+
| Field | Meaning |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `id` | The field records are keyed by. Defaults to `id`. |
|
|
67
|
+
| `single` | The store holds exactly ONE record, which therefore needs no id — the current user, the active session, a wizard being filled in. It is what makes `watch(undefined, …)` (and so `useStoreModel()` with no id) answerable. |
|
|
68
|
+
| `default` | `() => T` — what `model.record` shows while the model is empty. A screen binds to it instead of guarding every field, and the store still holds nothing. |
|
|
53
69
|
|
|
54
70
|
## Reading it from React
|
|
55
71
|
|
|
@@ -57,95 +73,132 @@ Reach it with `context.getStateResource<Task>(TASK_STATE)`. A typed accessor on
|
|
|
57
73
|
import { useStoreList, useStoreModel } from '@owlmeans/client'
|
|
58
74
|
|
|
59
75
|
// One record, by id. Re-renders whenever that record changes.
|
|
60
|
-
const task = useStoreModel<Task>(id,
|
|
76
|
+
const task = useStoreModel<Task>(id, TASKS)
|
|
61
77
|
task.record.title
|
|
62
78
|
|
|
63
79
|
// A LIVE QUERY. Re-renders whenever any write changes which records match.
|
|
64
|
-
const open = useStoreList<Task>({ query: { status: 'open' }, resource:
|
|
80
|
+
const open = useStoreList<Task>({ query: { status: 'open' }, resource: TASKS })
|
|
65
81
|
open.map(model => model.record.title)
|
|
66
82
|
|
|
67
|
-
// Everything in the store.
|
|
68
|
-
const all = useStoreList<Task>({
|
|
83
|
+
// Everything in the store, newest first.
|
|
84
|
+
const all = useStoreList<Task>({ sort: [{ field: 'createdAt', order: 'desc' }], resource: TASKS })
|
|
69
85
|
```
|
|
70
86
|
|
|
71
87
|
A query subscription creates nothing and re-evaluates on every create, update and delete — a list
|
|
72
88
|
screen never recomputes ids and never re-subscribes to keep up. The criteria object is compared by
|
|
73
|
-
content, so a filter that changes narrows the list.
|
|
89
|
+
content, so a filter that changes narrows the list. An omitted `query` matches everything.
|
|
74
90
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
91
|
+
**"Nothing loaded yet" is `model.empty`.** An id the store knows nothing about yields a model whose
|
|
92
|
+
`empty` is true, and nothing is written into the store on the way — so `useStoreModel` never throws
|
|
93
|
+
for missing data, and a screen bound to an unknown id does not put a blank row into every list
|
|
94
|
+
reading the same store:
|
|
79
95
|
|
|
80
96
|
```typescript
|
|
81
|
-
|
|
97
|
+
if (task.empty) {
|
|
98
|
+
return <Spinner/>
|
|
99
|
+
}
|
|
82
100
|
```
|
|
83
101
|
|
|
102
|
+
`model.record` is still readable while empty: it holds the resource's configured `default`, or `{}`
|
|
103
|
+
when there is none. Calling `model.update(...)` or `model.commit()` on an empty model writes it —
|
|
104
|
+
including the default it was showing.
|
|
105
|
+
|
|
106
|
+
`useStoreModel()` with no id addresses the one record of a `single` resource. On any other that is a
|
|
107
|
+
wiring mistake and throws `StateConfigError` (`NonSingle`).
|
|
108
|
+
|
|
84
109
|
## Writing to it
|
|
85
110
|
|
|
86
111
|
The server is the source of truth; the store is what the screen reads. Fetch, then write what came
|
|
87
112
|
back into the store, and let the subscriptions render it:
|
|
88
113
|
|
|
89
114
|
```typescript
|
|
90
|
-
const store = ctx.getStateResource
|
|
115
|
+
const store = ctx.getStateResource(TASKS)
|
|
91
116
|
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
await store.save(task)
|
|
95
|
-
}
|
|
117
|
+
const tasks = await ctx.entrypoint<ClientEntrypoint<Task[]>>(TASK_LIST).call()
|
|
118
|
+
await store.replace(tasks)
|
|
96
119
|
```
|
|
97
120
|
|
|
98
|
-
`
|
|
99
|
-
|
|
121
|
+
`replace(records)` makes the store agree with an authoritative list: every record given is written,
|
|
122
|
+
and every record the list does not name is dropped. That is the shape of "the server just told us
|
|
123
|
+
what exists" — saving each record one by one leaves the ones deleted elsewhere behind, and one
|
|
124
|
+
write wakes the subscribers once instead of once per record. The store is rewritten before anything
|
|
125
|
+
is told about it, so a subscriber never sees the half-applied set.
|
|
126
|
+
|
|
127
|
+
For single records: `save` creates or replaces, `create` refuses an id already there, `update`
|
|
128
|
+
requires the record to exist, `delete(id)` removes it and answers with what it removed, `take(id)`
|
|
129
|
+
is the same read but throws when the record is absent, `purge(where)` bulk-deletes (and refuses an
|
|
130
|
+
empty criteria object rather than emptying the store), and `clear()` drops everything. Each one
|
|
131
|
+
notifies every subscriber that cares.
|
|
132
|
+
|
|
133
|
+
A write carrying no value for the key field throws `StateConfigError` (`NoId`) — nothing here mints
|
|
134
|
+
ids. A write carrying a `ttl` throws `UnsupportedArgumentError`: the store keeps no expiring
|
|
135
|
+
records, so a ttl would be silently dropped.
|
|
100
136
|
|
|
101
137
|
### The commit rule
|
|
102
138
|
|
|
103
|
-
`StateModel.record` is a
|
|
139
|
+
`StateModel.record` is a SNAPSHOT. Assigning to it changes nothing anyone else can see:
|
|
104
140
|
|
|
105
141
|
```typescript
|
|
106
|
-
model.record.title = 'renamed'
|
|
107
|
-
model.update({ title: 'renamed' }) // RIGHT — merges and commits
|
|
142
|
+
model.record.title = 'renamed' // WRONG — a silent no-op, nothing re-renders
|
|
143
|
+
await model.update({ title: 'renamed' }) // RIGHT — merges and commits
|
|
108
144
|
```
|
|
109
145
|
|
|
110
|
-
`update(
|
|
111
|
-
|
|
112
|
-
|
|
146
|
+
`update(patch)` merges and commits in one step — batch several fields into one patch rather than
|
|
147
|
+
writing them one at a time. `commit()` writes what `record` currently holds, which is how an empty
|
|
148
|
+
model bound to a `default` is persisted as it stands. `clear()` deletes the record and leaves the
|
|
149
|
+
model empty again. The working copy is replaced rather than mutated on every write, so the record a
|
|
150
|
+
caller is holding never changes underneath it and two models of the same record stay comparable by
|
|
151
|
+
reference — which is what lets a React subscriber tell a real change from an unrelated one.
|
|
113
152
|
|
|
114
153
|
## Querying
|
|
115
154
|
|
|
116
|
-
|
|
117
|
-
|
|
155
|
+
Reads take the same criteria language as the server resources, so a filter written for an endpoint
|
|
156
|
+
means the same thing applied locally:
|
|
118
157
|
|
|
119
158
|
```typescript
|
|
120
|
-
await store.
|
|
121
|
-
await store.
|
|
122
|
-
await store.list({ status: ['open', 'blocked'] })
|
|
123
|
-
await store.list({
|
|
159
|
+
await store.get(id) // the record, or UnknownRecordError
|
|
160
|
+
await store.load({ status: 'open' }) // the first match, or null
|
|
161
|
+
await store.list({ status: ['open', 'blocked'] }) // { items, total }
|
|
162
|
+
await store.list({ status: 'open' }, { sort: ['createdAt'], size: 20 })
|
|
163
|
+
await store.count({ status: 'open' })
|
|
124
164
|
```
|
|
125
165
|
|
|
126
166
|
- A bare value is equality; a bare **array means "any of these"**.
|
|
127
167
|
- Operators: `$eq $ne $gt $gte $lt $lte $in $nin $exists $null $like $ilike $regex $startsWith
|
|
128
168
|
$endsWith $between $contains $contained $overlaps`, and `$and $or $not` to combine.
|
|
129
169
|
- A dotted key reaches into the record (`'owner.team'`).
|
|
130
|
-
-
|
|
131
|
-
|
|
132
|
-
|
|
170
|
+
- `null` matches absence; a criteria value of `undefined` is SKIPPED — an untouched filter must not
|
|
171
|
+
empty the list.
|
|
172
|
+
- `Sort<T>` is a field name (ascending) or `{ field, order: 'asc' | 'desc' }`.
|
|
133
173
|
|
|
134
|
-
`
|
|
135
|
-
|
|
174
|
+
`list()` returns `{ items, total }` and is **unpaged**: the store is already in memory and a screen
|
|
175
|
+
reading it expects all of it. Ask for a `size` to page, and `size: 0` still means no limit. A `page`
|
|
176
|
+
with no `size` throws `UnsupportedArgumentError('page-without-size')` — there is no default page
|
|
177
|
+
size to count against.
|
|
136
178
|
|
|
137
179
|
## Subscribing outside React
|
|
138
180
|
|
|
139
181
|
```typescript
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
182
|
+
const stopOne = store.watch(id, model => { … }) // one record
|
|
183
|
+
const stopMany = store.query({ status: 'open' }, models => { … }) // a live query
|
|
184
|
+
const stopAll = store.query(undefined, models => { … }, { sort: ['createdAt'] })
|
|
143
185
|
```
|
|
144
186
|
|
|
145
|
-
|
|
187
|
+
Both are **synchronous** and both are seeded before they return: the listener is called with the
|
|
188
|
+
current value straight away, then again on every change — including a removal, which reaches a
|
|
189
|
+
`watch` listener as an empty model. Each returns its unsubscribe.
|
|
190
|
+
|
|
191
|
+
Writes announce themselves on the default channel, so `publish` is for what the store cannot know
|
|
192
|
+
it did — a change that arrived from elsewhere, or a channel of a caller's own:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
const stop = await store.subscribe(event => { … }) // every write: StateEvent<T>
|
|
196
|
+
await store.publish({ type: 'set', records: [task] }, 'from-socket')
|
|
197
|
+
const once = await store.subscribe(handler, { channel: 'from-socket', once: true, ttl: 60 })
|
|
198
|
+
```
|
|
146
199
|
|
|
147
200
|
## Depends On
|
|
148
201
|
|
|
149
|
-
- `@owlmeans/resource` — `StateResource` extends `Resource`
|
|
202
|
+
- `@owlmeans/resource` — `StateResource` extends `Resource` and `PubSubResource`
|
|
150
203
|
- `@owlmeans/context` — for `getStateResource`
|
|
151
204
|
- React hooks: `@owlmeans/client`
|
package/build/errors.d.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { ResourceError } from '@owlmeans/resource';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export declare class
|
|
2
|
+
/**
|
|
3
|
+
* The resource was asked for something its {@link StateConfig} does not allow. Both cases are
|
|
4
|
+
* wiring mistakes rather than missing data, so they throw instead of answering with nothing.
|
|
5
|
+
*/
|
|
6
|
+
export declare class StateConfigError extends ResourceError {
|
|
7
7
|
static typeName: string;
|
|
8
|
+
/**
|
|
9
|
+
* A record was addressed without an id on a resource that holds many of them. Only a `single`
|
|
10
|
+
* resource has a record that needs no naming.
|
|
11
|
+
*/
|
|
12
|
+
static readonly NonSingle: string;
|
|
13
|
+
/** A write carried no value for the resource's id field, and nothing here mints one. */
|
|
14
|
+
static readonly NoId: string;
|
|
8
15
|
constructor(msg: string);
|
|
9
16
|
}
|
|
10
17
|
//# sourceMappingURL=errors.d.ts.map
|
package/build/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAElD,qBAAa,
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAElD;;;GAGG;AACH,qBAAa,gBAAiB,SAAQ,aAAa;IACjD,OAAuB,QAAQ,SAAyC;IAExE;;;OAGG;IACH,gBAAuB,SAAS,EAAE,MAAM,CAAe;IAEvD,wFAAwF;IACxF,gBAAuB,IAAI,EAAE,MAAM,CAAU;gBAEjC,GAAG,EAAE,MAAM;CAIxB"}
|
package/build/errors.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { ResourceError } from '@owlmeans/resource';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The resource was asked for something its {@link StateConfig} does not allow. Both cases are
|
|
4
|
+
* wiring mistakes rather than missing data, so they throw instead of answering with nothing.
|
|
5
|
+
*/
|
|
6
|
+
export class StateConfigError extends ResourceError {
|
|
7
|
+
static typeName = `${ResourceError.typeName}StateConfig`;
|
|
8
|
+
/**
|
|
9
|
+
* A record was addressed without an id on a resource that holds many of them. Only a `single`
|
|
10
|
+
* resource has a record that needs no naming.
|
|
11
|
+
*/
|
|
12
|
+
static NonSingle = 'non-single';
|
|
13
|
+
/** A write carried no value for the resource's id field, and nothing here mints one. */
|
|
14
|
+
static NoId = 'no-id';
|
|
4
15
|
constructor(msg) {
|
|
5
|
-
super(`
|
|
6
|
-
this.type =
|
|
16
|
+
super(`state-config:${msg}`);
|
|
17
|
+
this.type = StateConfigError.typeName;
|
|
7
18
|
}
|
|
8
19
|
}
|
|
9
|
-
|
|
10
|
-
static typeName = `${StateToolingError.typeName}Listener`;
|
|
11
|
-
constructor(msg) {
|
|
12
|
-
super(`listener:${msg}`);
|
|
13
|
-
this.type = StateListenerError.typeName;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
ResourceError.registerErrorClass(StateToolingError);
|
|
17
|
-
ResourceError.registerErrorClass(StateListenerError);
|
|
20
|
+
ResourceError.registerErrorClass(StateConfigError);
|
|
18
21
|
//# sourceMappingURL=errors.js.map
|
package/build/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAElD,MAAM,OAAO,
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAElD;;;GAGG;AACH,MAAM,OAAO,gBAAiB,SAAQ,aAAa;IAC1C,MAAM,CAAU,QAAQ,GAAG,GAAG,aAAa,CAAC,QAAQ,aAAa,CAAA;IAExE;;;OAGG;IACI,MAAM,CAAU,SAAS,GAAW,YAAY,CAAA;IAEvD,wFAAwF;IACjF,MAAM,CAAU,IAAI,GAAW,OAAO,CAAA;IAE7C,YAAY,GAAW;QACrB,KAAK,CAAC,gBAAgB,GAAG,EAAE,CAAC,CAAA;QAC5B,IAAI,CAAC,IAAI,GAAG,gBAAgB,CAAC,QAAQ,CAAA;IACvC,CAAC;;AAGH,aAAa,CAAC,kBAAkB,CAAC,gBAAgB,CAAC,CAAA"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ResourceRecord } from '@owlmeans/resource';
|
|
2
|
+
import type { StateAlias } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Name a state resource once, with the record type it holds attached:
|
|
5
|
+
*
|
|
6
|
+
* ```typescript
|
|
7
|
+
* export const TASKS = stateAlias<Task>('tasks')
|
|
8
|
+
* const tasks = context.getStateResource(TASKS) // StateResource<Task>
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* The handle is the string itself at runtime — the type rides along only so that every reader of
|
|
12
|
+
* the alias gets the record type without repeating it, and so that a mismatch is a compile error
|
|
13
|
+
* instead of a record shaped like nothing anyone expected.
|
|
14
|
+
*/
|
|
15
|
+
export declare const stateAlias: <T extends ResourceRecord>(alias: string) => StateAlias<T>;
|
|
16
|
+
//# sourceMappingURL=helper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"helper.d.ts","sourceRoot":"","sources":["../src/helper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AACxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,SAAS,cAAc,EAAE,OAAO,MAAM,KAAG,UAAU,CAAC,CAAC,CACzD,CAAA"}
|
package/build/helper.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Name a state resource once, with the record type it holds attached:
|
|
3
|
+
*
|
|
4
|
+
* ```typescript
|
|
5
|
+
* export const TASKS = stateAlias<Task>('tasks')
|
|
6
|
+
* const tasks = context.getStateResource(TASKS) // StateResource<Task>
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* The handle is the string itself at runtime — the type rides along only so that every reader of
|
|
10
|
+
* the alias gets the record type without repeating it, and so that a mismatch is a compile error
|
|
11
|
+
* instead of a record shaped like nothing anyone expected.
|
|
12
|
+
*/
|
|
13
|
+
export const stateAlias = (alias) => alias;
|
|
14
|
+
//# sourceMappingURL=helper.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"helper.js","sourceRoot":"","sources":["../src/helper.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,CAA2B,KAAa,EAAiB,EAAE,CACnF,KAAsB,CAAA"}
|
package/build/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type * from './types.js';
|
|
2
|
-
export * from './consts.js';
|
|
3
2
|
export * from './errors.js';
|
|
3
|
+
export * from './helper.js';
|
|
4
4
|
export * from './resource.js';
|
|
5
|
-
export * from './utils/
|
|
5
|
+
export * from './utils/model.js';
|
|
6
6
|
//# sourceMappingURL=index.d.ts.map
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,mBAAmB,YAAY,CAAA;AAE/B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,mBAAmB,YAAY,CAAA;AAE/B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,kBAAkB,CAAA"}
|
package/build/index.js
CHANGED
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,kBAAkB,CAAA"}
|