@owlmeans/state 0.1.18-rc.9 → 0.1.18
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 +158 -77
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/state/SKILL.md +38 -12
- package/build/consts.d.ts +3 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +3 -0
- package/build/consts.js.map +1 -0
- package/build/consts.local.d.ts +10 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +10 -0
- package/build/consts.local.js.map +1 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js.map +1 -1
- package/build/helper.d.ts.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/resource.d.ts +2 -2
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +37 -15
- package/build/resource.js.map +1 -1
- package/build/types.d.ts +2 -1
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +15 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/build/utils/criteria.d.ts +20 -0
- package/build/utils/criteria.d.ts.map +1 -0
- package/build/utils/criteria.js +252 -0
- package/build/utils/criteria.js.map +1 -0
- package/build/utils/index.d.ts +3 -0
- package/build/utils/index.d.ts.map +1 -0
- package/build/utils/index.js +3 -0
- package/build/utils/index.js.map +1 -0
- package/build/utils/model.d.ts +1 -13
- package/build/utils/model.d.ts.map +1 -1
- package/build/utils/model.js.map +1 -1
- package/build/utils/types.d.ts +15 -0
- package/build/utils/types.d.ts.map +1 -0
- package/build/utils/types.js +2 -0
- package/build/utils/types.js.map +1 -0
- package/package.json +4 -4
- package/src/consts.local.ts +11 -0
- package/src/index.ts +1 -0
- package/src/resource.ts +42 -41
- package/src/types.local.ts +16 -0
- package/src/types.ts +2 -1
- package/src/utils/model.ts +1 -14
- package/src/utils/types.ts +15 -0
- package/tests/resource.spec.ts +52 -2
package/README.md
CHANGED
|
@@ -1,131 +1,212 @@
|
|
|
1
1
|
# @owlmeans/state
|
|
2
2
|
|
|
3
|
-
The framework's client store: an in-memory `Resource` with live subscriptions
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
|
|
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`
|
|
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.
|
|
16
9
|
|
|
17
10
|
## Installation
|
|
18
11
|
|
|
19
12
|
```bash
|
|
20
|
-
bun add @owlmeans/state
|
|
13
|
+
bun add @owlmeans/state@^0.1.18
|
|
21
14
|
```
|
|
22
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
|
+
|
|
23
32
|
## Usage
|
|
24
33
|
|
|
25
|
-
Register
|
|
34
|
+
### Register stores in the context factory
|
|
26
35
|
|
|
27
|
-
```
|
|
36
|
+
```ts
|
|
28
37
|
import { appendStateResource, stateAlias } from '@owlmeans/state'
|
|
29
38
|
|
|
30
|
-
export const
|
|
39
|
+
export const PROJECTS = stateAlias<Project>('project-state')
|
|
40
|
+
export const STORIES = stateAlias<Story>('story-state')
|
|
31
41
|
|
|
32
42
|
export const makeContext = <C extends Config, T extends Context<C>>(cfg: C): T => {
|
|
33
|
-
const context =
|
|
34
|
-
appendStateResource<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)
|
|
35
48
|
|
|
36
49
|
return context
|
|
37
50
|
}
|
|
51
|
+
```
|
|
52
|
+
|
|
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.
|
|
56
|
+
|
|
57
|
+
### Fetch, then write what the server answered
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const store = context.getStateResource(PROJECTS)
|
|
38
61
|
|
|
39
|
-
const
|
|
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
|
|
40
67
|
```
|
|
41
68
|
|
|
42
|
-
|
|
43
|
-
when records of different kinds must not share an id space.
|
|
69
|
+
### Read it from React
|
|
44
70
|
|
|
45
|
-
|
|
71
|
+
The hooks live in [`@owlmeans/client`](../client):
|
|
46
72
|
|
|
47
|
-
```
|
|
73
|
+
```tsx
|
|
48
74
|
import { useStoreList, useStoreModel } from '@owlmeans/client'
|
|
49
75
|
|
|
50
|
-
const
|
|
51
|
-
|
|
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 />
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return <Card title={project.record.title} count={stories.length}
|
|
91
|
+
onRename={title => project.update({ title })} />
|
|
92
|
+
}
|
|
52
93
|
```
|
|
53
94
|
|
|
54
|
-
|
|
95
|
+
### A single-record store with a default
|
|
55
96
|
|
|
56
|
-
```
|
|
57
|
-
const
|
|
97
|
+
```ts
|
|
98
|
+
export const DRAFT = stateAlias<InvoiceDraft>('invoice-draft')
|
|
58
99
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
100
|
+
appendStateResource<C, T, InvoiceDraft>(context, DRAFT, {
|
|
101
|
+
single: true,
|
|
102
|
+
default: () => ({ customerId: '', lines: [], currency: 'EUR' })
|
|
103
|
+
})
|
|
63
104
|
|
|
64
|
-
|
|
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
|
|
108
|
+
```
|
|
109
|
+
|
|
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
|
+
})
|
|
118
|
+
|
|
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
|
+
})
|
|
65
126
|
```
|
|
66
127
|
|
|
67
128
|
## API
|
|
68
129
|
|
|
130
|
+
### `appendStateResource<C, T, R>(context, alias?, config?): T & StateResourceAppend`
|
|
131
|
+
|
|
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.
|
|
134
|
+
|
|
69
135
|
### `createStateResource<T>(alias?, config?): StateResource<T>`
|
|
70
136
|
|
|
71
|
-
The bare factory,
|
|
137
|
+
The bare factory, for registering the resource by hand. `appendStateResource` is the usual way.
|
|
72
138
|
|
|
73
139
|
### `StateConfig<T>`
|
|
74
140
|
|
|
75
141
|
| Field | Meaning |
|
|
76
142
|
|-------|---------|
|
|
77
143
|
| `id` | The field records are keyed by. Defaults to `id` |
|
|
78
|
-
| `single` | The resource holds exactly ONE record, which needs no id
|
|
79
|
-
| `default` |
|
|
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 |
|
|
80
146
|
|
|
81
147
|
### `StateResource<T>` (extends `Resource<T>`, `PubSubResource<StateEvent<T>>`)
|
|
82
148
|
|
|
83
|
-
- `
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
- `
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
91
158
|
announces itself as a `StateEvent` on the default channel
|
|
92
159
|
|
|
93
160
|
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
|
|
95
|
-
expires.
|
|
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.
|
|
96
164
|
|
|
97
165
|
### `StateModel<T>`
|
|
98
166
|
|
|
99
|
-
- `id` / `empty` / `record
|
|
167
|
+
- `id` / `empty` / `record`: `empty` is what "nothing loaded yet" looks like; `record` is the
|
|
100
168
|
configured `default` while it is true
|
|
101
|
-
- `update(patch)
|
|
102
|
-
- `commit()
|
|
103
|
-
- `clear()
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
`
|
|
116
|
-
`
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
-
|
|
128
|
-
|
|
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
|
|
129
210
|
|
|
130
211
|
<!-- owlmeans:agent-guidance:start -->
|
|
131
212
|
## Agent guidance
|
|
@@ -135,7 +216,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
135
216
|
your project's skill store (`.agents/skills/`):
|
|
136
217
|
|
|
137
218
|
```sh
|
|
138
|
-
npx @owlmeans/agent-skills
|
|
219
|
+
npx @owlmeans/agent-skills@^0.1.18
|
|
139
220
|
```
|
|
140
221
|
|
|
141
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
|
-
"schemaVersion":
|
|
2
|
+
"schemaVersion": 3,
|
|
3
3
|
"package": "@owlmeans/state",
|
|
4
|
-
"version": "0.1.18
|
|
5
|
-
"generatedAt": "2026-
|
|
4
|
+
"version": "0.1.18",
|
|
5
|
+
"generatedAt": "2026-10-28T00:31:34.730Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -8,7 +8,7 @@ user-invocable: false
|
|
|
8
8
|
# @owlmeans/state
|
|
9
9
|
|
|
10
10
|
**Layer:** Core
|
|
11
|
-
**Install:** `"@owlmeans/state": "^0.1.18
|
|
11
|
+
**Install:** `"@owlmeans/state": "^0.1.18"` 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
|
|
@@ -24,9 +24,11 @@ vocabulary of [[resource]]; `watch` and `query` are the live half.
|
|
|
24
24
|
| `stateAlias<T>(alias)` | Name a store once with the record type attached — `StateAlias<T>` |
|
|
25
25
|
| `StateResource<T>` | The resource interface — full CRUD plus `replace`, `clear`, `watch`, `query`, `publish`/`subscribe` |
|
|
26
26
|
| `StateModel<T>` | The subscribed wrapper — `id`, `empty`, `record`, `update`, `commit`, `clear` |
|
|
27
|
-
| `StateConfig<T>` | How the store is keyed — `id`, `single`, `default` |
|
|
27
|
+
| `StateConfig<T>` | How the store is keyed — `id`, `single`, `default`. Readable back as `resource.config` |
|
|
28
28
|
| `StateEvent<T>` | What a change looks like on the wire — `{ type: 'set' \| 'remove', records }` |
|
|
29
|
-
| `
|
|
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 |
|
|
30
32
|
|
|
31
33
|
The criteria evaluator (`matchCriteria`, `filterRecords`, `sortRecords`, `applyQuery`) lives in
|
|
32
34
|
**`@owlmeans/resource`** — the same engine the store runs on, for filtering a list you already hold.
|
|
@@ -103,8 +105,12 @@ if (task.empty) {
|
|
|
103
105
|
when there is none. Calling `model.update(...)` or `model.commit()` on an empty model writes it —
|
|
104
106
|
including the default it was showing.
|
|
105
107
|
|
|
106
|
-
|
|
107
|
-
|
|
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.
|
|
108
114
|
|
|
109
115
|
## Writing to it
|
|
110
116
|
|
|
@@ -114,7 +120,7 @@ back into the store, and let the subscriptions render it:
|
|
|
114
120
|
```typescript
|
|
115
121
|
const store = ctx.getStateResource(TASKS)
|
|
116
122
|
|
|
117
|
-
const tasks = await ctx.entrypoint
|
|
123
|
+
const tasks = await ctx.entrypoint(taskEntrypoints.list).call()
|
|
118
124
|
await store.replace(tasks)
|
|
119
125
|
```
|
|
120
126
|
|
|
@@ -130,9 +136,22 @@ is the same read but throws when the record is absent, `purge(where)` bulk-delet
|
|
|
130
136
|
empty criteria object rather than emptying the store), and `clear()` drops everything. Each one
|
|
131
137
|
notifies every subscriber that cares.
|
|
132
138
|
|
|
133
|
-
|
|
134
|
-
ids. A write carrying a `ttl` throws
|
|
135
|
-
records, so a ttl would be silently
|
|
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.
|
|
136
155
|
|
|
137
156
|
### The commit rule
|
|
138
157
|
|
|
@@ -186,7 +205,9 @@ const stopAll = store.query(undefined, models => { … }, { sort: ['createdAt']
|
|
|
186
205
|
|
|
187
206
|
Both are **synchronous** and both are seeded before they return: the listener is called with the
|
|
188
207
|
current value straight away, then again on every change — including a removal, which reaches a
|
|
189
|
-
`watch` listener as an empty model.
|
|
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.
|
|
190
211
|
|
|
191
212
|
Writes announce themselves on the default channel, so `publish` is for what the store cannot know
|
|
192
213
|
it did — a change that arrived from elsewhere, or a channel of a caller's own:
|
|
@@ -200,5 +221,10 @@ const once = await store.subscribe(handler, { channel: 'from-socket', once: true
|
|
|
200
221
|
## Depends On
|
|
201
222
|
|
|
202
223
|
- `@owlmeans/resource` — `StateResource` extends `Resource` and `PubSubResource`
|
|
203
|
-
- `@owlmeans/context` —
|
|
204
|
-
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,UAAU,aAAa,CAAA;AACpC,eAAO,MAAM,aAAa,UAAU,CAAA"}
|
package/build/consts.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,MAAM,CAAC,MAAM,UAAU,GAAG,UAAU,CAAA;AACpC,MAAM,CAAC,MAAM,aAAa,GAAG,OAAO,CAAA"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one slot of a `single` resource. It is a KEY and never a value: nothing writes it into a
|
|
3
|
+
* record, so a single resource's record still carries whatever id it arrived with, or none.
|
|
4
|
+
*/
|
|
5
|
+
export declare const SOLE = "";
|
|
6
|
+
/** The channel writes publish on, and the one a subscriber gets when it names none. */
|
|
7
|
+
export declare const CHANGES = "changes";
|
|
8
|
+
/** The alias a context's first state resource takes when nothing else is asked for. */
|
|
9
|
+
export declare const STATE = "state";
|
|
10
|
+
//# sourceMappingURL=consts.local.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.local.d.ts","sourceRoot":"","sources":["../src/consts.local.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,eAAO,MAAM,IAAI,KAAK,CAAA;AAEtB,uFAAuF;AACvF,eAAO,MAAM,OAAO,YAAY,CAAA;AAEhC,uFAAuF;AACvF,eAAO,MAAM,KAAK,UAAU,CAAA"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one slot of a `single` resource. It is a KEY and never a value: nothing writes it into a
|
|
3
|
+
* record, so a single resource's record still carries whatever id it arrived with, or none.
|
|
4
|
+
*/
|
|
5
|
+
export const SOLE = '';
|
|
6
|
+
/** The channel writes publish on, and the one a subscriber gets when it names none. */
|
|
7
|
+
export const CHANGES = 'changes';
|
|
8
|
+
/** The alias a context's first state resource takes when nothing else is asked for. */
|
|
9
|
+
export const STATE = 'state';
|
|
10
|
+
//# sourceMappingURL=consts.local.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.local.js","sourceRoot":"","sources":["../src/consts.local.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,EAAE,CAAA;AAEtB,uFAAuF;AACvF,MAAM,CAAC,MAAM,OAAO,GAAG,SAAS,CAAA;AAEhC,uFAAuF;AACvF,MAAM,CAAC,MAAM,KAAK,GAAG,OAAO,CAAA"}
|
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;;;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;
|
|
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.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;;;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
|
|
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"}
|
package/build/helper.d.ts.map
CHANGED
|
@@ -1 +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,
|
|
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/index.d.ts
CHANGED
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,kBAAkB,CAAA"}
|
|
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;AAChC,mBAAmB,kBAAkB,CAAA"}
|
package/build/resource.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type
|
|
1
|
+
import { type BasicConfig as Config, type BasicContext as Context } from '@owlmeans/context';
|
|
2
|
+
import { type ResourceRecord } from '@owlmeans/resource';
|
|
3
3
|
import type { StateConfig, StateResource, StateResourceAppend } from './types.js';
|
|
4
4
|
export declare const createStateResource: <T extends ResourceRecord>(alias?: string, cfg?: StateConfig<T>) => StateResource<T>;
|
|
5
5
|
/**
|
package/build/resource.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,KAAK,WAAW,IAAI,MAAM,EAAE,KAAK,YAAY,IAAI,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAC9G,OAAO,EAAqI,KAAK,cAAc,EAAwE,MAAM,oBAAoB,CAAA;AAGjQ,OAAO,KAAK,EACV,WAAW,EAA0B,aAAa,EAAE,mBAAmB,EACxE,MAAM,YAAY,CAAA;AAQnB,eAAO,MAAM,mBAAmB,GAAI,CAAC,SAAS,cAAc,UACnD,MAAM,QAAgB,WAAW,CAAC,CAAC,CAAC,KAC1C,aAAa,CAAC,CAAC,CA8ZjB,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,GAC9B,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,SAAS,cAAc,GAAG,cAAc,OAC5E,CAAC,UAAS,MAAM,QAAgB,WAAW,CAAC,CAAC,CAAC,KAAG,CAAC,GAAG,mBAa3D,CAAA"}
|
package/build/resource.js
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
import { appendContextual } from '@owlmeans/context';
|
|
2
|
-
import {
|
|
2
|
+
import { RecordExists, recordQueryHelper, UnknownRecordError, UnsupportedArgumentError } from '@owlmeans/resource';
|
|
3
3
|
import { StateConfigError } from './errors.js';
|
|
4
4
|
import { createStateModel } from './utils/model.js';
|
|
5
|
-
|
|
6
|
-
* The one slot of a `single` resource. It is a KEY and never a value: nothing writes it into a
|
|
7
|
-
* record, so a single resource's record still carries whatever id it arrived with, or none.
|
|
8
|
-
*/
|
|
9
|
-
const SOLE = '';
|
|
10
|
-
/** The channel writes publish on, and the one a subscriber gets when it names none. */
|
|
11
|
-
const CHANGES = 'changes';
|
|
12
|
-
/** The alias a context's first state resource takes when nothing else is asked for. */
|
|
13
|
-
const STATE = 'state';
|
|
5
|
+
import { CHANGES, SOLE, STATE } from './consts.local.js';
|
|
14
6
|
/** Milliseconds until a subscription expires: a number is seconds from now, a Date the instant. */
|
|
15
7
|
const expiresIn = (ttl) => ttl instanceof Date ? ttl.getTime() - Date.now() : ttl * 1000;
|
|
16
8
|
export const createStateResource = (alias = STATE, cfg) => {
|
|
@@ -76,7 +68,7 @@ export const createStateResource = (alias = STATE, cfg) => {
|
|
|
76
68
|
}
|
|
77
69
|
return record;
|
|
78
70
|
};
|
|
79
|
-
const first = (idOrWhere, opts) => typeof idOrWhere === 'string' ? read(idOrWhere) : firstMatch(records(), idOrWhere, opts);
|
|
71
|
+
const first = (idOrWhere, opts) => typeof idOrWhere === 'string' ? read(idOrWhere) : recordQueryHelper.firstMatch(records(), idOrWhere, opts);
|
|
80
72
|
/** The store keeps no expiring records, so a ttl would be silently dropped. */
|
|
81
73
|
const refuseTtl = (opts) => {
|
|
82
74
|
if (opts?.ttl != null) {
|
|
@@ -113,7 +105,23 @@ export const createStateResource = (alias = STATE, cfg) => {
|
|
|
113
105
|
models.set(key, { from: record, model });
|
|
114
106
|
return model;
|
|
115
107
|
};
|
|
116
|
-
|
|
108
|
+
/**
|
|
109
|
+
* The model for "nothing is addressed yet" — one instance, reused.
|
|
110
|
+
*
|
|
111
|
+
* It must be the SAME reference every time: a React subscriber compares snapshots by identity,
|
|
112
|
+
* and a freshly built model would read as a change on every render and loop. Writing through it
|
|
113
|
+
* is refused rather than silently dropped, because a caller that writes without an id has lost
|
|
114
|
+
* track of which record it meant.
|
|
115
|
+
*/
|
|
116
|
+
let blank;
|
|
117
|
+
const emptyModel = () => blank ??= createStateModel({
|
|
118
|
+
id: undefined,
|
|
119
|
+
record: undefined,
|
|
120
|
+
default: config.default,
|
|
121
|
+
write: async () => { throw new StateConfigError(StateConfigError.NoId); },
|
|
122
|
+
drop: async () => { throw new StateConfigError(StateConfigError.NoId); }
|
|
123
|
+
});
|
|
124
|
+
const queryModels = (watch) => recordQueryHelper.sortRecords(recordQueryHelper.filterRecords(records(), watch.where), watch.opts?.sort)
|
|
117
125
|
.map(record => modelFor(storedKey(record)));
|
|
118
126
|
const same = (left, right) => left.length === right.length && left.every((model, index) => model === right[index]);
|
|
119
127
|
/** Deliver on one channel. Returns what the handlers gave back, for a caller that awaits. */
|
|
@@ -194,9 +202,9 @@ export const createStateResource = (alias = STATE, cfg) => {
|
|
|
194
202
|
if (opts?.page != null && opts.size == null) {
|
|
195
203
|
throw new UnsupportedArgumentError('page-without-size');
|
|
196
204
|
}
|
|
197
|
-
return applyQuery(records(), where, opts);
|
|
205
|
+
return recordQueryHelper.applyQuery(records(), where, opts);
|
|
198
206
|
},
|
|
199
|
-
count: async (where) => filterRecords(records(), where).length,
|
|
207
|
+
count: async (where) => recordQueryHelper.filterRecords(records(), where).length,
|
|
200
208
|
create: async (record, opts) => {
|
|
201
209
|
refuseTtl(opts);
|
|
202
210
|
const key = keyOf(record);
|
|
@@ -239,7 +247,7 @@ export const createStateResource = (alias = STATE, cfg) => {
|
|
|
239
247
|
if (where == null || Object.keys(where).length < 1) {
|
|
240
248
|
throw new UnsupportedArgumentError('purge:empty-criteria');
|
|
241
249
|
}
|
|
242
|
-
const removed = filterRecords(records(), where)
|
|
250
|
+
const removed = recordQueryHelper.filterRecords(records(), where)
|
|
243
251
|
.map((record) => [storedKey(record), record]);
|
|
244
252
|
for (const [key] of removed) {
|
|
245
253
|
store.delete(key);
|
|
@@ -271,6 +279,20 @@ export const createStateResource = (alias = STATE, cfg) => {
|
|
|
271
279
|
notify('remove', dropped);
|
|
272
280
|
},
|
|
273
281
|
watch: (id, listener) => {
|
|
282
|
+
/**
|
|
283
|
+
* Watching before there is anything to watch is a RENDERING state, not a mistake.
|
|
284
|
+
*
|
|
285
|
+
* A screen binds to `useStoreModel(project.record.id)` while the project is still loading,
|
|
286
|
+
* so the id is legitimately absent for the first renders. Treating that as the
|
|
287
|
+
* configuration error it would be on `load()` or `save()` crashes the component tree over
|
|
288
|
+
* data that is simply not here yet. So an absent id on a listed resource watches nothing
|
|
289
|
+
* and reports an empty model; the error stays where it means something — a direct read or
|
|
290
|
+
* write with no id, which cannot be anything but a mistake.
|
|
291
|
+
*/
|
|
292
|
+
if (id == null && !single) {
|
|
293
|
+
listener(emptyModel());
|
|
294
|
+
return () => { };
|
|
295
|
+
}
|
|
274
296
|
const key = keyFor(id);
|
|
275
297
|
let listeners = watchers.get(key);
|
|
276
298
|
if (listeners == null) {
|