@owlmeans/state 0.1.18-rc.2 → 0.1.18-rc.20
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 +175 -48
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/state/SKILL.md +202 -19
- 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 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -1
- 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 +332 -151
- package/build/resource.js.map +1 -1
- package/build/types.d.ts +98 -29
- 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 +5 -4
- package/src/errors.ts +16 -13
- package/src/helper.ts +17 -0
- package/src/index.ts +2 -1
- package/src/resource.ts +407 -166
- package/src/types.ts +103 -30
- package/src/utils/model.ts +48 -28
- package/tests/resource.spec.ts +419 -0
- package/tsconfig.json +6 -1
- 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/index.d.ts +0 -2
- package/build/utils/index.d.ts.map +0 -1
- package/build/utils/index.js +0 -2
- package/build/utils/index.js.map +0 -1
- package/src/consts.ts +0 -3
- package/src/utils/index.ts +0 -2
package/README.md
CHANGED
|
@@ -1,85 +1,212 @@
|
|
|
1
1
|
# @owlmeans/state
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
- Used on the client to hold UI state (projects, stories, thinking journal entries) as reactive records
|
|
10
|
-
- `DEFAULT_ID` (`'_default'`) is the conventional ID for single-record resources
|
|
3
|
+
The framework's client store: an in-memory `Resource` with live subscriptions, registered on the
|
|
4
|
+
client context. Use it for records a screen binds to: the projects list, the current user, a wizard
|
|
5
|
+
draft, optimistic updates, a socket feed being rendered. It is a store, not a security boundary,
|
|
6
|
+
and it does not survive a reload. Data that must outlive the tab goes in
|
|
7
|
+
[`@owlmeans/client-resource`](../client-resource) (IndexedDB through `web-db`). The truth stays on
|
|
8
|
+
the server, behind entrypoints and a database resource.
|
|
11
9
|
|
|
12
10
|
## Installation
|
|
13
11
|
|
|
14
12
|
```bash
|
|
15
|
-
bun add @owlmeans/state
|
|
13
|
+
bun add @owlmeans/state@^0.1.18-rc.19
|
|
16
14
|
```
|
|
17
15
|
|
|
16
|
+
## Concepts
|
|
17
|
+
|
|
18
|
+
- **State resource**: a `StateResource<T>` registered with `appendStateResource(context, alias,
|
|
19
|
+
config?)`. It uses the full `Resource<T>` vocabulary (`get`, `load`, `list`, `count`, `create`,
|
|
20
|
+
`update`, `save`, `delete`, `take`, `purge`) plus `replace` and `clear`. Every client context
|
|
21
|
+
already carries a default one (`state`).
|
|
22
|
+
- **Typed alias**: `stateAlias<T>('tasks')` is a plain string at runtime that carries the record
|
|
23
|
+
type, so `context.getStateResource(TASKS)` is typed without repeating `<Task>`.
|
|
24
|
+
- **Model**: a `StateModel<T>` wraps one record. `empty` means "nothing loaded yet", `record` is a
|
|
25
|
+
read-only snapshot (the configured default while empty), and `update(patch)` merges and writes.
|
|
26
|
+
- **Live subscriptions**: `watch(id, listener)` follows one record, and `query(where, listener,
|
|
27
|
+
opts?)` follows a live set. Both are synchronous and call the listener with the current value
|
|
28
|
+
before they return, which lets React render without a loading frame.
|
|
29
|
+
- **Single store**: `{ single: true }` holds exactly one record that needs no id, for things like
|
|
30
|
+
the current account or the active wizard.
|
|
31
|
+
|
|
18
32
|
## Usage
|
|
19
33
|
|
|
20
|
-
|
|
34
|
+
### Register stores in the context factory
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { appendStateResource, stateAlias } from '@owlmeans/state'
|
|
21
38
|
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
export const PROJECTS = stateAlias<Project>('project-state')
|
|
40
|
+
export const STORIES = stateAlias<Story>('story-state')
|
|
24
41
|
|
|
25
|
-
|
|
26
|
-
context
|
|
42
|
+
export const makeContext = <C extends Config, T extends Context<C>>(cfg: C): T => {
|
|
43
|
+
const context = makeBasicContext<C, T>(cfg)
|
|
44
|
+
appendStateResource<C, T, Project>(context, PROJECTS)
|
|
45
|
+
appendStateResource<C, T, Story>(context, STORIES)
|
|
46
|
+
|
|
47
|
+
context.projectStore = () => context.getStateResource(PROJECTS)
|
|
48
|
+
|
|
49
|
+
return context
|
|
50
|
+
}
|
|
27
51
|
```
|
|
28
52
|
|
|
29
|
-
|
|
53
|
+
`appendStateResource` is idempotent: appending an alias that already exists keeps the resource and
|
|
54
|
+
what it has collected. The `projectStore` accessor is an app-level convenience declared on the
|
|
55
|
+
app's own `Context` type.
|
|
30
56
|
|
|
31
|
-
|
|
32
|
-
import { DEFAULT_ID } from '@owlmeans/state'
|
|
33
|
-
import type { StateModel, StateResource } from '@owlmeans/state'
|
|
57
|
+
### Fetch, then write what the server answered
|
|
34
58
|
|
|
35
|
-
|
|
59
|
+
```ts
|
|
60
|
+
const store = context.getStateResource(PROJECTS)
|
|
36
61
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
62
|
+
const { items } = await context.entrypoint(appProtocols.project.list).call({ query: { size: 50 } })
|
|
63
|
+
await store.replace(items) // write these, drop every other record
|
|
64
|
+
|
|
65
|
+
const project = await context.entrypoint(appProtocols.project.get).call({ params: { id } })
|
|
66
|
+
await store.save(project) // create or replace one
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Read it from React
|
|
70
|
+
|
|
71
|
+
The hooks live in [`@owlmeans/client`](../client):
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
import { useStoreList, useStoreModel } from '@owlmeans/client'
|
|
75
|
+
|
|
76
|
+
export const useProjectState = (id?: string) => useStoreModel<Project>(id, PROJECTS)
|
|
77
|
+
|
|
78
|
+
export const ProjectCard: FC<{ id: string }> = ({ id }) => {
|
|
79
|
+
const project = useProjectState(id)
|
|
80
|
+
const stories = useStoreList<Story>({
|
|
81
|
+
query: { projectId: id, status: ['planned', 'active'] },
|
|
82
|
+
sort: [{ field: 'createdAt', order: 'desc' }],
|
|
83
|
+
resource: STORIES
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (project.empty) {
|
|
87
|
+
return <Spinner />
|
|
42
88
|
}
|
|
89
|
+
|
|
90
|
+
return <Card title={project.record.title} count={stories.length}
|
|
91
|
+
onRename={title => project.update({ title })} />
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### A single-record store with a default
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
export const DRAFT = stateAlias<InvoiceDraft>('invoice-draft')
|
|
99
|
+
|
|
100
|
+
appendStateResource<C, T, InvoiceDraft>(context, DRAFT, {
|
|
101
|
+
single: true,
|
|
102
|
+
default: () => ({ customerId: '', lines: [], currency: 'EUR' })
|
|
43
103
|
})
|
|
104
|
+
|
|
105
|
+
const draft = useStoreModel<InvoiceDraft>(undefined, DRAFT) // no id: the sole record
|
|
106
|
+
await draft.update({ customerId }) // persists default + patch
|
|
107
|
+
await draft.clear() // back to empty after submit
|
|
44
108
|
```
|
|
45
109
|
|
|
46
|
-
|
|
110
|
+
### Fold a live feed into the store outside React
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const store = context.getStateResource(STORIES)
|
|
114
|
+
|
|
115
|
+
const stopQuery = store.query({ status: 'active' }, models => {
|
|
116
|
+
badge.set(models.length) // called now, then on every change to the set
|
|
117
|
+
})
|
|
47
118
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
119
|
+
socket.on('story', async (event: StoryEvent) => {
|
|
120
|
+
if (event.type === 'removed') {
|
|
121
|
+
await store.delete(event.id)
|
|
122
|
+
} else {
|
|
123
|
+
await store.save(event.story)
|
|
124
|
+
}
|
|
125
|
+
})
|
|
52
126
|
```
|
|
53
127
|
|
|
54
128
|
## API
|
|
55
129
|
|
|
56
|
-
### `
|
|
130
|
+
### `appendStateResource<C, T, R>(context, alias?, config?): T & StateResourceAppend`
|
|
57
131
|
|
|
58
|
-
|
|
132
|
+
Registers a state resource on the context (unless the alias is already registered) and installs
|
|
133
|
+
`getStateResource`. Without an alias it registers the default `state` store.
|
|
59
134
|
|
|
60
|
-
### `
|
|
135
|
+
### `createStateResource<T>(alias?, config?): StateResource<T>`
|
|
61
136
|
|
|
62
|
-
|
|
63
|
-
- `listen(listener)` — global listener for any change in the resource
|
|
64
|
-
- `erase()` — clear all records
|
|
137
|
+
The bare factory, for registering the resource by hand. `appendStateResource` is the usual way.
|
|
65
138
|
|
|
66
|
-
### `
|
|
139
|
+
### `StateConfig<T>`
|
|
67
140
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
141
|
+
| Field | Meaning |
|
|
142
|
+
|-------|---------|
|
|
143
|
+
| `id` | The field records are keyed by. Defaults to `id` |
|
|
144
|
+
| `single` | The resource holds exactly ONE record, which needs no id: the current user, the active session, a wizard being filled in |
|
|
145
|
+
| `default` | `() => T`, what `StateModel.record` shows while the model is empty |
|
|
72
146
|
|
|
73
|
-
### `
|
|
147
|
+
### `StateResource<T>` (extends `Resource<T>`, `PubSubResource<StateEvent<T>>`)
|
|
74
148
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
149
|
+
- `config`, the `StateConfig` it was created with
|
|
150
|
+
- `replace(records)`, which writes every record given and drops every record the list does not
|
|
151
|
+
name. This is the shape of "the server just told us what exists", and subscribers wake once
|
|
152
|
+
- `clear()`, which drops everything
|
|
153
|
+
- `watch(id, listener): () => void`, which follows one record. An absent id on a listed store
|
|
154
|
+
reports an empty model; on a `single` store it addresses the sole record
|
|
155
|
+
- `query(where, listener, opts?): () => void`, which follows a live set, re-evaluated on every
|
|
156
|
+
write that changes the answer. `undefined` matches everything; `opts.sort` orders it
|
|
157
|
+
- `publish(event, channel?)` / `subscribe(handler, opts?)`, the change stream. Every write
|
|
158
|
+
announces itself as a `StateEvent` on the default channel
|
|
159
|
+
|
|
160
|
+
Reads are unpaged: `list()` returns the whole store, and `list(where, { page })` without a `size`
|
|
161
|
+
is refused rather than silently answering with everything. Writes take no `ttl`, since nothing here
|
|
162
|
+
expires. The criteria language is the one from [`@owlmeans/resource`](../resource), including
|
|
163
|
+
dotted keys into nested fields.
|
|
78
164
|
|
|
79
|
-
|
|
165
|
+
### `StateModel<T>`
|
|
80
166
|
|
|
81
|
-
-
|
|
82
|
-
|
|
167
|
+
- `id` / `empty` / `record`: `empty` is what "nothing loaded yet" looks like; `record` is the
|
|
168
|
+
configured `default` while it is true
|
|
169
|
+
- `update(patch)`, which merges and writes in one step
|
|
170
|
+
- `commit()`, which writes what `record` currently holds, including a default not yet stored
|
|
171
|
+
- `clear()`, which deletes the record
|
|
172
|
+
|
|
173
|
+
### Exports
|
|
174
|
+
|
|
175
|
+
| Symbol | Kind | Purpose |
|
|
176
|
+
|---|---|---|
|
|
177
|
+
| `appendStateResource` | function | Register a state resource on the context and install `getStateResource` |
|
|
178
|
+
| `createStateResource` | function | The bare factory |
|
|
179
|
+
| `stateAlias<T>(alias)` | function | An alias typed with its record |
|
|
180
|
+
| `createStateModel(binding)` | function | Wrap a record, or its absence, as a model for a store of your own |
|
|
181
|
+
| `StateResource<T>` | type | The resource interface |
|
|
182
|
+
| `StateModel<T>`, `StateModelBinding<T>` | type | The model and what `createStateModel` binds to |
|
|
183
|
+
| `StateConfig<T>`, `StateEvent<T>`, `StateAlias<T>` | type | Keying config, change event `{ type: 'set' \| 'remove', records }`, typed alias |
|
|
184
|
+
| `StateResourceAppend`, `GetStateResource` | type | The `getStateResource` mixin |
|
|
185
|
+
| `StateConfigError` | class | `NoId`: a write with no key value on a many-record store (`NonSingle` is declared beside it) |
|
|
186
|
+
|
|
187
|
+
## Common pitfalls
|
|
188
|
+
|
|
189
|
+
- **Assigning into `model.record`.** It does not reach subscribers and nothing re-renders. Write
|
|
190
|
+
with `model.update({ ... })`.
|
|
191
|
+
- **Saving a server list record by record.** Records deleted elsewhere stay behind and subscribers
|
|
192
|
+
wake once per record. Use `replace(records)`.
|
|
193
|
+
- **Treating an empty model as an error.** An unknown or not-yet-known id yields `empty: true` and
|
|
194
|
+
writes nothing, so render a loading state.
|
|
195
|
+
- **Calling `getStateResource()` with no alias for a store you appended.** Without an alias it
|
|
196
|
+
answers the default store. Always pass the alias.
|
|
197
|
+
- **Writing with no id on a many-record store.** It throws `StateConfigError` (`NoId`), because
|
|
198
|
+
nothing here mints ids.
|
|
199
|
+
- **Passing `{ ttl }`**, or expecting data to survive a reload. Use `client-resource` for that.
|
|
200
|
+
- **Trusting the store on the server side.** The server re-validates everything that arrives.
|
|
201
|
+
- **Importing the React hooks from `@owlmeans/web-client`.** They are only exported by
|
|
202
|
+
`@owlmeans/client`.
|
|
203
|
+
|
|
204
|
+
## Related packages
|
|
205
|
+
|
|
206
|
+
- [`@owlmeans/resource`](../resource): the `Resource<T>` contract and the criteria engine
|
|
207
|
+
- [`@owlmeans/client`](../client): `useStoreModel` / `useStoreList` React hooks
|
|
208
|
+
- [`@owlmeans/client-resource`](../client-resource): the client store that survives a reload
|
|
209
|
+
- [`@owlmeans/client-job`](../client-job): a worked example, a socket feed folded into a state resource
|
|
83
210
|
|
|
84
211
|
<!-- owlmeans:agent-guidance:start -->
|
|
85
212
|
## Agent guidance
|
|
@@ -89,7 +216,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
89
216
|
your project's skill store (`.agents/skills/`):
|
|
90
217
|
|
|
91
218
|
```sh
|
|
92
|
-
npx @owlmeans/agent-skills
|
|
219
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.22
|
|
93
220
|
```
|
|
94
221
|
|
|
95
222
|
The embedded files are version-matched to this package release. Do not edit them
|
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.20",
|
|
5
|
+
"generatedAt": "2026-09-15T12:38:15.184Z",
|
|
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 state resource on a context,
|
|
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,40 +8,223 @@ 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.20"` in `dependencies`
|
|
12
|
+
|
|
13
|
+
The framework's client store. A state resource is a `Resource` like any other, registered **on the
|
|
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. Reads and writes are the resource
|
|
16
|
+
vocabulary of [[resource]]; `watch` and `query` are the live half.
|
|
12
17
|
|
|
13
18
|
## Key Exports
|
|
14
19
|
|
|
15
20
|
| Export | Description |
|
|
16
21
|
|--------|-------------|
|
|
17
|
-
| `appendStateResource<C, T>(context, alias)` | Register a state resource on the context |
|
|
18
|
-
| `
|
|
19
|
-
|
|
|
20
|
-
|
|
|
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`. Readable back as `resource.config` |
|
|
28
|
+
| `StateEvent<T>` | What a change looks like on the wire — `{ type: 'set' \| 'remove', records }` |
|
|
29
|
+
| `createStateModel(binding)` / `StateModelBinding<T>` | Wrap a record — or its absence — as a model, for a store of your own |
|
|
30
|
+
| `StateResourceAppend` / `GetStateResource` | The `getStateResource` mixin `appendStateResource` installs |
|
|
31
|
+
| `StateConfigError` | `NoId` — a write with no value for the key field on a store that holds many records. `NonSingle` is declared beside it for an id-less address on a many-record store, but every such path either answers an empty model (`watch`) or raises `NoId`, so `NoId` is the one a caller meets |
|
|
32
|
+
|
|
33
|
+
The criteria evaluator (`matchCriteria`, `filterRecords`, `sortRecords`, `applyQuery`) lives in
|
|
34
|
+
**`@owlmeans/resource`** — the same engine the store runs on, for filtering a list you already hold.
|
|
21
35
|
|
|
22
|
-
|
|
36
|
+
The React hooks live in **`@owlmeans/client`** — `useStoreModel`, `useStoreList`. They are not
|
|
37
|
+
re-exported by `@owlmeans/web-client`, so import them from `@owlmeans/client` directly.
|
|
23
38
|
|
|
24
|
-
|
|
39
|
+
## Registering
|
|
40
|
+
|
|
41
|
+
Every client context already carries one default state resource, so `useStoreModel(id)` works with
|
|
42
|
+
no setup at all. Register a named one per entity when records of different kinds must not share an
|
|
43
|
+
id space:
|
|
25
44
|
|
|
26
45
|
```typescript
|
|
27
|
-
import { appendStateResource } from '@owlmeans/state'
|
|
46
|
+
import { appendStateResource, stateAlias } from '@owlmeans/state'
|
|
28
47
|
|
|
29
|
-
export const
|
|
48
|
+
export const TASKS = stateAlias<Task>('task-state')
|
|
30
49
|
|
|
31
50
|
export const makeContext = <C extends Config, T extends Context<C>>(cfg: C): T => {
|
|
32
|
-
const context =
|
|
33
|
-
appendStateResource<C, T>(context,
|
|
34
|
-
context.projectStore = () => context.getStateResource(VIB_PROJECT_STATE)
|
|
51
|
+
const context = makeClientContext<C, T>(cfg)
|
|
52
|
+
appendStateResource<C, T, Task>(context, TASKS)
|
|
35
53
|
return context
|
|
36
54
|
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Reach it with `context.getStateResource(TASKS)` — a `StateAlias<T>` carries the record type, so the
|
|
58
|
+
accessor is typed without repeating `<Task>` at every call site. `getStateResource()` with no alias
|
|
59
|
+
answers the context's own default store (`state`), so always name the alias for a store you
|
|
60
|
+
appended. `appendStateResource` is idempotent: appending the same alias twice keeps the resource
|
|
61
|
+
already there, so a setup that runs more than once does not drop what the store has collected.
|
|
62
|
+
|
|
63
|
+
`StateConfig` decides how the store is keyed and what it shows before anything is loaded — all of it
|
|
64
|
+
optional:
|
|
65
|
+
|
|
66
|
+
| Field | Meaning |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `id` | The field records are keyed by. Defaults to `id`. |
|
|
69
|
+
| `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. |
|
|
70
|
+
| `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. |
|
|
71
|
+
|
|
72
|
+
## Reading it from React
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
import { useStoreList, useStoreModel } from '@owlmeans/client'
|
|
76
|
+
|
|
77
|
+
// One record, by id. Re-renders whenever that record changes.
|
|
78
|
+
const task = useStoreModel<Task>(id, TASKS)
|
|
79
|
+
task.record.title
|
|
80
|
+
|
|
81
|
+
// A LIVE QUERY. Re-renders whenever any write changes which records match.
|
|
82
|
+
const open = useStoreList<Task>({ query: { status: 'open' }, resource: TASKS })
|
|
83
|
+
open.map(model => model.record.title)
|
|
84
|
+
|
|
85
|
+
// Everything in the store, newest first.
|
|
86
|
+
const all = useStoreList<Task>({ sort: [{ field: 'createdAt', order: 'desc' }], resource: TASKS })
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A query subscription creates nothing and re-evaluates on every create, update and delete — a list
|
|
90
|
+
screen never recomputes ids and never re-subscribes to keep up. The criteria object is compared by
|
|
91
|
+
content, so a filter that changes narrows the list. An omitted `query` matches everything.
|
|
92
|
+
|
|
93
|
+
**"Nothing loaded yet" is `model.empty`.** An id the store knows nothing about yields a model whose
|
|
94
|
+
`empty` is true, and nothing is written into the store on the way — so `useStoreModel` never throws
|
|
95
|
+
for missing data, and a screen bound to an unknown id does not put a blank row into every list
|
|
96
|
+
reading the same store:
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
if (task.empty) {
|
|
100
|
+
return <Spinner/>
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`model.record` is still readable while empty: it holds the resource's configured `default`, or `{}`
|
|
105
|
+
when there is none. Calling `model.update(...)` or `model.commit()` on an empty model writes it —
|
|
106
|
+
including the default it was showing.
|
|
37
107
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
108
|
+
An ABSENT id answers the same way. A screen binds to `useStoreModel(project.record.id)` while the
|
|
109
|
+
project is still loading, so a missing id is a rendering state rather than a mistake: on a listed
|
|
110
|
+
store it watches nothing and reports an empty model, and on a `single` store it addresses that
|
|
111
|
+
store's sole record. The empty model it hands back is one shared instance, so a React subscriber
|
|
112
|
+
does not see a new value on every render — and writing through it throws `StateConfigError`
|
|
113
|
+
(`NoId`), because a caller writing with no id has lost track of which record it meant.
|
|
114
|
+
|
|
115
|
+
## Writing to it
|
|
116
|
+
|
|
117
|
+
The server is the source of truth; the store is what the screen reads. Fetch, then write what came
|
|
118
|
+
back into the store, and let the subscriptions render it:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
const store = ctx.getStateResource(TASKS)
|
|
122
|
+
|
|
123
|
+
const tasks = await ctx.entrypoint(taskEntrypoints.list).call()
|
|
124
|
+
await store.replace(tasks)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`replace(records)` makes the store agree with an authoritative list: every record given is written,
|
|
128
|
+
and every record the list does not name is dropped. That is the shape of "the server just told us
|
|
129
|
+
what exists" — saving each record one by one leaves the ones deleted elsewhere behind, and one
|
|
130
|
+
write wakes the subscribers once instead of once per record. The store is rewritten before anything
|
|
131
|
+
is told about it, so a subscriber never sees the half-applied set.
|
|
132
|
+
|
|
133
|
+
For single records: `save` creates or replaces, `create` refuses an id already there, `update`
|
|
134
|
+
requires the record to exist, `delete(id)` removes it and answers with what it removed, `take(id)`
|
|
135
|
+
is the same read but throws when the record is absent, `purge(where)` bulk-deletes (and refuses an
|
|
136
|
+
empty criteria object rather than emptying the store), and `clear()` drops everything. Each one
|
|
137
|
+
notifies every subscriber that cares.
|
|
138
|
+
|
|
139
|
+
On a store that holds many records, a write carrying no value for the key field throws
|
|
140
|
+
`StateConfigError` (`NoId`) — nothing here mints ids. A write carrying a `ttl` throws
|
|
141
|
+
`UnsupportedArgumentError`: the store keeps no expiring records, so a ttl would be silently
|
|
142
|
+
dropped.
|
|
143
|
+
|
|
144
|
+
On a `single` store every write lands in the one slot, so `replace([a, b])` keeps only the last of
|
|
145
|
+
them. The key field is never consulted on the way in: an id-less `save`, `create`, `update` or
|
|
146
|
+
`replace` is filed there normally and the record keeps whatever id it arrived with, or none.
|
|
147
|
+
`create` still refuses a slot already filled and `update` still requires it filled, both naming the
|
|
148
|
+
resource alias rather than an id.
|
|
149
|
+
|
|
150
|
+
`get(id)` / `load(id)` answer the sole record unless it carries a DIFFERENT id: a record stored with
|
|
151
|
+
`id: 'sid'` is a miss for any other name, while a record stored without an id at all — the shape a
|
|
152
|
+
single store invites, since it needs none — answers to every id asked for. Give the record an id
|
|
153
|
+
whenever a screen reads it by one, and treat an id-keyed read on an id-less single store as an
|
|
154
|
+
unconditional hit.
|
|
155
|
+
|
|
156
|
+
### The commit rule
|
|
157
|
+
|
|
158
|
+
`StateModel.record` is a SNAPSHOT. Assigning to it changes nothing anyone else can see:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
model.record.title = 'renamed' // WRONG — a silent no-op, nothing re-renders
|
|
162
|
+
await model.update({ title: 'renamed' }) // RIGHT — merges and commits
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`update(patch)` merges and commits in one step — batch several fields into one patch rather than
|
|
166
|
+
writing them one at a time. `commit()` writes what `record` currently holds, which is how an empty
|
|
167
|
+
model bound to a `default` is persisted as it stands. `clear()` deletes the record and leaves the
|
|
168
|
+
model empty again. The working copy is replaced rather than mutated on every write, so the record a
|
|
169
|
+
caller is holding never changes underneath it and two models of the same record stay comparable by
|
|
170
|
+
reference — which is what lets a React subscriber tell a real change from an unrelated one.
|
|
171
|
+
|
|
172
|
+
## Querying
|
|
173
|
+
|
|
174
|
+
Reads take the same criteria language as the server resources, so a filter written for an endpoint
|
|
175
|
+
means the same thing applied locally:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
await store.get(id) // the record, or UnknownRecordError
|
|
179
|
+
await store.load({ status: 'open' }) // the first match, or null
|
|
180
|
+
await store.list({ status: ['open', 'blocked'] }) // { items, total }
|
|
181
|
+
await store.list({ status: 'open' }, { sort: ['createdAt'], size: 20 })
|
|
182
|
+
await store.count({ status: 'open' })
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- A bare value is equality; a bare **array means "any of these"**.
|
|
186
|
+
- Operators: `$eq $ne $gt $gte $lt $lte $in $nin $exists $null $like $ilike $regex $startsWith
|
|
187
|
+
$endsWith $between $contains $contained $overlaps`, and `$and $or $not` to combine.
|
|
188
|
+
- A dotted key reaches into the record (`'owner.team'`).
|
|
189
|
+
- `null` matches absence; a criteria value of `undefined` is SKIPPED — an untouched filter must not
|
|
190
|
+
empty the list.
|
|
191
|
+
- `Sort<T>` is a field name (ascending) or `{ field, order: 'asc' | 'desc' }`.
|
|
192
|
+
|
|
193
|
+
`list()` returns `{ items, total }` and is **unpaged**: the store is already in memory and a screen
|
|
194
|
+
reading it expects all of it. Ask for a `size` to page, and `size: 0` still means no limit. A `page`
|
|
195
|
+
with no `size` throws `UnsupportedArgumentError('page-without-size')` — there is no default page
|
|
196
|
+
size to count against.
|
|
197
|
+
|
|
198
|
+
## Subscribing outside React
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
const stopOne = store.watch(id, model => { … }) // one record
|
|
202
|
+
const stopMany = store.query({ status: 'open' }, models => { … }) // a live query
|
|
203
|
+
const stopAll = store.query(undefined, models => { … }, { sort: ['createdAt'] })
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Both are **synchronous** and both are seeded before they return: the listener is called with the
|
|
207
|
+
current value straight away, then again on every change — including a removal, which reaches a
|
|
208
|
+
`watch` listener as an empty model. `watch(undefined, …)` on a listed store seeds an empty model
|
|
209
|
+
and subscribes to nothing. Each returns its unsubscribe, and a `query` listener is called again
|
|
210
|
+
only when the set of matching models actually changed, so an unrelated write re-renders nothing.
|
|
211
|
+
|
|
212
|
+
Writes announce themselves on the default channel, so `publish` is for what the store cannot know
|
|
213
|
+
it did — a change that arrived from elsewhere, or a channel of a caller's own:
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
const stop = await store.subscribe(event => { … }) // every write: StateEvent<T>
|
|
217
|
+
await store.publish({ type: 'set', records: [task] }, 'from-socket')
|
|
218
|
+
const once = await store.subscribe(handler, { channel: 'from-socket', once: true, ttl: 60 })
|
|
41
219
|
```
|
|
42
220
|
|
|
43
221
|
## Depends On
|
|
44
222
|
|
|
45
|
-
- `@owlmeans/resource` — `StateResource` extends `Resource`
|
|
46
|
-
- `@owlmeans/context` —
|
|
47
|
-
|
|
223
|
+
- `@owlmeans/resource` — `StateResource` extends `Resource` and `PubSubResource`
|
|
224
|
+
- `@owlmeans/context` — `appendContextual`, and the `getStateResource` mixin
|
|
225
|
+
|
|
226
|
+
## Related
|
|
227
|
+
|
|
228
|
+
- `resource` — the criteria language, paging and the base contract this implements
|
|
229
|
+
- `client` — where the React hooks live; it depends on this package, not the other way round
|
|
230
|
+
- `client-job` — a worked store: a socket feed folded into a state resource
|
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;IAE7C,YAAY,GAAG,EAAE,MAAM,EAGtB;CACF"}
|
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;CACF;AAED,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,SAAS,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"}
|