@owlmeans/state 0.1.18-rc.7 → 0.1.18-rc.9

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 +85 -61
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/state/SKILL.md +109 -56
  4. package/build/errors.d.ts +12 -5
  5. package/build/errors.d.ts.map +1 -1
  6. package/build/errors.js +16 -13
  7. package/build/errors.js.map +1 -1
  8. package/build/helper.d.ts +16 -0
  9. package/build/helper.d.ts.map +1 -0
  10. package/build/helper.js +14 -0
  11. package/build/helper.js.map +1 -0
  12. package/build/index.d.ts +2 -2
  13. package/build/index.d.ts.map +1 -1
  14. package/build/index.js +2 -2
  15. package/build/index.js.map +1 -1
  16. package/build/resource.d.ts +11 -4
  17. package/build/resource.d.ts.map +1 -1
  18. package/build/resource.js +296 -208
  19. package/build/resource.js.map +1 -1
  20. package/build/types.d.ts +90 -50
  21. package/build/types.d.ts.map +1 -1
  22. package/build/utils/model.d.ts +22 -2
  23. package/build/utils/model.d.ts.map +1 -1
  24. package/build/utils/model.js +26 -22
  25. package/build/utils/model.js.map +1 -1
  26. package/package.json +3 -3
  27. package/src/errors.ts +16 -13
  28. package/src/helper.ts +17 -0
  29. package/src/index.ts +2 -2
  30. package/src/resource.ts +361 -244
  31. package/src/types.ts +97 -55
  32. package/src/utils/model.ts +48 -28
  33. package/tests/resource.spec.ts +297 -90
  34. package/build/.gitkeep +0 -0
  35. package/build/consts.d.ts +0 -3
  36. package/build/consts.d.ts.map +0 -1
  37. package/build/consts.js +0 -3
  38. package/build/consts.js.map +0 -1
  39. package/build/utils/criteria.d.ts +0 -20
  40. package/build/utils/criteria.d.ts.map +0 -1
  41. package/build/utils/criteria.js +0 -252
  42. package/build/utils/criteria.js.map +0 -1
  43. package/build/utils/index.d.ts +0 -3
  44. package/build/utils/index.d.ts.map +0 -1
  45. package/build/utils/index.js +0 -3
  46. package/build/utils/index.js.map +0 -1
  47. package/src/consts.ts +0 -3
  48. package/src/utils/criteria.ts +0 -252
  49. package/src/utils/index.ts +0 -3
  50. package/tests/criteria.spec.ts +0 -121
package/README.md CHANGED
@@ -1,13 +1,18 @@
1
1
  # @owlmeans/state
2
2
 
3
- In-memory reactive state management with subscription-based updates for OwlMeans apps.
3
+ The framework's client store: an in-memory `Resource` with live subscriptions.
4
4
 
5
5
  ## Overview
6
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
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`
11
16
 
12
17
  ## Installation
13
18
 
@@ -17,91 +22,110 @@ bun add @owlmeans/state
17
22
 
18
23
  ## Usage
19
24
 
20
- Create and register a state resource in context setup:
25
+ Register a store per record type, and name it once with the type attached:
21
26
 
22
27
  ```typescript
23
- import { createStateResource } from '@owlmeans/state'
28
+ import { appendStateResource, stateAlias } from '@owlmeans/state'
24
29
 
25
- // In context.ts
26
- context.registerResource(createStateResource<ProjectState>('project-state'))
30
+ export const TASKS = stateAlias<Task>('tasks')
31
+
32
+ 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)
35
+
36
+ return context
37
+ }
38
+
39
+ const tasks = context.getStateResource(TASKS) // StateResource<Task>
27
40
  ```
28
41
 
29
- Subscribe to state changes in a service or component:
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.
44
+
45
+ Read it from React with the hooks in [`@owlmeans/client`](../client):
30
46
 
31
47
  ```typescript
32
- import { DEFAULT_ID } from '@owlmeans/state'
33
- import type { StateModel, StateResource } from '@owlmeans/state'
34
-
35
- const resource = context.resource<StateResource<ProjectState>>('project-state')
36
-
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
- })
48
+ import { useStoreList, useStoreModel } from '@owlmeans/client'
49
+
50
+ const task = useStoreModel<Task>(id, 'tasks') // one record, live
51
+ const open = useStoreList<Task>({ query: { status: 'open' }, resource: 'tasks' })
44
52
  ```
45
53
 
46
- Update state and commit:
54
+ Write through the resource, or through the model a subscription handed you:
47
55
 
48
56
  ```typescript
49
- const [model] = resource.subscribe({ id: projectId, listener: ... })[1]
50
- model.update({ status: 'active' })
51
- model.commit() // triggers listeners
57
+ const tasks = context.getStateResource(TASKS)
58
+
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()
63
+
64
+ model.update({ status: 'done' }) // merge and write in one step
52
65
  ```
53
66
 
54
67
  ## API
55
68
 
56
- ### `createStateResource<T>(alias?): StateResource<T>`
69
+ ### `createStateResource<T>(alias?, config?): StateResource<T>`
57
70
 
58
- Creates an in-memory resource with subscription support. Registers under `alias` (default: `'state'`).
71
+ The bare factory, when the resource is registered by hand. `appendStateResource` is the usual way.
59
72
 
60
- ### `StateResource<T>` (extends `Resource<T>`)
73
+ ### `StateConfig<T>`
61
74
 
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
75
+ | Field | Meaning |
76
+ |-------|---------|
77
+ | `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 |
70
80
 
71
- ### Criteria
81
+ ### `StateResource<T>` (extends `Resource<T>`, `PubSubResource<StateEvent<T>>`)
72
82
 
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.
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
91
+ announces itself as a `StateEvent` on the default channel
79
92
 
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 */ }
85
- })
86
- ```
93
+ 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.
87
96
 
88
97
  ### `StateModel<T>`
89
98
 
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
99
+ - `id` / `empty` / `record` `empty` is what "nothing loaded yet" looks like; `record` is the
100
+ 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
94
104
 
95
- ### `DEFAULT_ID`
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.
96
107
 
97
- ```typescript
98
- const DEFAULT_ID = '_default' // conventional ID for single-item resources
99
- ```
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.
100
124
 
101
125
  ## Related Packages
102
126
 
103
- - [`@owlmeans/resource`](../resource) — `Resource<T>` interface implemented by StateResource
104
- - [`@owlmeans/client`](../client) — `useStoreModel` / `useStoreList` React hooks for state resources
127
+ - [`@owlmeans/resource`](../resource) — the `Resource<T>` contract and the criteria engine
128
+ - [`@owlmeans/client`](../client) — `useStoreModel` / `useStoreList` React hooks
105
129
 
106
130
  <!-- owlmeans:agent-guidance:start -->
107
131
  ## Agent guidance
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/state",
4
- "version": "0.1.18-rc.7",
5
- "generatedAt": "2026-08-22T23:28:30.595Z",
4
+ "version": "0.1.18-rc.9",
5
+ "generatedAt": "2026-09-01T16:28:56.989Z",
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,28 @@ user-invocable: false
8
8
  # @owlmeans/state
9
9
 
10
10
  **Layer:** Core
11
- **Install:** `"@owlmeans/state": "^0.1.18-rc.7"` in `dependencies`
11
+ **Install:** `"@owlmeans/state": "^0.1.18-rc.9"` 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 interfacefull 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` |
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) |
30
+
31
+ The criteria evaluator (`matchCriteria`, `filterRecords`, `sortRecords`, `applyQuery`) lives in
32
+ **`@owlmeans/resource`** — the same engine the store runs on, for filtering a list you already hold.
27
33
 
28
34
  The React hooks live in **`@owlmeans/client`** — `useStoreModel`, `useStoreList`. They are not
29
35
  re-exported by `@owlmeans/web-client`, so import them from `@owlmeans/client` directly.
@@ -35,21 +41,31 @@ no setup at all. Register a named one per entity when records of different kinds
35
41
  id space:
36
42
 
37
43
  ```typescript
38
- import { appendStateResource } from '@owlmeans/state'
44
+ import { appendStateResource, stateAlias } from '@owlmeans/state'
39
45
 
40
- export const TASK_STATE = 'task-state'
46
+ export const TASKS = stateAlias<Task>('task-state')
41
47
 
42
48
  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
49
+ const context = makeClientContext<C, T>(cfg)
50
+ appendStateResource<C, T, Task>(context, TASKS)
47
51
  return context
48
52
  }
49
53
  ```
50
54
 
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.
55
+ Reach it with `context.getStateResource(TASKS)` a `StateAlias<T>` carries the record type, so the
56
+ accessor is typed without repeating `<Task>` at every call site. `getStateResource()` with no alias
57
+ answers the context's own default store (`state`), so always name the alias for a store you
58
+ appended. `appendStateResource` is idempotent: appending the same alias twice keeps the resource
59
+ already there, so a setup that runs more than once does not drop what the store has collected.
60
+
61
+ `StateConfig` decides how the store is keyed and what it shows before anything is loaded — all of it
62
+ optional:
63
+
64
+ | Field | Meaning |
65
+ |---|---|
66
+ | `id` | The field records are keyed by. Defaults to `id`. |
67
+ | `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. |
68
+ | `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
69
 
54
70
  ## Reading it from React
55
71
 
@@ -57,95 +73,132 @@ Reach it with `context.getStateResource<Task>(TASK_STATE)`. A typed accessor on
57
73
  import { useStoreList, useStoreModel } from '@owlmeans/client'
58
74
 
59
75
  // One record, by id. Re-renders whenever that record changes.
60
- const task = useStoreModel<Task>(id, TASK_STATE)
76
+ const task = useStoreModel<Task>(id, TASKS)
61
77
  task.record.title
62
78
 
63
79
  // A LIVE QUERY. Re-renders whenever any write changes which records match.
64
- const open = useStoreList<Task>({ query: { status: 'open' }, resource: TASK_STATE })
80
+ const open = useStoreList<Task>({ query: { status: 'open' }, resource: TASKS })
65
81
  open.map(model => model.record.title)
66
82
 
67
- // Everything in the store.
68
- const all = useStoreList<Task>({ query: {}, resource: TASK_STATE })
83
+ // Everything in the store, newest first.
84
+ const all = useStoreList<Task>({ sort: [{ field: 'createdAt', order: 'desc' }], resource: TASKS })
69
85
  ```
70
86
 
71
87
  A query subscription creates nothing and re-evaluates on every create, update and delete — a list
72
88
  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.
89
+ content, so a filter that changes narrows the list. An omitted `query` matches everything.
74
90
 
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:
91
+ **"Nothing loaded yet" is `model.empty`.** An id the store knows nothing about yields a model whose
92
+ `empty` is true, and nothing is written into the store on the way — so `useStoreModel` never throws
93
+ for missing data, and a screen bound to an unknown id does not put a blank row into every list
94
+ reading the same store:
79
95
 
80
96
  ```typescript
81
- const loading = task.record.id === DEFAULT_ID
97
+ if (task.empty) {
98
+ return <Spinner/>
99
+ }
82
100
  ```
83
101
 
102
+ `model.record` is still readable while empty: it holds the resource's configured `default`, or `{}`
103
+ when there is none. Calling `model.update(...)` or `model.commit()` on an empty model writes it —
104
+ including the default it was showing.
105
+
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
+
84
109
  ## Writing to it
85
110
 
86
111
  The server is the source of truth; the store is what the screen reads. Fetch, then write what came
87
112
  back into the store, and let the subscriptions render it:
88
113
 
89
114
  ```typescript
90
- const store = ctx.getStateResource<Task>(TASK_STATE)
115
+ const store = ctx.getStateResource(TASKS)
91
116
 
92
- const [tasks] = await ctx.entrypoint<ClientEntrypoint<Task[]>>(TASK_LIST).call()
93
- for (const task of tasks) {
94
- await store.save(task)
95
- }
117
+ const tasks = await ctx.entrypoint<ClientEntrypoint<Task[]>>(TASK_LIST).call()
118
+ await store.replace(tasks)
96
119
  ```
97
120
 
98
- `save` creates or replaces. `update` requires the record to exist. `delete(id)` removes it. Each
99
- one notifies every subscriber that cares.
121
+ `replace(records)` makes the store agree with an authoritative list: every record given is written,
122
+ and every record the list does not name is dropped. That is the shape of "the server just told us
123
+ what exists" — saving each record one by one leaves the ones deleted elsewhere behind, and one
124
+ write wakes the subscribers once instead of once per record. The store is rewritten before anything
125
+ is told about it, so a subscriber never sees the half-applied set.
126
+
127
+ For single records: `save` creates or replaces, `create` refuses an id already there, `update`
128
+ requires the record to exist, `delete(id)` removes it and answers with what it removed, `take(id)`
129
+ is the same read but throws when the record is absent, `purge(where)` bulk-deletes (and refuses an
130
+ empty criteria object rather than emptying the store), and `clear()` drops everything. Each one
131
+ notifies every subscriber that cares.
132
+
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.
100
136
 
101
137
  ### The commit rule
102
138
 
103
- `StateModel.record` is a COPY. Assigning to it changes nothing anyone else can see:
139
+ `StateModel.record` is a SNAPSHOT. Assigning to it changes nothing anyone else can see:
104
140
 
105
141
  ```typescript
106
- model.record.title = 'renamed' // WRONG — a silent no-op, nothing re-renders
107
- model.update({ title: 'renamed' }) // RIGHT — merges and commits
142
+ model.record.title = 'renamed' // WRONG — a silent no-op, nothing re-renders
143
+ await model.update({ title: 'renamed' }) // RIGHT — merges and commits
108
144
  ```
109
145
 
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.
146
+ `update(patch)` merges and commits in one step batch several fields into one patch rather than
147
+ writing them one at a time. `commit()` writes what `record` currently holds, which is how an empty
148
+ model bound to a `default` is persisted as it stands. `clear()` deletes the record and leaves the
149
+ model empty again. The working copy is replaced rather than mutated on every write, so the record a
150
+ caller is holding never changes underneath it and two models of the same record stay comparable by
151
+ reference — which is what lets a React subscriber tell a real change from an unrelated one.
113
152
 
114
153
  ## Querying
115
154
 
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:
155
+ Reads take the same criteria language as the server resources, so a filter written for an endpoint
156
+ means the same thing applied locally:
118
157
 
119
158
  ```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'] } })
159
+ await store.get(id) // the record, or UnknownRecordError
160
+ await store.load({ status: 'open' }) // the first match, or null
161
+ await store.list({ status: ['open', 'blocked'] }) // { items, total }
162
+ await store.list({ status: 'open' }, { sort: ['createdAt'], size: 20 })
163
+ await store.count({ status: 'open' })
124
164
  ```
125
165
 
126
166
  - A bare value is equality; a bare **array means "any of these"**.
127
167
  - Operators: `$eq $ne $gt $gte $lt $lte $in $nin $exists $null $like $ilike $regex $startsWith
128
168
  $endsWith $between $contains $contained $overlaps`, and `$and $or $not` to combine.
129
169
  - 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.
170
+ - `null` matches absence; a criteria value of `undefined` is SKIPPED — an untouched filter must not
171
+ empty the list.
172
+ - `Sort<T>` is a field name (ascending) or `{ field, order: 'asc' | 'desc' }`.
133
173
 
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.
174
+ `list()` returns `{ items, total }` and is **unpaged**: the store is already in memory and a screen
175
+ reading it expects all of it. Ask for a `size` to page, and `size: 0` still means no limit. A `page`
176
+ with no `size` throws `UnsupportedArgumentError('page-without-size')` — there is no default page
177
+ size to count against.
136
178
 
137
179
  ## Subscribing outside React
138
180
 
139
181
  ```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
182
+ const stopOne = store.watch(id, model => { }) // one record
183
+ const stopMany = store.query({ status: 'open' }, models => { }) // a live query
184
+ const stopAll = store.query(undefined, models => { … }, { sort: ['createdAt'] })
143
185
  ```
144
186
 
145
- Subscribing the same listener function twice throws hold the unsubscribe and call it instead.
187
+ Both are **synchronous** and both are seeded before they return: the listener is called with the
188
+ 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.
190
+
191
+ Writes announce themselves on the default channel, so `publish` is for what the store cannot know
192
+ it did — a change that arrived from elsewhere, or a channel of a caller's own:
193
+
194
+ ```typescript
195
+ const stop = await store.subscribe(event => { … }) // every write: StateEvent<T>
196
+ await store.publish({ type: 'set', records: [task] }, 'from-socket')
197
+ const once = await store.subscribe(handler, { channel: 'from-socket', once: true, ttl: 60 })
198
+ ```
146
199
 
147
200
  ## Depends On
148
201
 
149
- - `@owlmeans/resource` — `StateResource` extends `Resource`
202
+ - `@owlmeans/resource` — `StateResource` extends `Resource` and `PubSubResource`
150
203
  - `@owlmeans/context` — for `getStateResource`
151
204
  - React hooks: `@owlmeans/client`
package/build/errors.d.ts CHANGED
@@ -1,10 +1,17 @@
1
1
  import { ResourceError } from '@owlmeans/resource';
2
- export declare class StateToolingError extends ResourceError {
3
- static typeName: string;
4
- constructor(msg: string);
5
- }
6
- export declare class StateListenerError extends StateToolingError {
2
+ /**
3
+ * The resource was asked for something its {@link StateConfig} does not allow. Both cases are
4
+ * wiring mistakes rather than missing data, so they throw instead of answering with nothing.
5
+ */
6
+ export declare class StateConfigError extends ResourceError {
7
7
  static typeName: string;
8
+ /**
9
+ * A record was addressed without an id on a resource that holds many of them. Only a `single`
10
+ * resource has a record that needs no naming.
11
+ */
12
+ static readonly NonSingle: string;
13
+ /** A write carried no value for the resource's id field, and nothing here mints one. */
14
+ static readonly NoId: string;
8
15
  constructor(msg: string);
9
16
  }
10
17
  //# sourceMappingURL=errors.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAElD,qBAAa,iBAAkB,SAAQ,aAAa;IAClD,OAAuB,QAAQ,SAAqC;gBAExD,GAAG,EAAE,MAAM;CAIxB;AAED,qBAAa,kBAAmB,SAAQ,iBAAiB;IACvD,OAAuB,QAAQ,SAA0C;gBAE7D,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;gBAEjC,GAAG,EAAE,MAAM;CAIxB"}
package/build/errors.js CHANGED
@@ -1,18 +1,21 @@
1
1
  import { ResourceError } from '@owlmeans/resource';
2
- export class StateToolingError extends ResourceError {
3
- static typeName = `${ResourceError.typeName}Tooling`;
2
+ /**
3
+ * The resource was asked for something its {@link StateConfig} does not allow. Both cases are
4
+ * wiring mistakes rather than missing data, so they throw instead of answering with nothing.
5
+ */
6
+ export class StateConfigError extends ResourceError {
7
+ static typeName = `${ResourceError.typeName}StateConfig`;
8
+ /**
9
+ * A record was addressed without an id on a resource that holds many of them. Only a `single`
10
+ * resource has a record that needs no naming.
11
+ */
12
+ static NonSingle = 'non-single';
13
+ /** A write carried no value for the resource's id field, and nothing here mints one. */
14
+ static NoId = 'no-id';
4
15
  constructor(msg) {
5
- super(`tooling:${msg}`);
6
- this.type = StateToolingError.typeName;
16
+ super(`state-config:${msg}`);
17
+ this.type = StateConfigError.typeName;
7
18
  }
8
19
  }
9
- export class StateListenerError extends StateToolingError {
10
- static typeName = `${StateToolingError.typeName}Listener`;
11
- constructor(msg) {
12
- super(`listener:${msg}`);
13
- this.type = StateListenerError.typeName;
14
- }
15
- }
16
- ResourceError.registerErrorClass(StateToolingError);
17
- ResourceError.registerErrorClass(StateListenerError);
20
+ ResourceError.registerErrorClass(StateConfigError);
18
21
  //# sourceMappingURL=errors.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAElD,MAAM,OAAO,iBAAkB,SAAQ,aAAa;IAC3C,MAAM,CAAU,QAAQ,GAAG,GAAG,aAAa,CAAC,QAAQ,SAAS,CAAA;IAEpE,YAAY,GAAW;QACrB,KAAK,CAAC,WAAW,GAAG,EAAE,CAAC,CAAA;QACvB,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC,QAAQ,CAAA;IACxC,CAAC;;AAGH,MAAM,OAAO,kBAAmB,SAAQ,iBAAiB;IAChD,MAAM,CAAU,QAAQ,GAAG,GAAG,iBAAiB,CAAC,QAAQ,UAAU,CAAA;IAEzE,YAAY,GAAW;QACrB,KAAK,CAAC,YAAY,GAAG,EAAE,CAAC,CAAA;QACxB,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC,QAAQ,CAAA;IACzC,CAAC;;AAGH,aAAa,CAAC,kBAAkB,CAAC,iBAAiB,CAAC,CAAA;AACnD,aAAa,CAAC,kBAAkB,CAAC,kBAAkB,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;;AAGH,aAAa,CAAC,kBAAkB,CAAC,gBAAgB,CAAC,CAAA"}
@@ -0,0 +1,16 @@
1
+ import type { ResourceRecord } from '@owlmeans/resource';
2
+ import type { StateAlias } from './types.js';
3
+ /**
4
+ * Name a state resource once, with the record type it holds attached:
5
+ *
6
+ * ```typescript
7
+ * export const TASKS = stateAlias<Task>('tasks')
8
+ * const tasks = context.getStateResource(TASKS) // StateResource<Task>
9
+ * ```
10
+ *
11
+ * The handle is the string itself at runtime — the type rides along only so that every reader of
12
+ * the alias gets the record type without repeating it, and so that a mismatch is a compile error
13
+ * instead of a record shaped like nothing anyone expected.
14
+ */
15
+ export declare const stateAlias: <T extends ResourceRecord>(alias: string) => StateAlias<T>;
16
+ //# sourceMappingURL=helper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"helper.d.ts","sourceRoot":"","sources":["../src/helper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AACxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,SAAS,cAAc,EAAE,OAAO,MAAM,KAAG,UAAU,CAAC,CAAC,CACzD,CAAA"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Name a state resource once, with the record type it holds attached:
3
+ *
4
+ * ```typescript
5
+ * export const TASKS = stateAlias<Task>('tasks')
6
+ * const tasks = context.getStateResource(TASKS) // StateResource<Task>
7
+ * ```
8
+ *
9
+ * The handle is the string itself at runtime — the type rides along only so that every reader of
10
+ * the alias gets the record type without repeating it, and so that a mismatch is a compile error
11
+ * instead of a record shaped like nothing anyone expected.
12
+ */
13
+ export const stateAlias = (alias) => alias;
14
+ //# sourceMappingURL=helper.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"helper.js","sourceRoot":"","sources":["../src/helper.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,CAA2B,KAAa,EAAiB,EAAE,CACnF,KAAsB,CAAA"}
package/build/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type * from './types.js';
2
- export * from './consts.js';
3
2
  export * from './errors.js';
3
+ export * from './helper.js';
4
4
  export * from './resource.js';
5
- export * from './utils/criteria.js';
5
+ export * from './utils/model.js';
6
6
  //# 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,qBAAqB,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"}
package/build/index.js CHANGED
@@ -1,5 +1,5 @@
1
- export * from './consts.js';
2
1
  export * from './errors.js';
2
+ export * from './helper.js';
3
3
  export * from './resource.js';
4
- export * from './utils/criteria.js';
4
+ export * from './utils/model.js';
5
5
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,qBAAqB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,kBAAkB,CAAA"}