@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.
Files changed (50) hide show
  1. package/README.md +158 -77
  2. package/agent-meta/manifest.json +3 -3
  3. package/agent-meta/skills/state/SKILL.md +38 -12
  4. package/build/consts.d.ts +3 -0
  5. package/build/consts.d.ts.map +1 -0
  6. package/build/consts.js +3 -0
  7. package/build/consts.js.map +1 -0
  8. package/build/consts.local.d.ts +10 -0
  9. package/build/consts.local.d.ts.map +1 -0
  10. package/build/consts.local.js +10 -0
  11. package/build/consts.local.js.map +1 -0
  12. package/build/errors.d.ts.map +1 -1
  13. package/build/errors.js.map +1 -1
  14. package/build/helper.d.ts.map +1 -1
  15. package/build/index.d.ts +1 -0
  16. package/build/index.d.ts.map +1 -1
  17. package/build/resource.d.ts +2 -2
  18. package/build/resource.d.ts.map +1 -1
  19. package/build/resource.js +37 -15
  20. package/build/resource.js.map +1 -1
  21. package/build/types.d.ts +2 -1
  22. package/build/types.d.ts.map +1 -1
  23. package/build/types.local.d.ts +15 -0
  24. package/build/types.local.d.ts.map +1 -0
  25. package/build/types.local.js +2 -0
  26. package/build/types.local.js.map +1 -0
  27. package/build/utils/criteria.d.ts +20 -0
  28. package/build/utils/criteria.d.ts.map +1 -0
  29. package/build/utils/criteria.js +252 -0
  30. package/build/utils/criteria.js.map +1 -0
  31. package/build/utils/index.d.ts +3 -0
  32. package/build/utils/index.d.ts.map +1 -0
  33. package/build/utils/index.js +3 -0
  34. package/build/utils/index.js.map +1 -0
  35. package/build/utils/model.d.ts +1 -13
  36. package/build/utils/model.d.ts.map +1 -1
  37. package/build/utils/model.js.map +1 -1
  38. package/build/utils/types.d.ts +15 -0
  39. package/build/utils/types.d.ts.map +1 -0
  40. package/build/utils/types.js +2 -0
  41. package/build/utils/types.js.map +1 -0
  42. package/package.json +4 -4
  43. package/src/consts.local.ts +11 -0
  44. package/src/index.ts +1 -0
  45. package/src/resource.ts +42 -41
  46. package/src/types.local.ts +16 -0
  47. package/src/types.ts +2 -1
  48. package/src/utils/model.ts +1 -14
  49. package/src/utils/types.ts +15 -0
  50. 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 { BasicConfig as Config, BasicContext as Context } from '@owlmeans/context'
3
- import {
4
- applyQuery, filterRecords, firstMatch, RecordExists, sortRecords, UnknownRecordError,
5
- UnsupportedArgumentError
6
- } from '@owlmeans/resource'
7
- import type {
8
- Criteria, FirstOptions, ListOptions, ResourceRecord, SubscribeOptions, Ttl, Unsubscribe,
9
- WriteOptions
10
- } from '@owlmeans/resource'
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
- * @throws {StateConfigError} `NonSingle` — no id, on a resource that holds many records.
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
@@ -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
+ }
@@ -222,9 +222,59 @@ describe('@owlmeans/state — watching one record', () => {
222
222
  stop()
223
223
  })
224
224
 
225
- test('an id addresses no record without a single resource', async () => {
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
- expect(() => resource.watch(undefined, () => { })).toThrow(StateConfigError)
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