@owlmeans/state 0.1.18-rc.17 → 0.1.18-rc.19

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 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
- ## Overview
6
-
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`
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@^0.1.18-rc.9
13
+ bun add @owlmeans/state@^0.1.18-rc.19
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 a store per record type, and name it once with the type attached:
34
+ ### Register stores in the context factory
26
35
 
27
- ```typescript
36
+ ```ts
28
37
  import { appendStateResource, stateAlias } from '@owlmeans/state'
29
38
 
30
- export const TASKS = stateAlias<Task>('tasks')
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 = makeClientContext<C, T>(cfg)
34
- appendStateResource<C, T, Task>(context, TASKS)
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 tasks = context.getStateResource(TASKS) // StateResource<Task>
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
- 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.
69
+ ### Read it from React
44
70
 
45
- Read it from React with the hooks in [`@owlmeans/client`](../client):
71
+ The hooks live in [`@owlmeans/client`](../client):
46
72
 
47
- ```typescript
73
+ ```tsx
48
74
  import { useStoreList, useStoreModel } from '@owlmeans/client'
49
75
 
50
- const task = useStoreModel<Task>(id, 'tasks') // one record, live
51
- const open = useStoreList<Task>({ query: { status: 'open' }, resource: 'tasks' })
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
- Write through the resource, or through the model a subscription handed you:
95
+ ### A single-record store with a default
55
96
 
56
- ```typescript
57
- const tasks = context.getStateResource(TASKS)
97
+ ```ts
98
+ export const DRAFT = stateAlias<InvoiceDraft>('invoice-draft')
58
99
 
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()
100
+ appendStateResource<C, T, InvoiceDraft>(context, DRAFT, {
101
+ single: true,
102
+ default: () => ({ customerId: '', lines: [], currency: 'EUR' })
103
+ })
63
104
 
64
- model.update({ status: 'done' }) // merge and write in one step
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, when the resource is registered by hand. `appendStateResource` is the usual way.
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 — the current user, the active session, a wizard being filled in |
79
- | `default` | What `StateModel.record` shows while the model is empty |
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
- - `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
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` — nothing here
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` — `empty` is what "nothing loaded yet" looks like; `record` is the
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)` — merge and write in one step
102
- - `commit()` — write what `record` currently holds, including a default not yet stored
103
- - `clear()` — delete the record
104
-
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.
107
-
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.
124
-
125
- ## Related Packages
126
-
127
- - [`@owlmeans/resource`](../resource) — the `Resource<T>` contract and the criteria engine
128
- - [`@owlmeans/client`](../client) — `useStoreModel` / `useStoreList` React hooks
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@^0.1.18-rc.19
219
+ npx @owlmeans/agent-skills@^0.1.18-rc.21
139
220
  ```
140
221
 
141
222
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/state",
4
- "version": "0.1.18-rc.17",
5
- "generatedAt": "2026-09-12T13:51:53.531Z",
4
+ "version": "0.1.18-rc.19",
5
+ "generatedAt": "2026-09-15T12:21:31.907Z",
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-rc.17"` in `dependencies`
11
+ **Install:** `"@owlmeans/state": "^0.1.18-rc.19"` 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/state",
3
- "version": "0.1.18-rc.17",
3
+ "version": "0.1.18-rc.19",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -22,8 +22,8 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
25
- "@owlmeans/context": "^0.1.18-rc.15",
26
- "@owlmeans/resource": "^0.1.18-rc.16"
25
+ "@owlmeans/context": "^0.1.18-rc.17",
26
+ "@owlmeans/resource": "^0.1.18-rc.18"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@owlmeans/dep-config": "workspace:*",