@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.
- package/README.md +173 -68
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/state/SKILL.md +137 -58
- package/build/consts.local.d.ts +10 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +10 -0
- package/build/consts.local.js.map +1 -0
- package/build/errors.d.ts +12 -5
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +16 -13
- package/build/errors.js.map +1 -1
- package/build/helper.d.ts +16 -0
- package/build/helper.d.ts.map +1 -0
- package/build/helper.js +14 -0
- package/build/helper.js.map +1 -0
- package/build/index.d.ts +3 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -2
- package/build/index.js.map +1 -1
- package/build/resource.d.ts +12 -5
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +317 -207
- package/build/resource.js.map +1 -1
- package/build/types.d.ts +91 -50
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +15 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/build/utils/criteria.d.ts.map +1 -1
- package/build/utils/criteria.js.map +1 -1
- package/build/utils/model.d.ts +10 -2
- package/build/utils/model.d.ts.map +1 -1
- package/build/utils/model.js +26 -22
- package/build/utils/model.js.map +1 -1
- package/build/utils/types.d.ts +15 -0
- package/build/utils/types.d.ts.map +1 -0
- package/build/utils/types.js +2 -0
- package/build/utils/types.js.map +1 -0
- package/package.json +4 -4
- package/src/consts.local.ts +11 -0
- package/src/errors.ts +16 -13
- package/src/helper.ts +17 -0
- package/src/index.ts +3 -2
- package/src/resource.ts +359 -241
- package/src/types.local.ts +16 -0
- package/src/types.ts +98 -55
- package/src/utils/model.ts +35 -28
- package/src/utils/types.ts +15 -0
- package/tests/resource.spec.ts +347 -90
- package/build/.gitkeep +0 -0
- package/src/consts.ts +0 -3
- package/src/utils/criteria.ts +0 -252
- package/src/utils/index.ts +0 -3
- package/tests/criteria.spec.ts +0 -121
package/src/resource.ts
CHANGED
|
@@ -1,336 +1,454 @@
|
|
|
1
|
-
import { appendContextual } from '@owlmeans/context'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
*
|
|
39
|
+
* The key a record is filed under.
|
|
23
40
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
globalListeners.forEach(listener => listener(
|
|
42
|
-
records.map(record => createStateModel(record, resource))
|
|
43
|
-
))
|
|
70
|
+
return single ? SOLE : id
|
|
44
71
|
}
|
|
45
72
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
69
|
-
if (listener === params.listener) {
|
|
70
|
-
delete systemToListeners[key]
|
|
71
|
-
return true
|
|
72
|
-
}
|
|
133
|
+
models.set(key, { from: record, model })
|
|
73
134
|
|
|
74
|
-
|
|
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
|
-
*
|
|
139
|
+
* The model for "nothing is addressed yet" — one instance, reused.
|
|
83
140
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
227
|
+
return value
|
|
228
|
+
}
|
|
116
229
|
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
236
|
+
throw new UnknownRecordError(typeof idOrWhere === 'string' ? idOrWhere : 'criteria')
|
|
128
237
|
}
|
|
129
238
|
|
|
130
|
-
return record
|
|
239
|
+
return record
|
|
131
240
|
},
|
|
132
241
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
251
|
+
return recordQueryHelper.applyQuery(records(), where, opts)
|
|
158
252
|
},
|
|
159
253
|
|
|
160
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
263
|
+
return write(key, record)
|
|
171
264
|
},
|
|
172
265
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
274
|
+
return write(key, record)
|
|
190
275
|
},
|
|
191
276
|
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
280
|
+
return write(keyOf(record), record)
|
|
210
281
|
},
|
|
211
282
|
|
|
212
|
-
delete: async (id
|
|
213
|
-
const
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
315
|
+
return removed.length
|
|
231
316
|
},
|
|
232
317
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
239
|
-
|
|
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
|
-
|
|
337
|
+
clear: async () => {
|
|
338
|
+
const dropped = [...store.entries()]
|
|
339
|
+
store.clear()
|
|
340
|
+
notify('remove', dropped)
|
|
244
341
|
},
|
|
245
342
|
|
|
246
|
-
|
|
343
|
+
watch: (id, listener) => {
|
|
247
344
|
/**
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
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 (
|
|
254
|
-
|
|
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
|
|
357
|
+
return () => { }
|
|
272
358
|
}
|
|
273
359
|
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
395
|
+
return () => { queries.delete(watch) }
|
|
303
396
|
},
|
|
304
397
|
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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.
|
|
445
|
+
if (!_ctx.hasResource(alias)) {
|
|
446
|
+
_ctx.registerResource(createStateResource<R>(alias, cfg))
|
|
447
|
+
}
|
|
448
|
+
|
|
332
449
|
if (_ctx.getStateResource == null) {
|
|
333
|
-
_ctx.getStateResource =
|
|
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
|