@owlmeans/state 0.1.18-rc.8 → 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.
Files changed (55) hide show
  1. package/README.md +173 -68
  2. package/agent-meta/manifest.json +3 -3
  3. package/agent-meta/skills/state/SKILL.md +137 -58
  4. package/build/consts.local.d.ts +10 -0
  5. package/build/consts.local.d.ts.map +1 -0
  6. package/build/consts.local.js +10 -0
  7. package/build/consts.local.js.map +1 -0
  8. package/build/errors.d.ts +12 -5
  9. package/build/errors.d.ts.map +1 -1
  10. package/build/errors.js +16 -13
  11. package/build/errors.js.map +1 -1
  12. package/build/helper.d.ts +16 -0
  13. package/build/helper.d.ts.map +1 -0
  14. package/build/helper.js +14 -0
  15. package/build/helper.js.map +1 -0
  16. package/build/index.d.ts +3 -2
  17. package/build/index.d.ts.map +1 -1
  18. package/build/index.js +2 -2
  19. package/build/index.js.map +1 -1
  20. package/build/resource.d.ts +12 -5
  21. package/build/resource.d.ts.map +1 -1
  22. package/build/resource.js +317 -207
  23. package/build/resource.js.map +1 -1
  24. package/build/types.d.ts +91 -50
  25. package/build/types.d.ts.map +1 -1
  26. package/build/types.local.d.ts +15 -0
  27. package/build/types.local.d.ts.map +1 -0
  28. package/build/types.local.js +2 -0
  29. package/build/types.local.js.map +1 -0
  30. package/build/utils/criteria.d.ts.map +1 -1
  31. package/build/utils/criteria.js.map +1 -1
  32. package/build/utils/model.d.ts +10 -2
  33. package/build/utils/model.d.ts.map +1 -1
  34. package/build/utils/model.js +26 -22
  35. package/build/utils/model.js.map +1 -1
  36. package/build/utils/types.d.ts +15 -0
  37. package/build/utils/types.d.ts.map +1 -0
  38. package/build/utils/types.js +2 -0
  39. package/build/utils/types.js.map +1 -0
  40. package/package.json +4 -4
  41. package/src/consts.local.ts +11 -0
  42. package/src/errors.ts +16 -13
  43. package/src/helper.ts +17 -0
  44. package/src/index.ts +3 -2
  45. package/src/resource.ts +359 -241
  46. package/src/types.local.ts +16 -0
  47. package/src/types.ts +98 -55
  48. package/src/utils/model.ts +35 -28
  49. package/src/utils/types.ts +15 -0
  50. package/tests/resource.spec.ts +347 -90
  51. package/build/.gitkeep +0 -0
  52. package/src/consts.ts +0 -3
  53. package/src/utils/criteria.ts +0 -252
  54. package/src/utils/index.ts +0 -3
  55. package/tests/criteria.spec.ts +0 -121
package/README.md CHANGED
@@ -1,107 +1,212 @@
1
1
  # @owlmeans/state
2
2
 
3
- In-memory reactive state management with subscription-based updates for OwlMeans apps.
4
-
5
- ## Overview
6
-
7
- - `createStateResource()` creates an in-memory store that implements the `Resource<T>` interface
8
- - Subscriptions trigger listeners whenever records are created, updated, or deleted
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
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
- Create and register a state resource in context setup:
34
+ ### Register stores in the context factory
35
+
36
+ ```ts
37
+ import { appendStateResource, stateAlias } from '@owlmeans/state'
21
38
 
22
- ```typescript
23
- import { createStateResource } from '@owlmeans/state'
39
+ export const PROJECTS = stateAlias<Project>('project-state')
40
+ export const STORIES = stateAlias<Story>('story-state')
24
41
 
25
- // In context.ts
26
- context.registerResource(createStateResource<ProjectState>('project-state'))
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
- Subscribe to state changes in a service or component:
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
- ```typescript
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
- const resource = context.resource<StateResource<ProjectState>>('project-state')
59
+ ```ts
60
+ const store = context.getStateResource(PROJECTS)
36
61
 
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
- })
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
44
67
  ```
45
68
 
46
- Update state and commit:
69
+ ### Read it from React
47
70
 
48
- ```typescript
49
- const [model] = resource.subscribe({ id: projectId, listener: ... })[1]
50
- model.update({ status: 'active' })
51
- model.commit() // triggers listeners
52
- ```
71
+ The hooks live in [`@owlmeans/client`](../client):
53
72
 
54
- ## API
73
+ ```tsx
74
+ import { useStoreList, useStoreModel } from '@owlmeans/client'
55
75
 
56
- ### `createStateResource<T>(alias?): StateResource<T>`
76
+ export const useProjectState = (id?: string) => useStoreModel<Project>(id, PROJECTS)
57
77
 
58
- Creates an in-memory resource with subscription support. Registers under `alias` (default: `'state'`).
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
+ })
59
85
 
60
- ### `StateResource<T>` (extends `Resource<T>`)
86
+ if (project.empty) {
87
+ return <Spinner />
88
+ }
61
89
 
62
- - `subscribe(params): [unsubscribe, StateModel<T>[]]` — subscribe to records by `id`, or to a live
63
- `query`; returns the current records
64
- - `listen(listener)` — global listener for any change in the resource
65
- - `erase()` — clear all records
66
- - `all(): Promise<T[]>` — every record, as a plain array
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
90
+ return <Card title={project.record.title} count={stories.length}
91
+ onRename={title => project.update({ title })} />
92
+ }
93
+ ```
70
94
 
71
- ### Criteria
95
+ ### A single-record store with a default
72
96
 
73
- `list`, `match` and a `query` subscription share the criteria language of the server resources — a
74
- bare value is equality, a bare array means "any of these", and `$eq $ne $gt $gte $lt $lte $in $nin
75
- $exists $null $like $ilike $regex $startsWith $endsWith $between $contains $contained $overlaps`
76
- combine under `$and` / `$or` / `$not`. A dotted key reaches into the record; a value of `undefined`
77
- is skipped. `matchCriteria`, `filterRecords` and `sortRecords` are exported for filtering a list
78
- already in hand.
97
+ ```ts
98
+ export const DRAFT = stateAlias<InvoiceDraft>('invoice-draft')
79
99
 
80
- ```typescript
81
- const open = await resource.match({ status: 'open' })
82
- const [unsubscribe] = resource.subscribe({
83
- query: { status: 'open' },
84
- listener: models => { /* re-runs on every write that changes the answer */ }
100
+ appendStateResource<C, T, InvoiceDraft>(context, DRAFT, {
101
+ single: true,
102
+ default: () => ({ customerId: '', lines: [], currency: 'EUR' })
85
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
86
108
  ```
87
109
 
88
- ### `StateModel<T>`
110
+ ### Fold a live feed into the store outside React
89
111
 
90
- - `record: T` — the current record
91
- - `update(data?)` — merge partial data into the record
92
- - `commit(force?)` — apply changes and notify subscribers
93
- - `clear()` — remove the record
112
+ ```ts
113
+ const store = context.getStateResource(STORIES)
94
114
 
95
- ### `DEFAULT_ID`
115
+ const stopQuery = store.query({ status: 'active' }, models => {
116
+ badge.set(models.length) // called now, then on every change to the set
117
+ })
96
118
 
97
- ```typescript
98
- const DEFAULT_ID = '_default' // conventional ID for single-item resources
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
+ })
99
126
  ```
100
127
 
101
- ## Related Packages
128
+ ## API
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
+
135
+ ### `createStateResource<T>(alias?, config?): StateResource<T>`
136
+
137
+ The bare factory, for registering the resource by hand. `appendStateResource` is the usual way.
138
+
139
+ ### `StateConfig<T>`
140
+
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 |
146
+
147
+ ### `StateResource<T>` (extends `Resource<T>`, `PubSubResource<StateEvent<T>>`)
148
+
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.
164
+
165
+ ### `StateModel<T>`
102
166
 
103
- - [`@owlmeans/resource`](../resource) — `Resource<T>` interface implemented by StateResource
104
- - [`@owlmeans/client`](../client) — `useStoreModel` / `useStoreList` React hooks for state resources
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
105
210
 
106
211
  <!-- owlmeans:agent-guidance:start -->
107
212
  ## Agent guidance
@@ -111,7 +216,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
111
216
  your project's skill store (`.agents/skills/`):
112
217
 
113
218
  ```sh
114
- npx @owlmeans/agent-skills
219
+ npx @owlmeans/agent-skills@^0.1.18
115
220
  ```
116
221
 
117
222
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
- "schemaVersion": 2,
2
+ "schemaVersion": 3,
3
3
  "package": "@owlmeans/state",
4
- "version": "0.1.18-rc.8",
5
- "generatedAt": "2026-08-24T21:14:00.121Z",
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
  {
@@ -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 query subscriptions, and the StateModel commit semantics. Auto-invoked when importing state primitives or building client-side application 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, 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,30 @@ user-invocable: false
8
8
  # @owlmeans/state
9
9
 
10
10
  **Layer:** Core
11
- **Install:** `"@owlmeans/state": "^0.1.18-rc.8"` in `dependencies`
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
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
- | `StateResource<T>` | The resource interface — full CRUD plus `all`, `match`, `subscribe`, `listen`, `erase` |
24
- | `StateModel<T>` | The subscribed wrapper — `record`, `update`, `commit`, `clear` |
25
- | `matchCriteria`, `filterRecords`, `sortRecords` | The criteria evaluator, for filtering a list you already hold |
26
- | `DEFAULT_ID` (`_default`), `DEFAULT_ALIAS` (`state`) | Constants |
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.
27
35
 
28
36
  The React hooks live in **`@owlmeans/client`** — `useStoreModel`, `useStoreList`. They are not
29
37
  re-exported by `@owlmeans/web-client`, so import them from `@owlmeans/client` directly.
@@ -35,21 +43,31 @@ no setup at all. Register a named one per entity when records of different kinds
35
43
  id space:
36
44
 
37
45
  ```typescript
38
- import { appendStateResource } from '@owlmeans/state'
46
+ import { appendStateResource, stateAlias } from '@owlmeans/state'
39
47
 
40
- export const TASK_STATE = 'task-state'
48
+ export const TASKS = stateAlias<Task>('task-state')
41
49
 
42
50
  export const makeContext = <C extends Config, T extends Context<C>>(cfg: C): T => {
43
- const context = makeBasicContext<C, T>(cfg)
44
- appendStateResource<C, T>(context, TASK_STATE)
45
- // A child context must inherit THIS factory, or it is built without the resource above.
46
- context.makeContext = makeContext as typeof context.makeContext
51
+ const context = makeClientContext<C, T>(cfg)
52
+ appendStateResource<C, T, Task>(context, TASKS)
47
53
  return context
48
54
  }
49
55
  ```
50
56
 
51
- Reach it with `context.getStateResource<Task>(TASK_STATE)`. A typed accessor on the context
52
- (`context.taskStore = () => context.getStateResource(TASK_STATE)`) is optional sugar.
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. |
53
71
 
54
72
  ## Reading it from React
55
73
 
@@ -57,95 +75,156 @@ Reach it with `context.getStateResource<Task>(TASK_STATE)`. A typed accessor on
57
75
  import { useStoreList, useStoreModel } from '@owlmeans/client'
58
76
 
59
77
  // One record, by id. Re-renders whenever that record changes.
60
- const task = useStoreModel<Task>(id, TASK_STATE)
78
+ const task = useStoreModel<Task>(id, TASKS)
61
79
  task.record.title
62
80
 
63
81
  // A LIVE QUERY. Re-renders whenever any write changes which records match.
64
- const open = useStoreList<Task>({ query: { status: 'open' }, resource: TASK_STATE })
82
+ const open = useStoreList<Task>({ query: { status: 'open' }, resource: TASKS })
65
83
  open.map(model => model.record.title)
66
84
 
67
- // Everything in the store.
68
- const all = useStoreList<Task>({ query: {}, resource: TASK_STATE })
85
+ // Everything in the store, newest first.
86
+ const all = useStoreList<Task>({ sort: [{ field: 'createdAt', order: 'desc' }], resource: TASKS })
69
87
  ```
70
88
 
71
89
  A query subscription creates nothing and re-evaluates on every create, update and delete — a list
72
90
  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.
91
+ content, so a filter that changes narrows the list. An omitted `query` matches everything.
74
92
 
75
- An **id** subscription is different on purpose: it CREATES a placeholder record so a screen has
76
- something to bind to before the real one arrives. `useStoreModel(undefined)` therefore hands back a
77
- record whose id is `DEFAULT_ID` — that sentinel, not `null`, is what "nothing loaded yet" looks
78
- like:
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:
79
97
 
80
98
  ```typescript
81
- const loading = task.record.id === DEFAULT_ID
99
+ if (task.empty) {
100
+ return <Spinner/>
101
+ }
82
102
  ```
83
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.
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.
114
+
84
115
  ## Writing to it
85
116
 
86
117
  The server is the source of truth; the store is what the screen reads. Fetch, then write what came
87
118
  back into the store, and let the subscriptions render it:
88
119
 
89
120
  ```typescript
90
- const store = ctx.getStateResource<Task>(TASK_STATE)
121
+ const store = ctx.getStateResource(TASKS)
91
122
 
92
- const [tasks] = await ctx.entrypoint<ClientEntrypoint<Task[]>>(TASK_LIST).call()
93
- for (const task of tasks) {
94
- await store.save(task)
95
- }
123
+ const tasks = await ctx.entrypoint(taskEntrypoints.list).call()
124
+ await store.replace(tasks)
96
125
  ```
97
126
 
98
- `save` creates or replaces. `update` requires the record to exist. `delete(id)` removes it. Each
99
- one notifies every subscriber that cares.
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.
100
155
 
101
156
  ### The commit rule
102
157
 
103
- `StateModel.record` is a COPY. Assigning to it changes nothing anyone else can see:
158
+ `StateModel.record` is a SNAPSHOT. Assigning to it changes nothing anyone else can see:
104
159
 
105
160
  ```typescript
106
- model.record.title = 'renamed' // WRONG — a silent no-op, nothing re-renders
107
- model.update({ title: 'renamed' }) // RIGHT — merges and commits
161
+ model.record.title = 'renamed' // WRONG — a silent no-op, nothing re-renders
162
+ await model.update({ title: 'renamed' }) // RIGHT — merges and commits
108
163
  ```
109
164
 
110
- `update(data)` merges and commits in one step; `commit()` writes the current `record` back and is
111
- what you call after assigning several fields; `clear()` deletes the record. `commit()` skips the
112
- write when nothing actually changed, so calling it twice is free.
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.
113
171
 
114
172
  ## Querying
115
173
 
116
- `list`, `match` and the `query` subscription all take the same criteria language as the server
117
- resources, so a filter written for an endpoint means the same thing applied locally:
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:
118
176
 
119
177
  ```typescript
120
- await store.all() // every record, plain array
121
- await store.match({ status: 'open' }) // the matching records, plain array
122
- await store.list({ status: ['open', 'blocked'] }) // the Resource envelope: { items, pager }
123
- await store.list({ criteria: { status: 'open' }, pager: { page: 0, size: 20, sort: ['createdAt'] } })
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' })
124
183
  ```
125
184
 
126
185
  - A bare value is equality; a bare **array means "any of these"**.
127
186
  - Operators: `$eq $ne $gt $gte $lt $lte $in $nin $exists $null $like $ilike $regex $startsWith
128
187
  $endsWith $between $contains $contained $overlaps`, and `$and $or $not` to combine.
129
188
  - A dotted key reaches into the record (`'owner.team'`).
130
- - A criteria value of `undefined` is SKIPPED — an untouched filter must not empty the list.
131
- - `list()` with no arguments returns everything: a state resource is unpaged unless a pager is
132
- asked for, unlike a server resource that defaults to a page size.
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' }`.
133
192
 
134
- `all()` and `match()` return plain arrays; `list()` returns `{ items, pager }` because that is the
135
- `Resource` contract. Destructuring the wrong one is a silently empty render, so pick by shape.
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.
136
197
 
137
198
  ## Subscribing outside React
138
199
 
139
200
  ```typescript
140
- const [unsubscribe] = store.subscribe({ query: { status: 'open' }, listener: models => { ... } })
141
- const stop = store.listen(models => { ... }) // every change, whatever it is
142
- await store.erase() // drop everything
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'] })
143
204
  ```
144
205
 
145
- Subscribing the same listener function twice throws — hold the unsubscribe and call it instead.
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 })
219
+ ```
146
220
 
147
221
  ## Depends On
148
222
 
149
- - `@owlmeans/resource` — `StateResource` extends `Resource`
150
- - `@owlmeans/context` — for `getStateResource`
151
- - React hooks: `@owlmeans/client`
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
@@ -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"}