@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/tests/resource.spec.ts
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { createStateResource,
|
|
3
|
-
import type { StateModel } from '@owlmeans/state'
|
|
2
|
+
import { createStateResource, StateConfigError } from '@owlmeans/state'
|
|
3
|
+
import type { StateEvent, StateModel } from '@owlmeans/state'
|
|
4
|
+
import type { ResourceRecord } from '@owlmeans/resource'
|
|
4
5
|
|
|
5
|
-
interface Task {
|
|
6
|
-
id?: string
|
|
6
|
+
interface Task extends ResourceRecord {
|
|
7
7
|
title?: string
|
|
8
8
|
status?: string
|
|
9
9
|
points?: number
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
interface Session extends ResourceRecord {
|
|
13
|
+
token?: string
|
|
14
|
+
user?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Doc extends ResourceRecord {
|
|
18
|
+
key?: string
|
|
19
|
+
body?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
const seeded = async () => {
|
|
13
23
|
const resource = createStateResource<Task>('tasks')
|
|
14
24
|
await resource.create({ id: 'a', title: 'Alpha', status: 'open', points: 3 })
|
|
@@ -18,145 +28,392 @@ const seeded = async () => {
|
|
|
18
28
|
return resource
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
describe('@owlmeans/state —
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
describe('@owlmeans/state — reads', () => {
|
|
32
|
+
test('list() is unpaged and always reports the total', async () => {
|
|
33
|
+
const resource = await seeded()
|
|
34
|
+
const result = await resource.list()
|
|
35
|
+
expect(result.items.map(item => item.id)).toEqual(['a', 'b', 'c'])
|
|
36
|
+
expect(result.total).toBe(3)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('list(where, opts) filters, sorts and pages', async () => {
|
|
40
|
+
const resource = await seeded()
|
|
41
|
+
const first = await resource.list(
|
|
42
|
+
{ status: ['open', 'done'] },
|
|
43
|
+
{ page: 0, size: 2, sort: [{ field: 'points', order: 'desc' }] }
|
|
44
|
+
)
|
|
45
|
+
expect(first.items.map(item => item.id)).toEqual(['b', 'a'])
|
|
46
|
+
expect(first.total).toBe(3)
|
|
47
|
+
|
|
48
|
+
const second = await resource.list(
|
|
49
|
+
{ status: ['open', 'done'] },
|
|
50
|
+
{ page: 1, size: 2, sort: [{ field: 'points', order: 'desc' }] }
|
|
51
|
+
)
|
|
52
|
+
expect(second.items.map(item => item.id)).toEqual(['c'])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// The store is unpaged, so a page number alone has no page size to count from. Answering with
|
|
56
|
+
// everything would silently ignore what the caller asked for.
|
|
57
|
+
test('a page without a size is refused', async () => {
|
|
58
|
+
const resource = await seeded()
|
|
59
|
+
await expect(resource.list(undefined, { page: 1 })).rejects.toThrow()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('count() answers without carrying the records', async () => {
|
|
63
|
+
const resource = await seeded()
|
|
64
|
+
expect(await resource.count()).toBe(3)
|
|
65
|
+
expect(await resource.count({ status: 'open' })).toBe(2)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('one record by id or by criteria', async () => {
|
|
69
|
+
const resource = await seeded()
|
|
70
|
+
expect((await resource.load('a'))?.title).toBe('Alpha')
|
|
71
|
+
expect((await resource.load({ status: 'done' }))?.id).toBe('b')
|
|
72
|
+
expect((await resource.load({ status: 'missing' }))).toBeNull()
|
|
73
|
+
expect((await resource.load('zz'))).toBeNull()
|
|
74
|
+
|
|
75
|
+
expect((await resource.get({ title: 'Gamma' })).id).toBe('c')
|
|
76
|
+
await expect(resource.get('zz')).rejects.toThrow()
|
|
77
|
+
await expect(resource.get({ status: 'missing' })).rejects.toThrow()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('a sort picks which of several matches comes first', async () => {
|
|
26
81
|
const resource = await seeded()
|
|
82
|
+
const lowest = await resource.get({ status: 'open' }, { sort: ['points'] })
|
|
83
|
+
expect(lowest.id).toBe('c')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('@owlmeans/state — writes', () => {
|
|
88
|
+
test('create refuses a record that is already there', async () => {
|
|
89
|
+
const resource = await seeded()
|
|
90
|
+
await expect(resource.create({ id: 'a' })).rejects.toThrow()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('update replaces the record and refuses an unknown one', async () => {
|
|
94
|
+
const resource = await seeded()
|
|
95
|
+
await resource.update({ id: 'a', title: 'Renamed' })
|
|
96
|
+
const record = await resource.get('a')
|
|
97
|
+
expect(record.title).toBe('Renamed')
|
|
98
|
+
expect(record.status).toBeUndefined()
|
|
99
|
+
|
|
100
|
+
await expect(resource.update({ id: 'zz' })).rejects.toThrow()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('save creates or replaces', async () => {
|
|
104
|
+
const resource = await seeded()
|
|
105
|
+
await resource.save({ id: 'd', title: 'Delta' })
|
|
106
|
+
await resource.save({ id: 'a', title: 'Alpha again' })
|
|
107
|
+
expect(await resource.count()).toBe(4)
|
|
108
|
+
expect((await resource.get('a')).title).toBe('Alpha again')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Nothing here mints ids, so a record without one is misfiled rather than new — inventing a key
|
|
112
|
+
// would put a record in the store that no screen can ever address again.
|
|
113
|
+
test('a write without an id is a configuration error', async () => {
|
|
114
|
+
const resource = await seeded()
|
|
115
|
+
await expect(resource.save({ title: 'nameless' })).rejects.toThrow(StateConfigError)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('the store keeps nothing that expires, so a ttl is refused', async () => {
|
|
119
|
+
const resource = await seeded()
|
|
120
|
+
await expect(resource.save({ id: 'a' }, { ttl: 60 })).rejects.toThrow()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('delete answers with null, take throws', async () => {
|
|
124
|
+
const resource = await seeded()
|
|
125
|
+
expect((await resource.delete('a'))?.id).toBe('a')
|
|
126
|
+
expect(await resource.delete('a')).toBeNull()
|
|
127
|
+
|
|
128
|
+
expect((await resource.take('b')).id).toBe('b')
|
|
129
|
+
await expect(resource.take('b')).rejects.toThrow()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('purge removes the matching records and refuses to empty the store', async () => {
|
|
133
|
+
const resource = await seeded()
|
|
134
|
+
await expect(resource.purge({})).rejects.toThrow()
|
|
135
|
+
expect(await resource.purge({ status: 'open' })).toBe(2)
|
|
136
|
+
expect((await resource.list()).items.map(item => item.id)).toEqual(['b'])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('replace makes the store agree with an authoritative list', async () => {
|
|
140
|
+
const resource = await seeded()
|
|
141
|
+
await resource.replace([
|
|
142
|
+
{ id: 'a', title: 'Alpha', status: 'done' },
|
|
143
|
+
{ id: 'z', title: 'Zeta', status: 'open' }
|
|
144
|
+
])
|
|
27
145
|
const { items } = await resource.list()
|
|
28
|
-
expect(items.map(item => item.id)).toEqual(['a', '
|
|
146
|
+
expect(items.map(item => item.id)).toEqual(['a', 'z'])
|
|
147
|
+
expect((await resource.get('a')).status).toBe('done')
|
|
29
148
|
})
|
|
30
149
|
|
|
31
|
-
test('
|
|
150
|
+
test('clear drops everything', async () => {
|
|
32
151
|
const resource = await seeded()
|
|
33
|
-
|
|
34
|
-
expect(
|
|
35
|
-
expect(result.pager?.total).toBe(2)
|
|
152
|
+
await resource.clear()
|
|
153
|
+
expect(await resource.count()).toBe(0)
|
|
36
154
|
})
|
|
155
|
+
})
|
|
37
156
|
|
|
38
|
-
|
|
157
|
+
describe('@owlmeans/state — watching one record', () => {
|
|
158
|
+
// The whole point of the model: a subscription READS the store, it does not seed it. A screen
|
|
159
|
+
// bound to an id that has not arrived must not put a blank row into every list reading the same
|
|
160
|
+
// resource.
|
|
161
|
+
test('an id subscription creates NOTHING', async () => {
|
|
39
162
|
const resource = await seeded()
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
expect(
|
|
45
|
-
expect(
|
|
163
|
+
const seen: StateModel<Task>[] = []
|
|
164
|
+
const stop = resource.watch('zz', model => { seen.push(model) })
|
|
165
|
+
|
|
166
|
+
expect(await resource.count()).toBe(3)
|
|
167
|
+
expect(await resource.load('zz')).toBeNull()
|
|
168
|
+
expect(seen).toHaveLength(1)
|
|
169
|
+
expect(seen[0]!.empty).toBe(true)
|
|
170
|
+
expect(seen[0]!.id).toBe('zz')
|
|
46
171
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
172
|
+
stop()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('an empty model shows the configured default and stays out of the store', async () => {
|
|
176
|
+
const resource = createStateResource<Task>('drafts', {
|
|
177
|
+
default: () => ({ title: 'Untitled', status: 'draft' })
|
|
50
178
|
})
|
|
51
|
-
|
|
179
|
+
let model!: StateModel<Task>
|
|
180
|
+
const stop = resource.watch('new', current => { model = current })
|
|
181
|
+
|
|
182
|
+
expect(model.empty).toBe(true)
|
|
183
|
+
expect(model.record.title).toBe('Untitled')
|
|
184
|
+
expect(await resource.count()).toBe(0)
|
|
185
|
+
|
|
186
|
+
stop()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('the listener is seeded synchronously and follows the record', async () => {
|
|
190
|
+
const resource = await seeded()
|
|
191
|
+
const titles: Array<string | undefined> = []
|
|
192
|
+
const stop = resource.watch('a', model => { titles.push(model.record.title) })
|
|
193
|
+
|
|
194
|
+
expect(titles).toEqual(['Alpha'])
|
|
195
|
+
|
|
196
|
+
await resource.save({ id: 'a', title: 'Renamed' })
|
|
197
|
+
expect(titles.at(-1)).toBe('Renamed')
|
|
198
|
+
|
|
199
|
+
await resource.delete('a')
|
|
200
|
+
expect(titles.at(-1)).toBeUndefined()
|
|
201
|
+
|
|
202
|
+
stop()
|
|
203
|
+
await resource.save({ id: 'a', title: 'Back' })
|
|
204
|
+
expect(titles).toHaveLength(3)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('a model written through stops being empty', async () => {
|
|
208
|
+
const resource = await seeded()
|
|
209
|
+
let model!: StateModel<Task>
|
|
210
|
+
const stop = resource.watch('zz', current => { model = current })
|
|
211
|
+
|
|
212
|
+
const written = await model.update({ title: 'Zulu' })
|
|
213
|
+
expect(written.id).toBe('zz')
|
|
214
|
+
expect((await resource.get('zz')).title).toBe('Zulu')
|
|
215
|
+
|
|
216
|
+
// The listener has been handed a fresh model by now; the one written through knows it landed.
|
|
217
|
+
expect(model.empty).toBe(false)
|
|
218
|
+
|
|
219
|
+
await model.clear()
|
|
220
|
+
expect(await resource.load('zz')).toBeNull()
|
|
221
|
+
|
|
222
|
+
stop()
|
|
223
|
+
})
|
|
224
|
+
|
|
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 () => {
|
|
231
|
+
const resource = await seeded()
|
|
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()
|
|
52
239
|
})
|
|
53
240
|
|
|
54
|
-
test('
|
|
241
|
+
test('the empty model is one reference, so a subscriber sees no change', async () => {
|
|
55
242
|
const resource = await seeded()
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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()
|
|
59
252
|
})
|
|
60
253
|
|
|
61
|
-
test('the
|
|
254
|
+
test('writing through the empty model is refused', async () => {
|
|
62
255
|
const resource = await seeded()
|
|
63
|
-
|
|
64
|
-
|
|
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()
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
describe('@owlmeans/state — a single resource', () => {
|
|
282
|
+
test('one slot, addressed without an id', async () => {
|
|
283
|
+
const resource = createStateResource<Session>('session', { single: true })
|
|
284
|
+
let model!: StateModel<Session>
|
|
285
|
+
const stop = resource.watch(undefined, current => { model = current })
|
|
286
|
+
|
|
287
|
+
expect(model.empty).toBe(true)
|
|
288
|
+
|
|
289
|
+
await resource.save({ token: 'first', user: 'ann' })
|
|
290
|
+
expect(model.empty).toBe(false)
|
|
291
|
+
expect(model.record.token).toBe('first')
|
|
292
|
+
|
|
293
|
+
await resource.save({ token: 'second', user: 'bob' })
|
|
294
|
+
expect(await resource.count()).toBe(1)
|
|
295
|
+
expect(model.record.user).toBe('bob')
|
|
296
|
+
|
|
297
|
+
await model.clear()
|
|
298
|
+
expect(await resource.count()).toBe(0)
|
|
299
|
+
|
|
300
|
+
stop()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('the one record still answers only to its own id', async () => {
|
|
304
|
+
const resource = createStateResource<Session>('session-id', { single: true })
|
|
305
|
+
await resource.save({ id: 'sid', token: 'a' })
|
|
306
|
+
expect((await resource.load('sid'))?.token).toBe('a')
|
|
307
|
+
expect(await resource.load('other')).toBeNull()
|
|
65
308
|
})
|
|
66
309
|
})
|
|
67
310
|
|
|
68
|
-
describe('@owlmeans/state —
|
|
69
|
-
test('
|
|
311
|
+
describe('@owlmeans/state — a custom id field', () => {
|
|
312
|
+
test('records are keyed by the configured field', async () => {
|
|
313
|
+
const resource = createStateResource<Doc>('docs', { id: 'key' })
|
|
314
|
+
await resource.save({ key: 'intro', body: 'hello' })
|
|
315
|
+
await resource.save({ key: 'outro', body: 'bye' })
|
|
316
|
+
|
|
317
|
+
expect((await resource.get('intro')).body).toBe('hello')
|
|
318
|
+
expect(await resource.count()).toBe(2)
|
|
319
|
+
await expect(resource.save({ body: 'orphan' })).rejects.toThrow(StateConfigError)
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
describe('@owlmeans/state — live queries', () => {
|
|
324
|
+
test('a query subscriber is seeded and follows every write that changes the answer', async () => {
|
|
70
325
|
const resource = await seeded()
|
|
71
326
|
const seen: string[][] = []
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
listener: models => { seen.push(models.map(model => model.record.id!)) },
|
|
327
|
+
const stop = resource.query({ status: 'open' }, models => {
|
|
328
|
+
seen.push(models.map(model => model.record.id!))
|
|
75
329
|
})
|
|
76
330
|
|
|
77
|
-
expect(
|
|
331
|
+
expect(seen.at(-1)).toEqual(['a', 'c'])
|
|
78
332
|
|
|
79
|
-
// A record entering the set
|
|
80
333
|
await resource.create({ id: 'd', title: 'Delta', status: 'open' })
|
|
81
334
|
expect(seen.at(-1)).toEqual(['a', 'c', 'd'])
|
|
82
335
|
|
|
83
|
-
|
|
84
|
-
await resource.update({ id: 'a', status: 'done' })
|
|
336
|
+
await resource.save({ id: 'a', status: 'done' })
|
|
85
337
|
expect(seen.at(-1)).toEqual(['c', 'd'])
|
|
86
338
|
|
|
87
|
-
// A record leaving the set through a delete
|
|
88
339
|
await resource.delete('c')
|
|
89
340
|
expect(seen.at(-1)).toEqual(['d'])
|
|
90
341
|
|
|
91
|
-
|
|
92
|
-
const before = seen.length
|
|
342
|
+
stop()
|
|
93
343
|
await resource.create({ id: 'e', status: 'open' })
|
|
94
|
-
expect(seen.
|
|
344
|
+
expect(seen.at(-1)).toEqual(['d'])
|
|
95
345
|
})
|
|
96
346
|
|
|
97
|
-
// The id path creates a placeholder so a screen has something to bind to before its record
|
|
98
|
-
// arrives. A query has no such record to invent, and inventing one would put a blank row in
|
|
99
|
-
// every list.
|
|
100
347
|
test('a query subscription creates nothing', async () => {
|
|
101
348
|
const resource = createStateResource<Task>('empty')
|
|
102
|
-
const
|
|
103
|
-
expect(
|
|
104
|
-
|
|
349
|
+
const stop = resource.query({ status: 'open' }, () => { })
|
|
350
|
+
expect(await resource.count()).toBe(0)
|
|
351
|
+
stop()
|
|
105
352
|
})
|
|
106
353
|
|
|
107
|
-
test('an
|
|
354
|
+
test('an absent query matches everything and takes the sort', async () => {
|
|
108
355
|
const resource = await seeded()
|
|
109
|
-
|
|
110
|
-
resource.
|
|
111
|
-
id
|
|
112
|
-
|
|
113
|
-
listener: models => { seen.push(models[0]!.record.title!) },
|
|
114
|
-
})
|
|
115
|
-
expect((await resource.load('zz'))?.title).toBe('placeholder')
|
|
356
|
+
let ids: string[] = []
|
|
357
|
+
const stop = resource.query(undefined, models => {
|
|
358
|
+
ids = models.map(model => model.record.id!)
|
|
359
|
+
}, { sort: [{ field: 'points', order: 'desc' }] })
|
|
116
360
|
|
|
117
|
-
|
|
118
|
-
|
|
361
|
+
expect(ids).toEqual(['b', 'a', 'c'])
|
|
362
|
+
stop()
|
|
119
363
|
})
|
|
120
364
|
|
|
121
|
-
|
|
365
|
+
// A write that leaves the answer alone must not re-render the screens reading it.
|
|
366
|
+
test('an unchanged answer is not announced again', async () => {
|
|
122
367
|
const resource = await seeded()
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
})
|
|
128
|
-
expect(
|
|
368
|
+
let calls = 0
|
|
369
|
+
const stop = resource.query({ status: 'open' }, () => { calls += 1 })
|
|
370
|
+
expect(calls).toBe(1)
|
|
371
|
+
|
|
372
|
+
await resource.save({ id: 'b', status: 'done', title: 'Beta reworded' })
|
|
373
|
+
expect(calls).toBe(1)
|
|
374
|
+
|
|
375
|
+
await resource.save({ id: 'b', status: 'open' })
|
|
376
|
+
expect(calls).toBe(2)
|
|
377
|
+
|
|
378
|
+
stop()
|
|
129
379
|
})
|
|
380
|
+
})
|
|
130
381
|
|
|
131
|
-
|
|
382
|
+
describe('@owlmeans/state — the change stream', () => {
|
|
383
|
+
test('every write announces itself', async () => {
|
|
132
384
|
const resource = await seeded()
|
|
133
|
-
const
|
|
134
|
-
resource.subscribe(
|
|
135
|
-
expect(() => resource.subscribe({ query: { status: 'open' }, listener })).toThrow()
|
|
385
|
+
const events: StateEvent<Task>[] = []
|
|
386
|
+
const stop = await resource.subscribe(event => { events.push(event) })
|
|
136
387
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
expect(again.map(model => model.record.id)).toEqual(first.map(model => model.record.id))
|
|
141
|
-
})
|
|
388
|
+
await resource.save({ id: 'd', title: 'Delta' })
|
|
389
|
+
expect(events.at(-1)?.type).toBe('set')
|
|
390
|
+
expect(events.at(-1)?.records.map(record => record.id)).toEqual(['d'])
|
|
142
391
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
392
|
+
await resource.purge({ status: 'open' })
|
|
393
|
+
expect(events.at(-1)?.type).toBe('remove')
|
|
394
|
+
expect(events.at(-1)?.records.map(record => record.id)).toEqual(['a', 'c'])
|
|
395
|
+
|
|
396
|
+
await stop()
|
|
397
|
+
await resource.save({ id: 'e' })
|
|
398
|
+
expect(events).toHaveLength(2)
|
|
147
399
|
})
|
|
148
400
|
|
|
149
|
-
test('a
|
|
401
|
+
test('a once subscriber hears one event, and a channel keeps its traffic apart', async () => {
|
|
150
402
|
const resource = await seeded()
|
|
151
|
-
let
|
|
152
|
-
resource.subscribe(
|
|
403
|
+
let once = 0
|
|
404
|
+
await resource.subscribe(() => { once += 1 }, { once: true })
|
|
405
|
+
|
|
406
|
+
const aside: StateEvent<Task>[] = []
|
|
407
|
+
const stop = await resource.subscribe(event => { aside.push(event) }, { channel: 'aside' })
|
|
408
|
+
|
|
409
|
+
await resource.save({ id: 'd' })
|
|
410
|
+
await resource.save({ id: 'f' })
|
|
411
|
+
expect(once).toBe(1)
|
|
412
|
+
expect(aside).toHaveLength(0)
|
|
153
413
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
})[1])
|
|
414
|
+
await resource.publish({ type: 'set', records: [{ id: 'd' }] }, 'aside')
|
|
415
|
+
expect(aside).toHaveLength(1)
|
|
157
416
|
|
|
158
|
-
|
|
159
|
-
await Promise.resolve()
|
|
160
|
-
expect((await resource.load('a'))?.points).toBe(42)
|
|
417
|
+
await stop()
|
|
161
418
|
})
|
|
162
419
|
})
|
package/build/.gitkeep
DELETED
|
File without changes
|
package/src/consts.ts
DELETED