@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/types.ts CHANGED
@@ -1,49 +1,122 @@
1
- import type { ListCriteria, Resource, ResourceRecord } from '@owlmeans/resource'
1
+ import type {
2
+ Criteria, FirstOptions, PubSubResource, Resource, ResourceRecord
3
+ } from '@owlmeans/resource'
2
4
 
3
- export interface StateResource<T extends ResourceRecord> extends Resource<T> {
5
+ /**
6
+ * How a state resource is keyed and what it shows before anything is loaded.
7
+ *
8
+ * Everything here is optional: an unconfigured resource keys records by `id`, holds as many of
9
+ * them as it is given, and shows an empty object until a record arrives.
10
+ */
11
+ export interface StateConfig<T extends ResourceRecord> {
12
+ /** The field records are keyed by. Defaults to `id`. */
13
+ id?: keyof T & string
4
14
  /**
5
- * @returns unsubscribe function
15
+ * The resource holds exactly ONE record, which therefore needs no id — the current user, the
16
+ * active session, a wizard being filled in. It is what makes `watch(undefined, ...)` (and so
17
+ * `useStoreModel()` with no id) answerable: elsewhere there is no "the record" to address.
6
18
  */
7
- subscribe: (params: StateSubscriptionOption<T>) => [() => void, StateModel<T>[]]
8
- listen: (listener: StateListener<T>) => () => void
9
- erase: () => Promise<void>
19
+ single?: boolean
20
+ /**
21
+ * What {@link StateModel.record} shows while the model is empty. A screen that binds to a
22
+ * record before it has arrived reads the default instead of guarding every field — and the
23
+ * store still holds nothing, so nothing renders it as a row.
24
+ */
25
+ default?: () => T
10
26
  }
11
27
 
12
- export interface StateSubscriptionOption<T extends ResourceRecord> {
13
- id?: string | string[]
14
- _systemId?: string
15
- query?: ListCriteria
16
- default?: Partial<T>
17
- listener: StateListener<T>
28
+ /** One change to the store, as its subscribers see it. */
29
+ export interface StateEvent<T extends ResourceRecord> {
30
+ type: 'set' | 'remove'
31
+ records: T[]
18
32
  }
19
33
 
20
- export interface StateListener<T extends ResourceRecord> {
21
- (record: StateModel<T>[], systemId?: string): void | Promise<void>
22
- }
34
+ /**
35
+ * The framework's client store: a `Resource` like any other, registered ON the context — which is
36
+ * what separates it from a store held beside the app, since a screen, a service and a guard all
37
+ * reach the same records through the same container.
38
+ *
39
+ * Reads and writes are the resource vocabulary; `watch` and `query` are the live half, and they
40
+ * are synchronous on purpose — a React subscriber has to have its value before it renders.
41
+ */
42
+ export interface StateResource<T extends ResourceRecord> extends Resource<T>, PubSubResource<StateEvent<T>> {
43
+ readonly config: StateConfig<T>
23
44
 
24
- export interface StateModel<T extends ResourceRecord> {
25
- record: T,
45
+ /**
46
+ * Make the store agree with an authoritative list: every record given is written, and every
47
+ * record the list does not name is dropped. That is the shape of "the server just told us what
48
+ * exists" — saving each record one by one leaves the ones deleted elsewhere behind.
49
+ */
50
+ replace(records: T[]): Promise<void>
26
51
 
27
- commit: (force?: boolean) => void
52
+ /** Drop every record. */
53
+ clear(): Promise<void>
28
54
 
29
- update: (data?: Partial<T>) => void
55
+ /**
56
+ * Follow one record. The listener is called with the current model straight away — before
57
+ * `watch` returns — and again on every change to that record, including its removal.
58
+ *
59
+ * `undefined` addresses the one record of a `single` resource; on any other it throws, since
60
+ * there is nothing for it to mean.
61
+ *
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.
64
+ * @returns unsubscribe
65
+ */
66
+ watch(id: string | undefined, listener: (model: StateModel<T>) => void): () => void
30
67
 
31
- clear: () => void
68
+ /**
69
+ * Follow a live QUERY. The listener is called with the matching models straight away and again
70
+ * whenever a write changes the answer, so a list screen never recomputes ids and never
71
+ * re-subscribes to keep up. `undefined` matches every record.
72
+ *
73
+ * A query subscription creates nothing: an empty store yields an empty list.
74
+ *
75
+ * @returns unsubscribe
76
+ */
77
+ query(
78
+ where: Criteria<T> | undefined,
79
+ listener: (models: StateModel<T>[]) => void,
80
+ opts?: FirstOptions<T>
81
+ ): () => void
32
82
  }
33
83
 
34
- export interface StateResourceAppend {
35
- getStateResource: <T extends ResourceRecord>(alias?: string) => StateResource<T>
84
+ /**
85
+ * One record, as something bound to it can hold: what it currently says, and how to change it.
86
+ *
87
+ * `record` is a snapshot — assigning into it changes nothing anyone can see. `update` is how a
88
+ * change reaches the store and every other subscriber.
89
+ */
90
+ export interface StateModel<T extends ResourceRecord> {
91
+ readonly id: string | undefined
92
+ /**
93
+ * Nothing is loaded yet: the store holds no record under this model's key. This — not a
94
+ * sentinel id, and not a placeholder record — is what "not there" looks like, so a subscription
95
+ * to an unknown id leaves the store exactly as empty as it found it.
96
+ */
97
+ readonly empty: boolean
98
+ /** The record, or the configured `default` while {@link StateModel.empty} is true. */
99
+ readonly record: Readonly<T>
100
+ /** Merge and write in one step. */
101
+ update(patch: Partial<T>): Promise<T>
102
+ /** Write what {@link StateModel.record} currently holds — including a default not yet stored. */
103
+ commit(): Promise<T>
104
+ clear(): Promise<void>
36
105
  }
37
106
 
38
- export interface UseStoreHelper {
39
- <T extends ResourceRecord>(id?: string | UseStoreHelperOptions<T>, opts?: string | boolean | UseStoreHelperOptions<T>): StateModel<T>
40
- }
107
+ /**
108
+ * A state alias that remembers the record type it addresses, so `getStateResource(TASKS)` is
109
+ * typed without repeating `<Task>` at every call site. Built by {@link stateAlias}; it is a plain
110
+ * string at runtime, so it works anywhere an alias is expected.
111
+ */
112
+ export type StateAlias<T extends ResourceRecord> = string & { readonly _state?: T }
41
113
 
42
- export interface UseStoreListHelper {
43
- <T extends ResourceRecord>(id?: string | string[] | UseStoreHelperOptions<T>, opts?: string | boolean | UseStoreHelperOptions<T>): StateModel<T>[]
114
+ export interface GetStateResource {
115
+ <T extends ResourceRecord>(alias: StateAlias<T>): StateResource<T>
116
+ <T extends ResourceRecord = ResourceRecord>(alias?: string): StateResource<T>
44
117
  }
45
118
 
46
- export interface UseStoreHelperOptions<T extends ResourceRecord> extends Omit<StateSubscriptionOption<T>, "listener"> {
47
- listen?: boolean
48
- resource?: string
119
+ export interface StateResourceAppend {
120
+ /** The state resource under `alias`, or the first one appended to the context. */
121
+ getStateResource: GetStateResource
49
122
  }
@@ -1,40 +1,60 @@
1
1
  import type { ResourceRecord } from '@owlmeans/resource'
2
- import { MisshapedRecord } from '@owlmeans/resource'
3
- import type { StateModel, StateResource } from '../types.js'
2
+ import type { StateModel } from '../types.js'
4
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
+ }
17
+
18
+ /**
19
+ * Wrap one record — or its absence — as something a screen can bind to.
20
+ *
21
+ * The working copy is replaced rather than mutated on every write, so the record a caller is
22
+ * holding never changes underneath it and two models of the same record stay comparable by
23
+ * reference.
24
+ */
5
25
  export const createStateModel = <T extends ResourceRecord>(
6
- record: T, resource: StateResource<T>
26
+ binding: StateModelBinding<T>
7
27
  ): StateModel<T> => {
8
- let before: T = record
28
+ /** What an empty model shows: the configured default, or nothing at all. */
29
+ const blank = (): T => binding.default?.() ?? {} as T
30
+
31
+ let working: T = binding.record ?? blank()
32
+ let stored = binding.record != null
33
+
9
34
  const model: StateModel<T> = {
10
- record: { ...record },
11
-
12
- commit: force => {
13
- if (force !== true) {
14
- if (!Object.entries(before).reduce(
15
- (changed, [key, value]) => changed || model.record[key as keyof T] !== value, false
16
- ) && !Object.entries(model.record).reduce(
17
- (changed, [key, value]) => changed || before[key as keyof T] !== value, false
18
- )) {
19
- return
20
- }
21
- }
22
- if (record.id == null) {
23
- throw new MisshapedRecord('id')
24
- }
25
- before = model.record
26
- void resource.load(record.id).then(exists => {
27
- exists != null && resource.update(model.record)
28
- })
35
+ get id() { return binding.id },
36
+
37
+ get empty() { return !stored },
38
+
39
+ get record() { return working },
40
+
41
+ update: async patch => {
42
+ working = { ...working, ...patch } as T
43
+
44
+ return model.commit()
29
45
  },
30
46
 
31
- update: data => {
32
- Object.assign(model.record, data)
33
- model.commit()
47
+ commit: async () => {
48
+ working = await binding.write(working)
49
+ stored = true
50
+
51
+ return working
34
52
  },
35
53
 
36
- clear: () => {
37
- void resource.delete(record)
54
+ clear: async () => {
55
+ await binding.drop()
56
+ stored = false
57
+ working = blank()
38
58
  }
39
59
  }
40
60
 
@@ -0,0 +1,419 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { createStateResource, StateConfigError } from '@owlmeans/state'
3
+ import type { StateEvent, StateModel } from '@owlmeans/state'
4
+ import type { ResourceRecord } from '@owlmeans/resource'
5
+
6
+ interface Task extends ResourceRecord {
7
+ title?: string
8
+ status?: string
9
+ points?: number
10
+ }
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
+
22
+ const seeded = async () => {
23
+ const resource = createStateResource<Task>('tasks')
24
+ await resource.create({ id: 'a', title: 'Alpha', status: 'open', points: 3 })
25
+ await resource.create({ id: 'b', title: 'Beta', status: 'done', points: 8 })
26
+ await resource.create({ id: 'c', title: 'Gamma', status: 'open', points: 1 })
27
+
28
+ return resource
29
+ }
30
+
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 () => {
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
+ ])
145
+ const { items } = await resource.list()
146
+ expect(items.map(item => item.id)).toEqual(['a', 'z'])
147
+ expect((await resource.get('a')).status).toBe('done')
148
+ })
149
+
150
+ test('clear drops everything', async () => {
151
+ const resource = await seeded()
152
+ await resource.clear()
153
+ expect(await resource.count()).toBe(0)
154
+ })
155
+ })
156
+
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 () => {
162
+ const resource = await seeded()
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')
171
+
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' })
178
+ })
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()
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()
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()
308
+ })
309
+ })
310
+
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 () => {
325
+ const resource = await seeded()
326
+ const seen: string[][] = []
327
+ const stop = resource.query({ status: 'open' }, models => {
328
+ seen.push(models.map(model => model.record.id!))
329
+ })
330
+
331
+ expect(seen.at(-1)).toEqual(['a', 'c'])
332
+
333
+ await resource.create({ id: 'd', title: 'Delta', status: 'open' })
334
+ expect(seen.at(-1)).toEqual(['a', 'c', 'd'])
335
+
336
+ await resource.save({ id: 'a', status: 'done' })
337
+ expect(seen.at(-1)).toEqual(['c', 'd'])
338
+
339
+ await resource.delete('c')
340
+ expect(seen.at(-1)).toEqual(['d'])
341
+
342
+ stop()
343
+ await resource.create({ id: 'e', status: 'open' })
344
+ expect(seen.at(-1)).toEqual(['d'])
345
+ })
346
+
347
+ test('a query subscription creates nothing', async () => {
348
+ const resource = createStateResource<Task>('empty')
349
+ const stop = resource.query({ status: 'open' }, () => { })
350
+ expect(await resource.count()).toBe(0)
351
+ stop()
352
+ })
353
+
354
+ test('an absent query matches everything and takes the sort', async () => {
355
+ const resource = await seeded()
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' }] })
360
+
361
+ expect(ids).toEqual(['b', 'a', 'c'])
362
+ stop()
363
+ })
364
+
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 () => {
367
+ const resource = await seeded()
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()
379
+ })
380
+ })
381
+
382
+ describe('@owlmeans/state — the change stream', () => {
383
+ test('every write announces itself', async () => {
384
+ const resource = await seeded()
385
+ const events: StateEvent<Task>[] = []
386
+ const stop = await resource.subscribe(event => { events.push(event) })
387
+
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'])
391
+
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)
399
+ })
400
+
401
+ test('a once subscriber hears one event, and a channel keeps its traffic apart', async () => {
402
+ const resource = await seeded()
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)
413
+
414
+ await resource.publish({ type: 'set', records: [{ id: 'd' }] }, 'aside')
415
+ expect(aside).toHaveLength(1)
416
+
417
+ await stop()
418
+ })
419
+ })
package/tsconfig.json CHANGED
@@ -6,5 +6,10 @@
6
6
  "rootDir": "./src/",
7
7
  "outDir": "./build/"
8
8
  },
9
- "exclude": ["./dist/**/*", "./build/**/*", "./*.ts"]
9
+ "exclude": [
10
+ "./dist/**/*",
11
+ "./build/**/*",
12
+ "./tests/**/*",
13
+ "./*.ts"
14
+ ]
10
15
  }
package/build/.gitkeep DELETED
File without changes
package/build/consts.d.ts DELETED
@@ -1,3 +0,0 @@
1
- export declare const DEFAULT_ID = "_default";
2
- export declare const DEFAULT_ALIAS = "state";
3
- //# sourceMappingURL=consts.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,UAAU,aAAa,CAAA;AACpC,eAAO,MAAM,aAAa,UAAU,CAAA"}