@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.
Files changed (50) hide show
  1. package/README.md +158 -77
  2. package/agent-meta/manifest.json +3 -3
  3. package/agent-meta/skills/state/SKILL.md +38 -12
  4. package/build/consts.d.ts +3 -0
  5. package/build/consts.d.ts.map +1 -0
  6. package/build/consts.js +3 -0
  7. package/build/consts.js.map +1 -0
  8. package/build/consts.local.d.ts +10 -0
  9. package/build/consts.local.d.ts.map +1 -0
  10. package/build/consts.local.js +10 -0
  11. package/build/consts.local.js.map +1 -0
  12. package/build/errors.d.ts.map +1 -1
  13. package/build/errors.js.map +1 -1
  14. package/build/helper.d.ts.map +1 -1
  15. package/build/index.d.ts +1 -0
  16. package/build/index.d.ts.map +1 -1
  17. package/build/resource.d.ts +2 -2
  18. package/build/resource.d.ts.map +1 -1
  19. package/build/resource.js +37 -15
  20. package/build/resource.js.map +1 -1
  21. package/build/types.d.ts +2 -1
  22. package/build/types.d.ts.map +1 -1
  23. package/build/types.local.d.ts +15 -0
  24. package/build/types.local.d.ts.map +1 -0
  25. package/build/types.local.js +2 -0
  26. package/build/types.local.js.map +1 -0
  27. package/build/utils/criteria.d.ts +20 -0
  28. package/build/utils/criteria.d.ts.map +1 -0
  29. package/build/utils/criteria.js +252 -0
  30. package/build/utils/criteria.js.map +1 -0
  31. package/build/utils/index.d.ts +3 -0
  32. package/build/utils/index.d.ts.map +1 -0
  33. package/build/utils/index.js +3 -0
  34. package/build/utils/index.js.map +1 -0
  35. package/build/utils/model.d.ts +1 -13
  36. package/build/utils/model.d.ts.map +1 -1
  37. package/build/utils/model.js.map +1 -1
  38. package/build/utils/types.d.ts +15 -0
  39. package/build/utils/types.d.ts.map +1 -0
  40. package/build/utils/types.js +2 -0
  41. package/build/utils/types.js.map +1 -0
  42. package/package.json +4 -4
  43. package/src/consts.local.ts +11 -0
  44. package/src/index.ts +1 -0
  45. package/src/resource.ts +42 -41
  46. package/src/types.local.ts +16 -0
  47. package/src/types.ts +2 -1
  48. package/src/utils/model.ts +1 -14
  49. package/src/utils/types.ts +15 -0
  50. 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
- ## 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
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 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
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
@@ -1,8 +1,8 @@
1
1
  {
2
- "schemaVersion": 2,
2
+ "schemaVersion": 3,
3
3
  "package": "@owlmeans/state",
4
- "version": "0.1.18-rc.9",
5
- "generatedAt": "2026-09-01T16:28:56.989Z",
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-rc.9"` 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
@@ -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
- | `StateConfigError` | `NonSingle` (no id on a many-record store), `NoId` (a write with no key) |
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
- `useStoreModel()` with no id addresses the one record of a `single` resource. On any other that is a
107
- wiring mistake and throws `StateConfigError` (`NonSingle`).
108
+ 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<ClientEntrypoint<Task[]>>(TASK_LIST).call()
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
- A write carrying no value for the key field throws `StateConfigError` (`NoId`) — nothing here mints
134
- ids. A write carrying a `ttl` throws `UnsupportedArgumentError`: the store keeps no expiring
135
- records, so a ttl would be silently dropped.
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. Each returns its unsubscribe.
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` — for `getStateResource`
204
- - React hooks: `@owlmeans/client`
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,3 @@
1
+ export declare const DEFAULT_ID = "_default";
2
+ export declare const DEFAULT_ALIAS = "state";
3
+ //# sourceMappingURL=consts.d.ts.map
@@ -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"}
@@ -0,0 +1,3 @@
1
+ export const DEFAULT_ID = '_default';
2
+ export const DEFAULT_ALIAS = 'state';
3
+ //# sourceMappingURL=consts.js.map
@@ -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"}
@@ -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;gBAEjC,GAAG,EAAE,MAAM;CAIxB"}
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"}
@@ -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;;AAGH,aAAa,CAAC,kBAAkB,CAAC,gBAAgB,CAAC,CAAA"}
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"}
@@ -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,EAAE,OAAO,MAAM,KAAG,UAAU,CAAC,CAAC,CACzD,CAAA"}
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
@@ -3,4 +3,5 @@ export * from './errors.js';
3
3
  export * from './helper.js';
4
4
  export * from './resource.js';
5
5
  export * from './utils/model.js';
6
+ export type * from './utils/types.js';
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -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"}
@@ -1,5 +1,5 @@
1
- import type { BasicConfig as Config, BasicContext as Context } from '@owlmeans/context';
2
- import type { ResourceRecord } from '@owlmeans/resource';
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
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,IAAI,MAAM,EAAE,YAAY,IAAI,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAKvF,OAAO,KAAK,EAC2B,cAAc,EAEpD,MAAM,oBAAoB,CAAA;AAG3B,OAAO,KAAK,EACV,WAAW,EAA0B,aAAa,EAAE,mBAAmB,EACxE,MAAM,YAAY,CAAA;AAgCnB,eAAO,MAAM,mBAAmB,GAAI,CAAC,SAAS,cAAc,EAC1D,QAAO,MAAc,EAAE,MAAM,WAAW,CAAC,CAAC,CAAC,KAC1C,aAAa,CAAC,CAAC,CA6XjB,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,GAC9B,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,SAAS,cAAc,GAAG,cAAc,EACjF,KAAK,CAAC,EAAE,QAAO,MAAc,EAAE,MAAM,WAAW,CAAC,CAAC,CAAC,KAAG,CAAC,GAAG,mBAa3D,CAAA"}
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 { applyQuery, filterRecords, firstMatch, RecordExists, sortRecords, UnknownRecordError, UnsupportedArgumentError } from '@owlmeans/resource';
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
- const queryModels = (watch) => sortRecords(filterRecords(records(), watch.where), watch.opts?.sort)
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) {