@owlmeans/state 0.1.18-rc.9 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -77
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/state/SKILL.md +38 -12
- package/build/consts.d.ts +3 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +3 -0
- package/build/consts.js.map +1 -0
- 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.map +1 -1
- package/build/errors.js.map +1 -1
- package/build/helper.d.ts.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/resource.d.ts +2 -2
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +37 -15
- package/build/resource.js.map +1 -1
- package/build/types.d.ts +2 -1
- 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 +20 -0
- package/build/utils/criteria.d.ts.map +1 -0
- package/build/utils/criteria.js +252 -0
- package/build/utils/criteria.js.map +1 -0
- package/build/utils/index.d.ts +3 -0
- package/build/utils/index.d.ts.map +1 -0
- package/build/utils/index.js +3 -0
- package/build/utils/index.js.map +1 -0
- package/build/utils/model.d.ts +1 -13
- package/build/utils/model.d.ts.map +1 -1
- 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/index.ts +1 -0
- package/src/resource.ts +42 -41
- package/src/types.local.ts +16 -0
- package/src/types.ts +2 -1
- package/src/utils/model.ts +1 -14
- package/src/utils/types.ts +15 -0
- package/tests/resource.spec.ts +52 -2
package/src/resource.ts
CHANGED
|
@@ -1,49 +1,17 @@
|
|
|
1
|
-
import { appendContextual } from '@owlmeans/context'
|
|
2
|
-
import type
|
|
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'
|
|
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'
|
|
11
3
|
import { StateConfigError } from './errors.js'
|
|
12
4
|
import { createStateModel } from './utils/model.js'
|
|
13
5
|
import type {
|
|
14
6
|
StateConfig, StateEvent, StateModel, StateResource, StateResourceAppend
|
|
15
7
|
} 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'
|
|
8
|
+
import { CHANGES, SOLE, STATE } from './consts.local.js'
|
|
9
|
+
import type { QueryWatch, Subscription } from './types.local.js'
|
|
28
10
|
|
|
29
11
|
/** Milliseconds until a subscription expires: a number is seconds from now, a Date the instant. */
|
|
30
12
|
const expiresIn = (ttl: Ttl): number =>
|
|
31
13
|
ttl instanceof Date ? ttl.getTime() - Date.now() : ttl * 1000
|
|
32
14
|
|
|
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
15
|
export const createStateResource = <T extends ResourceRecord>(
|
|
48
16
|
alias: string = STATE, cfg?: StateConfig<T>
|
|
49
17
|
): StateResource<T> => {
|
|
@@ -124,7 +92,7 @@ export const createStateResource = <T extends ResourceRecord>(
|
|
|
124
92
|
}
|
|
125
93
|
|
|
126
94
|
const first = (idOrWhere: string | Criteria<T>, opts?: FirstOptions<T>): T | null =>
|
|
127
|
-
typeof idOrWhere === 'string' ? read(idOrWhere) : firstMatch(records(), idOrWhere, opts)
|
|
95
|
+
typeof idOrWhere === 'string' ? read(idOrWhere) : recordQueryHelper.firstMatch(records(), idOrWhere, opts)
|
|
128
96
|
|
|
129
97
|
/** The store keeps no expiring records, so a ttl would be silently dropped. */
|
|
130
98
|
const refuseTtl = (opts?: WriteOptions): void => {
|
|
@@ -167,8 +135,25 @@ export const createStateResource = <T extends ResourceRecord>(
|
|
|
167
135
|
return model
|
|
168
136
|
}
|
|
169
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The model for "nothing is addressed yet" — one instance, reused.
|
|
140
|
+
*
|
|
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.
|
|
145
|
+
*/
|
|
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
|
+
|
|
170
155
|
const queryModels = (watch: QueryWatch<T>): StateModel<T>[] =>
|
|
171
|
-
sortRecords(filterRecords(records(), watch.where), watch.opts?.sort)
|
|
156
|
+
recordQueryHelper.sortRecords(recordQueryHelper.filterRecords(records(), watch.where), watch.opts?.sort)
|
|
172
157
|
.map(record => modelFor(storedKey(record)))
|
|
173
158
|
|
|
174
159
|
const same = (left: StateModel<T>[], right: StateModel<T>[]): boolean =>
|
|
@@ -263,10 +248,10 @@ export const createStateResource = <T extends ResourceRecord>(
|
|
|
263
248
|
throw new UnsupportedArgumentError('page-without-size')
|
|
264
249
|
}
|
|
265
250
|
|
|
266
|
-
return applyQuery(records(), where, opts)
|
|
251
|
+
return recordQueryHelper.applyQuery(records(), where, opts)
|
|
267
252
|
},
|
|
268
253
|
|
|
269
|
-
count: async (where?: Criteria<T>) => filterRecords(records(), where).length,
|
|
254
|
+
count: async (where?: Criteria<T>) => recordQueryHelper.filterRecords(records(), where).length,
|
|
270
255
|
|
|
271
256
|
create: async (record: Partial<T>, opts?: WriteOptions) => {
|
|
272
257
|
refuseTtl(opts)
|
|
@@ -320,7 +305,7 @@ export const createStateResource = <T extends ResourceRecord>(
|
|
|
320
305
|
if (where == null || Object.keys(where).length < 1) {
|
|
321
306
|
throw new UnsupportedArgumentError('purge:empty-criteria')
|
|
322
307
|
}
|
|
323
|
-
const removed = filterRecords(records(), where)
|
|
308
|
+
const removed = recordQueryHelper.filterRecords(records(), where)
|
|
324
309
|
.map((record): [string, T] => [storedKey(record), record])
|
|
325
310
|
for (const [key] of removed) {
|
|
326
311
|
store.delete(key)
|
|
@@ -356,6 +341,22 @@ export const createStateResource = <T extends ResourceRecord>(
|
|
|
356
341
|
},
|
|
357
342
|
|
|
358
343
|
watch: (id, listener) => {
|
|
344
|
+
/**
|
|
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.
|
|
353
|
+
*/
|
|
354
|
+
if (id == null && !single) {
|
|
355
|
+
listener(emptyModel())
|
|
356
|
+
|
|
357
|
+
return () => { }
|
|
358
|
+
}
|
|
359
|
+
|
|
359
360
|
const key = keyFor(id)
|
|
360
361
|
let listeners = watchers.get(key)
|
|
361
362
|
if (listeners == null) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Criteria, FirstOptions, ResourceRecord } from '@owlmeans/resource'
|
|
2
|
+
import type { StateEvent, StateModel } from './types.js'
|
|
3
|
+
|
|
4
|
+
export interface QueryWatch<T extends ResourceRecord> {
|
|
5
|
+
where?: Criteria<T>
|
|
6
|
+
opts?: FirstOptions<T>
|
|
7
|
+
listener: (models: StateModel<T>[]) => void
|
|
8
|
+
last: StateModel<T>[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Subscription<T extends ResourceRecord> {
|
|
12
|
+
handler: (value: StateEvent<T>) => void | Promise<void>
|
|
13
|
+
channel: string
|
|
14
|
+
once: boolean
|
|
15
|
+
timer?: ReturnType<typeof setTimeout>
|
|
16
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -59,7 +59,8 @@ export interface StateResource<T extends ResourceRecord> extends Resource<T>, Pu
|
|
|
59
59
|
* `undefined` addresses the one record of a `single` resource; on any other it throws, since
|
|
60
60
|
* there is nothing for it to mean.
|
|
61
61
|
*
|
|
62
|
-
*
|
|
62
|
+
* An absent id on a listed resource watches nothing and reports an empty model — a screen
|
|
63
|
+
* binds before the record supplying the id exists, and that is a loading state, not an error.
|
|
63
64
|
* @returns unsubscribe
|
|
64
65
|
*/
|
|
65
66
|
watch(id: string | undefined, listener: (model: StateModel<T>) => void): () => void
|
package/src/utils/model.ts
CHANGED
|
@@ -1,19 +1,6 @@
|
|
|
1
1
|
import type { ResourceRecord } from '@owlmeans/resource'
|
|
2
2
|
import type { StateModel } from '../types.js'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* What a model needs from the resource that made it: which record it stands for, and the two
|
|
6
|
-
* writes it can perform. The key stays on the resource side, so a model bound to the one record
|
|
7
|
-
* of a `single` resource works the same as one bound to an id.
|
|
8
|
-
*/
|
|
9
|
-
export interface StateModelBinding<T extends ResourceRecord> {
|
|
10
|
-
id: string | undefined
|
|
11
|
-
/** The stored record, or `undefined` when the store holds none — an EMPTY model. */
|
|
12
|
-
record: T | undefined
|
|
13
|
-
default?: () => T
|
|
14
|
-
write: (record: T) => Promise<T>
|
|
15
|
-
drop: () => Promise<void>
|
|
16
|
-
}
|
|
3
|
+
import type { StateModelBinding } from './types.js'
|
|
17
4
|
|
|
18
5
|
/**
|
|
19
6
|
* Wrap one record — or its absence — as something a screen can bind to.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ResourceRecord } from '@owlmeans/resource'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a model needs from the resource that made it: which record it stands for, and the two
|
|
5
|
+
* writes it can perform. The key stays on the resource side, so a model bound to the one record
|
|
6
|
+
* of a `single` resource works the same as one bound to an id.
|
|
7
|
+
*/
|
|
8
|
+
export interface StateModelBinding<T extends ResourceRecord> {
|
|
9
|
+
id: string | undefined
|
|
10
|
+
/** The stored record, or `undefined` when the store holds none — an EMPTY model. */
|
|
11
|
+
record: T | undefined
|
|
12
|
+
default?: () => T
|
|
13
|
+
write: (record: T) => Promise<T>
|
|
14
|
+
drop: () => Promise<void>
|
|
15
|
+
}
|
package/tests/resource.spec.ts
CHANGED
|
@@ -222,9 +222,59 @@ describe('@owlmeans/state — watching one record', () => {
|
|
|
222
222
|
stop()
|
|
223
223
|
})
|
|
224
224
|
|
|
225
|
-
|
|
225
|
+
/**
|
|
226
|
+
* A screen binds to `watch(project.id)` while the project is still loading, so an absent id is
|
|
227
|
+
* a rendering state and must not take the component tree down with it. Reading with no id is a
|
|
228
|
+
* different question — there the caller has lost track of which record it meant.
|
|
229
|
+
*/
|
|
230
|
+
test('watching with no id reports empty rather than throwing', async () => {
|
|
226
231
|
const resource = await seeded()
|
|
227
|
-
|
|
232
|
+
let model!: StateModel<Session>
|
|
233
|
+
const stop = resource.watch(undefined, current => { model = current })
|
|
234
|
+
|
|
235
|
+
expect(model.empty).toBe(true)
|
|
236
|
+
expect(model.id).toBeUndefined()
|
|
237
|
+
|
|
238
|
+
stop()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('the empty model is one reference, so a subscriber sees no change', async () => {
|
|
242
|
+
const resource = await seeded()
|
|
243
|
+
const seen: StateModel<Session>[] = []
|
|
244
|
+
const first = resource.watch(undefined, current => { seen.push(current) })
|
|
245
|
+
const second = resource.watch(undefined, current => { seen.push(current) })
|
|
246
|
+
|
|
247
|
+
expect(seen).toHaveLength(2)
|
|
248
|
+
expect(seen[0]).toBe(seen[1])
|
|
249
|
+
|
|
250
|
+
first()
|
|
251
|
+
second()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('writing through the empty model is refused', async () => {
|
|
255
|
+
const resource = await seeded()
|
|
256
|
+
let model!: StateModel<Session>
|
|
257
|
+
const stop = resource.watch(undefined, current => { model = current })
|
|
258
|
+
|
|
259
|
+
await expect(model.update({ token: 'x' } as Partial<Session>)).rejects.toThrow(StateConfigError)
|
|
260
|
+
|
|
261
|
+
stop()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The reason the empty model exists at all: the store it stands in for must be untouched. The
|
|
266
|
+
* placeholder record this replaced put a blank row into every list reading the same store.
|
|
267
|
+
*/
|
|
268
|
+
test('watching with no id writes nothing into the store', async () => {
|
|
269
|
+
const resource = await seeded()
|
|
270
|
+
const before = await resource.list()
|
|
271
|
+
const stop = resource.watch(undefined, () => { })
|
|
272
|
+
const after = await resource.list()
|
|
273
|
+
|
|
274
|
+
expect(after.total).toBe(before.total)
|
|
275
|
+
expect(after.items.some(item => item.id == null)).toBe(false)
|
|
276
|
+
|
|
277
|
+
stop()
|
|
228
278
|
})
|
|
229
279
|
})
|
|
230
280
|
|