@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/src/resource.ts CHANGED
@@ -1,336 +1,454 @@
1
- import { appendContextual } from '@owlmeans/context'
2
- import { MisshapedRecord, RecordExists, UnknownRecordError, UnsupportedArgumentError } from '@owlmeans/resource'
3
- import { prepareListOptions } from '@owlmeans/resource'
4
- import type { ListCriteria, LifecycleOptions, ListResult, ResourceRecord } from '@owlmeans/resource'
5
- import type { StateListener, StateModel, StateResource, StateResourceAppend, StateSubscriptionOption } from './types.js'
6
- import { DEFAULT_ALIAS, DEFAULT_ID } from './consts'
7
- import { StateListenerError } from './errors.js'
1
+ import { appendContextual, type BasicConfig as Config, type BasicContext as Context } from '@owlmeans/context'
2
+ import { RecordExists, recordQueryHelper, UnknownRecordError, UnsupportedArgumentError, type Criteria, type FirstOptions, type ListOptions, type ResourceRecord, type SubscribeOptions, type Ttl, type Unsubscribe, type WriteOptions } from '@owlmeans/resource'
3
+ import { StateConfigError } from './errors.js'
8
4
  import { createStateModel } from './utils/model.js'
9
- import { filterRecords, sortRecords } from './utils/criteria.js'
10
- import type { BasicContext as Context, BasicConfig as Config } from '@owlmeans/context'
5
+ import type {
6
+ StateConfig, StateEvent, StateModel, StateResource, StateResourceAppend
7
+ } from './types.js'
8
+ import { CHANGES, SOLE, STATE } from './consts.local.js'
9
+ import type { QueryWatch, Subscription } from './types.local.js'
10
+
11
+ /** Milliseconds until a subscription expires: a number is seconds from now, a Date the instant. */
12
+ const expiresIn = (ttl: Ttl): number =>
13
+ ttl instanceof Date ? ttl.getTime() - Date.now() : ttl * 1000
14
+
15
+ export const createStateResource = <T extends ResourceRecord>(
16
+ alias: string = STATE, cfg?: StateConfig<T>
17
+ ): StateResource<T> => {
18
+ const config: StateConfig<T> = { ...cfg }
19
+ const idField = (config.id ?? 'id') as keyof T & string
20
+ const single = config.single === true
21
+
22
+ const store = new Map<string, T>()
23
+
24
+ /** One registry per kind of subscriber: a key, a query, and the change stream. */
25
+ const watchers = new Map<string, Set<(model: StateModel<T>) => void>>()
26
+ const queries = new Set<QueryWatch<T>>()
27
+ const subscribers = new Set<Subscription<T>>()
11
28
 
12
- export const createStateResource = <R extends ResourceRecord>(alias: string = DEFAULT_ALIAS): StateResource<R> => {
13
- const location = `state-resource:${alias}`
29
+ /**
30
+ * The model handed out for a key, kept until the record behind it is replaced. React compares
31
+ * snapshots by reference, so rebuilding a model on every unrelated write would re-render every
32
+ * screen bound to the store.
33
+ */
34
+ const models = new Map<string, { from: T | undefined, model: StateModel<T> }>()
14
35
 
15
- const store: { [id: string]: R } = {}
16
- const recordToListener = new Map<string, Set<StateListener<R>>>()
17
- const listenerToRecord = new Map<StateListener<R>, string[]>()
18
- const globalListeners: StateListener<R>[] = []
19
- const systemToListeners: Record<string, StateListener<R>> = {}
36
+ const records = (): T[] => [...store.values()]
20
37
 
21
38
  /**
22
- * Live query subscriptions.
39
+ * The key a record is filed under.
23
40
  *
24
- * An id subscription is answered from a key; a query has to be re-evaluated against the whole
25
- * store whenever anything changes, because a write can add a record to a set it was not in.
26
- * Kept in two registries for the same reason the id side is: a React subscriber arrives with a
27
- * `_systemId` and must dedup across re-renders, while a plain listener must not silently
28
- * subscribe twice.
41
+ * @throws {StateConfigError} `NoId` — nothing here mints ids, so a record without one is
42
+ * misfiled rather than new.
29
43
  */
30
- interface QuerySubscription {
31
- query: ListCriteria
32
- listener: StateListener<R>
33
- systemId?: string
44
+ const keyOf = (record: Partial<T>): string => {
45
+ if (single) {
46
+ return SOLE
47
+ }
48
+ const id = record[idField] as unknown
49
+ if (typeof id !== 'string' || id === '') {
50
+ throw new StateConfigError(StateConfigError.NoId)
51
+ }
52
+
53
+ return id
34
54
  }
35
- const listenerToQuery = new Map<StateListener<R>, QuerySubscription>()
36
- const systemToQueries: Record<string, QuerySubscription> = {}
37
55
 
38
- type StoreKey = keyof typeof store
56
+ /**
57
+ * The key an id addresses.
58
+ *
59
+ * @throws {StateConfigError} `NonSingle` — an absent id on a resource that holds many records.
60
+ */
61
+ const keyFor = (id: string | undefined): string => {
62
+ if (id == null) {
63
+ if (!single) {
64
+ throw new StateConfigError(StateConfigError.NonSingle)
65
+ }
66
+
67
+ return SOLE
68
+ }
39
69
 
40
- const _notifyGlobalListeners = (records: R[]) => {
41
- globalListeners.forEach(listener => listener(
42
- records.map(record => createStateModel(record, resource))
43
- ))
70
+ return single ? SOLE : id
44
71
  }
45
72
 
46
- const _usubscribe = (params: StateSubscriptionOption<R>) => () => {
47
- listenerToQuery.delete(params.listener)
48
- Object.entries(systemToQueries).some(([key, subscription]) => {
49
- if (subscription.listener === params.listener) {
50
- delete systemToQueries[key]
51
- return true
52
- }
73
+ /** The key a record already in the store is filed under — its id is known to be there. */
74
+ const storedKey = (record: T): string =>
75
+ single ? SOLE : record[idField] as unknown as string
53
76
 
54
- return false
55
- })
77
+ /**
78
+ * A read by id. On a single resource the one record answers only to its own id, so asking for
79
+ * a different one is a miss rather than the sole record under a wrong name.
80
+ */
81
+ const read = (id: string): T | null => {
82
+ const record = store.get(keyFor(id))
83
+ if (record == null) {
84
+ return null
85
+ }
86
+ const own = record[idField] as unknown
87
+ if (single && own != null && own !== id) {
88
+ return null
89
+ }
90
+
91
+ return record
92
+ }
93
+
94
+ const first = (idOrWhere: string | Criteria<T>, opts?: FirstOptions<T>): T | null =>
95
+ typeof idOrWhere === 'string' ? read(idOrWhere) : recordQueryHelper.firstMatch(records(), idOrWhere, opts)
96
+
97
+ /** The store keeps no expiring records, so a ttl would be silently dropped. */
98
+ const refuseTtl = (opts?: WriteOptions): void => {
99
+ if (opts?.ttl != null) {
100
+ throw new UnsupportedArgumentError('ttl')
101
+ }
102
+ }
103
+
104
+ const modelFor = (key: string): StateModel<T> => {
105
+ const record = store.get(key)
106
+ const cached = models.get(key)
107
+ if (cached != null && cached.from === record) {
108
+ return cached.model
109
+ }
110
+
111
+ const id = (record?.[idField] as unknown as string | undefined) ?? (key === SOLE ? undefined : key)
112
+ const model = createStateModel<T>({
113
+ id,
114
+ record,
115
+ default: config.default,
116
+ write: async value => {
117
+ const next = { ...value } as T
118
+ if (!single) {
119
+ (next as Record<string, unknown>)[idField] = key
120
+ }
56
121
 
57
- const ids = listenerToRecord.get(params.listener)
58
- listenerToRecord.delete(params.listener)
59
- ids?.forEach(id => {
60
- const listeners = recordToListener.get(id)
61
- if (listeners != null) {
62
- listeners.delete(params.listener)
63
- if (listeners.size === 0) {
64
- recordToListener.delete(id)
122
+ return write(key, next)
123
+ },
124
+ drop: async () => {
125
+ const removed = store.get(key)
126
+ if (removed == null) {
127
+ return
65
128
  }
129
+ store.delete(key)
130
+ notify('remove', [[key, removed]])
66
131
  }
67
132
  })
68
- Object.entries(systemToListeners).some(([key, listener]) => {
69
- if (listener === params.listener) {
70
- delete systemToListeners[key]
71
- return true
72
- }
133
+ models.set(key, { from: record, model })
73
134
 
74
- return false
75
- })
135
+ return model
76
136
  }
77
137
 
78
- const _queryModels = (query: ListCriteria): StateModel<R>[] =>
79
- filterRecords(Object.values(store) as R[], query).map(record => createStateModel(record, resource))
80
-
81
138
  /**
82
- * Re-run every live query and hand each subscriber its current answer.
139
+ * The model for "nothing is addressed yet" — one instance, reused.
83
140
  *
84
- * Deliberately unconditional: deciding whether a write could have changed a given query's
85
- * result is the same work as re-running it, and getting that wrong leaves a list stale with
86
- * nothing to indicate it.
141
+ * It must be the SAME reference every time: a React subscriber compares snapshots by identity,
142
+ * and a freshly built model would read as a change on every render and loop. Writing through it
143
+ * is refused rather than silently dropped, because a caller that writes without an id has lost
144
+ * track of which record it meant.
87
145
  */
88
- const _notifyQueryListeners = () => {
89
- for (const subscription of [...listenerToQuery.values(), ...Object.values(systemToQueries)]) {
90
- subscription.listener(_queryModels(subscription.query), subscription.systemId)
146
+ let blank: StateModel<T> | undefined
147
+ const emptyModel = (): StateModel<T> => blank ??= createStateModel<T>({
148
+ id: undefined,
149
+ record: undefined,
150
+ default: config.default,
151
+ write: async () => { throw new StateConfigError(StateConfigError.NoId) },
152
+ drop: async () => { throw new StateConfigError(StateConfigError.NoId) }
153
+ })
154
+
155
+ const queryModels = (watch: QueryWatch<T>): StateModel<T>[] =>
156
+ recordQueryHelper.sortRecords(recordQueryHelper.filterRecords(records(), watch.where), watch.opts?.sort)
157
+ .map(record => modelFor(storedKey(record)))
158
+
159
+ const same = (left: StateModel<T>[], right: StateModel<T>[]): boolean =>
160
+ left.length === right.length && left.every((model, index) => model === right[index])
161
+
162
+ /** Deliver on one channel. Returns what the handlers gave back, for a caller that awaits. */
163
+ const deliver = (event: StateEvent<T>, channel: string): Array<void | Promise<void>> => {
164
+ const delivered: Array<void | Promise<void>> = []
165
+ for (const subscription of [...subscribers]) {
166
+ if (subscription.channel !== channel) {
167
+ continue
168
+ }
169
+ if (subscription.once) {
170
+ subscribers.delete(subscription)
171
+ if (subscription.timer != null) {
172
+ clearTimeout(subscription.timer)
173
+ }
174
+ }
175
+ delivered.push(subscription.handler(event))
91
176
  }
177
+
178
+ return delivered
92
179
  }
93
180
 
94
- const _notifyListeners = (record: R) => {
95
- for (const listener of recordToListener.get(record.id!) ?? []) {
96
- listener([createStateModel(record, resource)])
181
+ /**
182
+ * Tell everyone what changed. Key watchers hear only about their own record; every live query
183
+ * is re-evaluated, because a write can move a record into a set it was not in and deciding
184
+ * whether it did is the same work as re-running the query.
185
+ */
186
+ const notify = (type: StateEvent<T>['type'], changed: Array<[string, T]>): void => {
187
+ if (changed.length < 1) {
188
+ return
189
+ }
190
+ for (const [key] of changed) {
191
+ const listeners = watchers.get(key)
192
+ if (listeners == null) {
193
+ continue
194
+ }
195
+ const model = modelFor(key)
196
+ for (const listener of [...listeners]) {
197
+ listener(model)
198
+ }
97
199
  }
98
- for (const key of Object.keys(systemToListeners)) {
99
- const [, ...idsProto] = key.split(':')
100
- const ids = idsProto.join(':').split(',')
101
- if (ids.includes(record.id!)) {
102
- systemToListeners[key](ids.map(id => createStateModel(store[id], resource)), key)
200
+ for (const watch of [...queries]) {
201
+ const current = queryModels(watch)
202
+ if (same(watch.last, current)) {
203
+ continue
204
+ }
205
+ watch.last = current
206
+ watch.listener(current)
207
+ }
208
+ deliver({ type, records: changed.map(([, record]) => record) }, CHANGES)
209
+ /**
210
+ * A key nobody watches and nothing is stored under has no model worth keeping — otherwise the
211
+ * cache grows with every record the store ever held. A watched key keeps its entry, so an
212
+ * empty model stays the same object and a screen bound to a deleted record does not re-render
213
+ * on every unrelated write.
214
+ */
215
+ for (const [key] of changed) {
216
+ if (!store.has(key) && !watchers.has(key)) {
217
+ models.delete(key)
103
218
  }
104
219
  }
105
220
  }
106
221
 
107
- const resource: StateResource<R> = appendContextual<StateResource<R>>(alias, {
108
- get: async (id, field, opts) => {
109
- const record = await resource.load(id, field, opts)
110
- if (record == null) {
111
- throw new UnknownRecordError(id)
112
- }
222
+ const write = (key: string, record: Partial<T>): T => {
223
+ const value = { ...record } as T
224
+ store.set(key, value)
225
+ notify('set', [[key, value]])
113
226
 
114
- return record as any
115
- },
227
+ return value
228
+ }
116
229
 
117
- load: async (id, field, opts) => {
118
- if (field != null) {
119
- throw new UnsupportedArgumentError(`${location}:get:filed`)
120
- }
121
- if (opts != null) {
122
- throw new UnsupportedArgumentError(`${location}:get:opts`)
123
- }
124
- const record = store[id] as R | undefined
230
+ const resource: StateResource<T> = appendContextual<StateResource<T>>(alias, {
231
+ config,
125
232
 
233
+ get: async (idOrWhere: string | Criteria<T>, opts?: FirstOptions<T>): Promise<T> => {
234
+ const record = first(idOrWhere, opts)
126
235
  if (record == null) {
127
- return null
236
+ throw new UnknownRecordError(typeof idOrWhere === 'string' ? idOrWhere : 'criteria')
128
237
  }
129
238
 
130
- return record as any
239
+ return record
131
240
  },
132
241
 
133
- /**
134
- * The `Resource` list contract, criteria and pager included.
135
- *
136
- * The default page size is 0 — meaning UNPAGED — rather than the ten a server resource
137
- * defaults to. Every existing caller of a state resource writes `list()` and expects the
138
- * whole store back; silently returning the first ten would be a truncation nothing reports.
139
- * Pagination happens when a pager is actually asked for.
140
- */
141
- list: async (criteria, opts) => {
142
- const options = prepareListOptions(0, criteria, opts)
143
- const matched = sortRecords(
144
- filterRecords(Object.values(store) as R[], options.criteria),
145
- options.pager?.sort
146
- )
147
-
148
- const size = options.pager?.size ?? 0
149
- const page = options.pager?.page ?? 0
150
- const items = size > 0 ? matched.slice(page * size, page * size + size) : matched
151
-
152
- const result: ListResult<R> = {
153
- items,
154
- pager: { ...options.pager, total: matched.length }
242
+ load: async (idOrWhere: string | Criteria<T>, opts?: FirstOptions<T>): Promise<T | null> =>
243
+ first(idOrWhere, opts),
244
+
245
+ /** Unpaged unless a size is asked for: a screen reading the store expects all of it. */
246
+ list: async (where?: Criteria<T>, opts?: ListOptions<T>) => {
247
+ if (opts?.page != null && opts.size == null) {
248
+ throw new UnsupportedArgumentError('page-without-size')
155
249
  }
156
250
 
157
- return result as any
251
+ return recordQueryHelper.applyQuery(records(), where, opts)
158
252
  },
159
253
 
160
- all: async () => Object.values(store) as any,
161
-
162
- match: async criteria => filterRecords(Object.values(store) as R[], criteria) as any,
254
+ count: async (where?: Criteria<T>) => recordQueryHelper.filterRecords(records(), where).length,
163
255
 
164
- save: async (record, opts) => {
165
- const id = record.id ?? DEFAULT_ID
166
- if (store[id] != null) {
167
- return resource.update(record, opts)
256
+ create: async (record: Partial<T>, opts?: WriteOptions) => {
257
+ refuseTtl(opts)
258
+ const key = keyOf(record)
259
+ if (store.has(key)) {
260
+ throw new RecordExists(key === SOLE ? alias : key)
168
261
  }
169
262
 
170
- return resource.create(record, opts as LifecycleOptions)
263
+ return write(key, record)
171
264
  },
172
265
 
173
- create: async (record, opts) => {
174
- if (!("id" in record) || record.id == null) {
175
- record.id = DEFAULT_ID
266
+ /** Replaces the stored record rather than merging into it, as the contract says. */
267
+ update: async (record: Partial<T>, opts?: WriteOptions) => {
268
+ refuseTtl(opts)
269
+ const key = keyOf(record)
270
+ if (!store.has(key)) {
271
+ throw new UnknownRecordError(key === SOLE ? alias : key)
176
272
  }
177
- if (store[record.id as StoreKey] != null) {
178
- throw new RecordExists(record.id)
179
- }
180
- if (opts != null) {
181
- throw new UnsupportedArgumentError(`${location}:create:opts`)
182
- }
183
- store[record.id as StoreKey] = record as unknown as R
184
-
185
- _notifyListeners(store[record.id as StoreKey])
186
- _notifyGlobalListeners([store[record.id as StoreKey]])
187
- _notifyQueryListeners()
188
273
 
189
- return record as any
274
+ return write(key, record)
190
275
  },
191
276
 
192
- update: async (record, opts) => {
193
- if (opts != null) {
194
- throw new UnsupportedArgumentError(`${location}:update:opts`)
195
- }
196
- if (!("id" in record) || record.id == null) {
197
- record.id = DEFAULT_ID
198
- }
199
- const reference = store[record.id as StoreKey]
200
- if (reference == null) {
201
- throw new UnknownRecordError(record.id)
202
- }
203
- Object.assign(reference, record)
204
-
205
- _notifyListeners(reference)
206
- _notifyGlobalListeners([reference])
207
- _notifyQueryListeners()
277
+ save: async (record: Partial<T>, opts?: WriteOptions) => {
278
+ refuseTtl(opts)
208
279
 
209
- return reference as any
280
+ return write(keyOf(record), record)
210
281
  },
211
282
 
212
- delete: async (id, opts) => {
213
- const _id = typeof id === 'string' ? id : id.id
214
- if (_id == null) {
215
- throw new UnsupportedArgumentError('id')
216
- }
217
- const record: R | null = store[_id as StoreKey] as R
283
+ delete: async (id: string) => {
284
+ const record = read(id)
218
285
  if (record == null) {
219
286
  return null
220
287
  }
221
- if (opts != null) {
222
- throw new UnsupportedArgumentError(`${location}:delete:opts`)
288
+ const key = keyFor(id)
289
+ store.delete(key)
290
+ notify('remove', [[key, record]])
291
+
292
+ return record
293
+ },
294
+
295
+ take: async (id: string) => {
296
+ const record = await resource.delete(id)
297
+ if (record == null) {
298
+ throw new UnknownRecordError(id)
223
299
  }
224
- delete store[_id as StoreKey]
225
300
 
226
- _notifyListeners(record)
227
- _notifyGlobalListeners([record])
228
- _notifyQueryListeners()
301
+ return record
302
+ },
303
+
304
+ purge: async (where: Criteria<T>) => {
305
+ if (where == null || Object.keys(where).length < 1) {
306
+ throw new UnsupportedArgumentError('purge:empty-criteria')
307
+ }
308
+ const removed = recordQueryHelper.filterRecords(records(), where)
309
+ .map((record): [string, T] => [storedKey(record), record])
310
+ for (const [key] of removed) {
311
+ store.delete(key)
312
+ }
313
+ notify('remove', removed)
229
314
 
230
- return record as any
315
+ return removed.length
231
316
  },
232
317
 
233
- pick: async (id, opts) => {
234
- const _id = typeof id === 'string' ? id : id.id
235
- if (_id == null) {
236
- throw new MisshapedRecord('id')
318
+ /**
319
+ * The store is rewritten before anything is told about it, so a subscriber never sees the
320
+ * half-applied set. On a `single` resource every record lands in the one slot, which means a
321
+ * list of several collapses to the last of them.
322
+ */
323
+ replace: async list => {
324
+ const written = list.map((record): [string, T] => [keyOf(record), { ...record }])
325
+ const keys = new Set(written.map(([key]) => key))
326
+ const dropped = [...store.entries()].filter(([key]) => !keys.has(key))
327
+ for (const [key] of dropped) {
328
+ store.delete(key)
237
329
  }
238
- const record = await resource.delete(_id, opts)
239
- if (record == null) {
240
- throw new UnknownRecordError(_id)
330
+ for (const [key, record] of written) {
331
+ store.set(key, record)
241
332
  }
333
+ notify('remove', dropped)
334
+ notify('set', written)
335
+ },
242
336
 
243
- return record as any
337
+ clear: async () => {
338
+ const dropped = [...store.entries()]
339
+ store.clear()
340
+ notify('remove', dropped)
244
341
  },
245
342
 
246
- subscribe: params => {
343
+ watch: (id, listener) => {
247
344
  /**
248
- * A query subscription is a different thing from an id subscription and cannot be layered
249
- * on top of one: it creates no placeholder record, and its answer is a set that changes
250
- * membership rather than a fixed list of records that change content. `id` wins when both
251
- * are given, so an existing caller that happens to pass a query keeps its behaviour.
345
+ * Watching before there is anything to watch is a RENDERING state, not a mistake.
346
+ *
347
+ * A screen binds to `useStoreModel(project.record.id)` while the project is still loading,
348
+ * so the id is legitimately absent for the first renders. Treating that as the
349
+ * configuration error it would be on `load()` or `save()` crashes the component tree over
350
+ * data that is simply not here yet. So an absent id on a listed resource watches nothing
351
+ * and reports an empty model; the error stays where it means something — a direct read or
352
+ * write with no id, which cannot be anything but a mistake.
252
353
  */
253
- if (params.query != null && params.id == null) {
254
- const subscription: QuerySubscription = { query: params.query, listener: params.listener }
255
- const models = _queryModels(params.query)
256
-
257
- if (params._systemId != null) {
258
- const key = `${params._systemId}:query:${JSON.stringify(params.query)}`
259
- if (systemToQueries[key] != null) {
260
- return [_usubscribe(params), models]
261
- }
262
- subscription.systemId = key
263
- systemToQueries[key] = subscription
264
- } else {
265
- if (listenerToQuery.has(params.listener)) {
266
- throw new StateListenerError('subscribed')
267
- }
268
- listenerToQuery.set(params.listener, subscription)
269
- }
354
+ if (id == null && !single) {
355
+ listener(emptyModel())
270
356
 
271
- return [_usubscribe(params), models]
357
+ return () => { }
272
358
  }
273
359
 
274
- const id = params.id ?? DEFAULT_ID
275
- const ids = Array.isArray(id) ? id : [id]
276
- const records = ids.map(id => {
277
- if (store[id] == null) {
278
- store[id as StoreKey] = { ...params.default, id } as R
279
- }
280
- return createStateModel(store[id], resource)
281
- })
282
- if (params._systemId != null) {
283
- const key = `${params._systemId}:${ids.join(",")}`
284
- if (systemToListeners[key] != null) {
285
- return [_usubscribe(params), records]
286
- }
360
+ const key = keyFor(id)
361
+ let listeners = watchers.get(key)
362
+ if (listeners == null) {
363
+ listeners = new Set()
364
+ watchers.set(key, listeners)
365
+ }
366
+ const bound = listeners
367
+ bound.add(listener)
368
+ /**
369
+ * Seeded before `watch` returns, and seeded with nothing when the store holds nothing: an
370
+ * id subscription creates NO record, so a screen bound to an unknown id gets an empty
371
+ * model rather than putting a blank row into every list reading the same store.
372
+ */
373
+ listener(modelFor(key))
287
374
 
288
- systemToListeners[key] = params.listener
289
- } else {
290
- if (listenerToRecord.has(params.listener)) {
291
- throw new StateListenerError('subscribed')
375
+ /**
376
+ * The cached model outlives the subscription on purpose. A React subscriber takes its first
377
+ * snapshot by watching and stopping again, and a rebuilt model would read as a changed value
378
+ * the moment it subscribes for real. Keys the store no longer holds are dropped on the next
379
+ * change to them instead.
380
+ */
381
+ return () => {
382
+ bound.delete(listener)
383
+ if (bound.size < 1) {
384
+ watchers.delete(key)
292
385
  }
293
- listenerToRecord.set(params.listener, ids)
294
- ids.forEach(id => {
295
- if (!recordToListener.has(id)) {
296
- recordToListener.set(id, new Set())
297
- }
298
- recordToListener.get(id)?.add(params.listener)
299
- })
300
386
  }
387
+ },
388
+
389
+ query: (where, listener, opts) => {
390
+ const watch: QueryWatch<T> = { where, opts, listener, last: [] }
391
+ queries.add(watch)
392
+ watch.last = queryModels(watch)
393
+ listener(watch.last)
301
394
 
302
- return [_usubscribe(params), records]
395
+ return () => { queries.delete(watch) }
303
396
  },
304
397
 
305
- listen: listener => {
306
- globalListeners.push(listener)
398
+ /**
399
+ * Writes announce themselves on the default channel, so publishing is for what the store
400
+ * cannot know it did — a change that arrived from elsewhere, or a channel of a caller's own.
401
+ */
402
+ publish: async (value: StateEvent<T>, channel?: string) => {
403
+ await Promise.all(deliver(value, channel ?? CHANGES))
404
+ },
307
405
 
308
- return () => {
309
- const index = globalListeners.indexOf(listener)
310
- if (index >= 0) {
311
- globalListeners.splice(index, 1)
406
+ subscribe: async (
407
+ handler: (value: StateEvent<T>) => void | Promise<void>, opts?: SubscribeOptions
408
+ ) => {
409
+ const subscription: Subscription<T> = {
410
+ handler, channel: opts?.channel ?? CHANGES, once: opts?.once === true
411
+ }
412
+ subscribers.add(subscription)
413
+
414
+ const stop: Unsubscribe = async () => {
415
+ subscribers.delete(subscription)
416
+ if (subscription.timer != null) {
417
+ clearTimeout(subscription.timer)
418
+ subscription.timer = undefined
312
419
  }
313
420
  }
314
- },
315
421
 
316
- erase: async () => {
317
- await Promise.all(Object.keys(store).map(key => resource.delete(key)))
422
+ if (opts?.ttl != null) {
423
+ subscription.timer = setTimeout(() => void stop(), expiresIn(opts.ttl))
424
+ }
425
+
426
+ return stop
318
427
  }
319
428
  })
320
429
 
321
430
  return resource
322
431
  }
323
432
 
324
- export const appendStateResource = <C extends Config, T extends Context<C>>(
325
- ctx: T, alias: string = DEFAULT_ALIAS
326
- ): T & StateResourceAppend => {
327
- const resource = createStateResource(alias)
328
-
433
+ /**
434
+ * Register a state resource on the context and expose `getStateResource`.
435
+ *
436
+ * Idempotent: appending the same alias twice keeps the resource that is already there, so a
437
+ * setup that runs more than once does not drop what the store has collected. The first alias
438
+ * appended is the one `getStateResource()` answers with when it is called without one.
439
+ */
440
+ export const appendStateResource = <
441
+ C extends Config, T extends Context<C>, R extends ResourceRecord = ResourceRecord
442
+ >(ctx: T, alias: string = STATE, cfg?: StateConfig<R>): T & StateResourceAppend => {
329
443
  const _ctx = ctx as T & StateResourceAppend
330
444
 
331
- _ctx.registerResource(resource)
445
+ if (!_ctx.hasResource(alias)) {
446
+ _ctx.registerResource(createStateResource<R>(alias, cfg))
447
+ }
448
+
332
449
  if (_ctx.getStateResource == null) {
333
- _ctx.getStateResource = alias => ctx.resource(alias ?? resource.alias)
450
+ _ctx.getStateResource = (<S extends ResourceRecord>(_alias?: string) =>
451
+ ctx.resource<StateResource<S>>(_alias ?? alias)) as StateResourceAppend['getStateResource']
334
452
  }
335
453
 
336
454
  return _ctx