@owlmeans/state 0.1.18-rc.2 → 0.1.18-rc.21

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