@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.
Files changed (55) hide show
  1. package/README.md +173 -68
  2. package/agent-meta/manifest.json +3 -3
  3. package/agent-meta/skills/state/SKILL.md +137 -58
  4. package/build/consts.local.d.ts +10 -0
  5. package/build/consts.local.d.ts.map +1 -0
  6. package/build/consts.local.js +10 -0
  7. package/build/consts.local.js.map +1 -0
  8. package/build/errors.d.ts +12 -5
  9. package/build/errors.d.ts.map +1 -1
  10. package/build/errors.js +16 -13
  11. package/build/errors.js.map +1 -1
  12. package/build/helper.d.ts +16 -0
  13. package/build/helper.d.ts.map +1 -0
  14. package/build/helper.js +14 -0
  15. package/build/helper.js.map +1 -0
  16. package/build/index.d.ts +3 -2
  17. package/build/index.d.ts.map +1 -1
  18. package/build/index.js +2 -2
  19. package/build/index.js.map +1 -1
  20. package/build/resource.d.ts +12 -5
  21. package/build/resource.d.ts.map +1 -1
  22. package/build/resource.js +317 -207
  23. package/build/resource.js.map +1 -1
  24. package/build/types.d.ts +91 -50
  25. package/build/types.d.ts.map +1 -1
  26. package/build/types.local.d.ts +15 -0
  27. package/build/types.local.d.ts.map +1 -0
  28. package/build/types.local.js +2 -0
  29. package/build/types.local.js.map +1 -0
  30. package/build/utils/criteria.d.ts.map +1 -1
  31. package/build/utils/criteria.js.map +1 -1
  32. package/build/utils/model.d.ts +10 -2
  33. package/build/utils/model.d.ts.map +1 -1
  34. package/build/utils/model.js +26 -22
  35. package/build/utils/model.js.map +1 -1
  36. package/build/utils/types.d.ts +15 -0
  37. package/build/utils/types.d.ts.map +1 -0
  38. package/build/utils/types.js +2 -0
  39. package/build/utils/types.js.map +1 -0
  40. package/package.json +4 -4
  41. package/src/consts.local.ts +11 -0
  42. package/src/errors.ts +16 -13
  43. package/src/helper.ts +17 -0
  44. package/src/index.ts +3 -2
  45. package/src/resource.ts +359 -241
  46. package/src/types.local.ts +16 -0
  47. package/src/types.ts +98 -55
  48. package/src/utils/model.ts +35 -28
  49. package/src/utils/types.ts +15 -0
  50. package/tests/resource.spec.ts +347 -90
  51. package/build/.gitkeep +0 -0
  52. package/src/consts.ts +0 -3
  53. package/src/utils/criteria.ts +0 -252
  54. package/src/utils/index.ts +0 -3
  55. package/tests/criteria.spec.ts +0 -121
@@ -1,252 +0,0 @@
1
- import { UnsupportedArgumentError } from '@owlmeans/resource'
2
- import type { ListCriteria, ListSort, ResourceRecord } from '@owlmeans/resource'
3
-
4
- /**
5
- * In-memory evaluation of `ListCriteria`.
6
- *
7
- * The operator set mirrors `@owlmeans/postgres-resource` on purpose: one criteria shape has to
8
- * mean the same thing whether a list is filtered in SQL by an endpoint or locally by a screen
9
- * reading the state resource. A client that learns `{ status: ['open', 'done'] }` means "either
10
- * of these" must not meet a different answer when the same object reaches the server.
11
- *
12
- * Two deliberate differences, both because a state resource carries no schema:
13
- *
14
- * - An unknown key cannot be detected. Postgres throws on one (a typo silently widening a query
15
- * to the whole table is worth being loud about); here the property is simply absent from the
16
- * record, so the record does not match. There is nothing to compare it against.
17
- * - There is no jsonb/column distinction, so a dotted key always reaches into the record.
18
- */
19
-
20
- const COMPARISON = new Set(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte'])
21
-
22
- /** Dates compare by instant; everything else compares as it is. */
23
- const comparable = (value: unknown): unknown =>
24
- value instanceof Date ? value.getTime() : value
25
-
26
- /** Reach `profile.city` inside a record. A missing step yields `undefined`, never a throw. */
27
- const reach = (record: unknown, path: string[]): unknown =>
28
- path.reduce<unknown>(
29
- (value, step) => value == null || typeof value !== 'object'
30
- ? undefined
31
- : (value as Record<string, unknown>)[step],
32
- record
33
- )
34
-
35
- /**
36
- * An object naming at least one `$` key is a spec, not a value to compare against.
37
- * A Date and an array are values even though both are objects.
38
- */
39
- const isOperatorSpec = (value: unknown): value is Record<string, unknown> =>
40
- value != null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)
41
- && Object.keys(value).some(key => key.startsWith('$'))
42
-
43
- const equal = (left: unknown, right: unknown): boolean => {
44
- if (left instanceof Date || right instanceof Date) {
45
- return comparable(left) === comparable(right)
46
- }
47
- if (Array.isArray(left) && Array.isArray(right)) {
48
- return left.length === right.length && left.every((entry, index) => equal(entry, right[index]))
49
- }
50
- return left === right
51
- }
52
-
53
- /** `$in` / `$nin`. `null` in the list matches an absent property, matching the SQL widening. */
54
- const inList = (value: unknown, operand: unknown): boolean => {
55
- const values = Array.isArray(operand) ? operand : [operand]
56
- return values.some(entry => entry == null ? value == null : equal(value, entry))
57
- }
58
-
59
- const text = (value: unknown): string => value == null ? '' : `${value}`
60
-
61
- /** `%` and `_` are the SQL wildcards; `\` escapes them. */
62
- const likeToRegExp = (pattern: string, flags: string): RegExp => {
63
- let source = ''
64
- for (let index = 0; index < pattern.length; index++) {
65
- const char = pattern[index]!
66
- if (char === '\\' && index + 1 < pattern.length) {
67
- source += pattern[++index]!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
68
- continue
69
- }
70
- if (char === '%') { source += '.*'; continue }
71
- if (char === '_') { source += '.'; continue }
72
- source += char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
73
- }
74
-
75
- return new RegExp(`^${source}$`, flags)
76
- }
77
-
78
- /**
79
- * @throws {UnsupportedArgumentError} on an operator this store cannot answer.
80
- */
81
- const applyOperators = (value: unknown, spec: Record<string, unknown>): boolean => {
82
- for (const [operator, operand] of Object.entries(spec)) {
83
- if (COMPARISON.has(operator)) {
84
- const left = comparable(value)
85
- const right = comparable(operand)
86
- const matched =
87
- operator === '$eq' ? equal(value, operand)
88
- : operator === '$ne' ? !equal(value, operand)
89
- : left == null || right == null ? false
90
- : operator === '$gt' ? (left as never) > (right as never)
91
- : operator === '$gte' ? (left as never) >= (right as never)
92
- : operator === '$lt' ? (left as never) < (right as never)
93
- : (left as never) <= (right as never)
94
- if (!matched) return false
95
- continue
96
- }
97
- switch (operator) {
98
- case '$in':
99
- if (!inList(value, operand)) return false
100
- break
101
- case '$nin':
102
- if (inList(value, operand)) return false
103
- break
104
- case '$exists':
105
- if ((value != null) !== (operand !== false)) return false
106
- break
107
- case '$null':
108
- if ((value == null) !== (operand !== false)) return false
109
- break
110
- case '$like':
111
- if (!likeToRegExp(text(operand), '').test(text(value))) return false
112
- break
113
- case '$ilike':
114
- if (!likeToRegExp(text(operand), 'i').test(text(value))) return false
115
- break
116
- case '$regex':
117
- if (!new RegExp(text(operand)).test(text(value))) return false
118
- break
119
- case '$startsWith':
120
- if (!text(value).startsWith(text(operand))) return false
121
- break
122
- case '$endsWith':
123
- if (!text(value).endsWith(text(operand))) return false
124
- break
125
- case '$between': {
126
- if (!Array.isArray(operand) || operand.length !== 2) {
127
- throw new UnsupportedArgumentError(`criteria:$between`)
128
- }
129
- const left = comparable(value)
130
- if (left == null) return false
131
- const from = comparable(operand[0])
132
- const to = comparable(operand[1])
133
- if ((left as never) < (from as never) || (left as never) > (to as never)) return false
134
- break
135
- }
136
- case '$contains': {
137
- const operands = Array.isArray(operand) ? operand : [operand]
138
- const values = Array.isArray(value) ? value : [value]
139
- if (!operands.every(entry => values.some(candidate => equal(candidate, entry)))) return false
140
- break
141
- }
142
- case '$contained': {
143
- const operands = Array.isArray(operand) ? operand : [operand]
144
- const values = Array.isArray(value) ? value : [value]
145
- if (!values.every(candidate => operands.some(entry => equal(candidate, entry)))) return false
146
- break
147
- }
148
- case '$overlaps': {
149
- const operands = Array.isArray(operand) ? operand : [operand]
150
- const values = Array.isArray(value) ? value : [value]
151
- if (!values.some(candidate => operands.some(entry => equal(candidate, entry)))) return false
152
- break
153
- }
154
- default:
155
- throw new UnsupportedArgumentError(`criteria-operator:${operator}`)
156
- }
157
- }
158
-
159
- return true
160
- }
161
-
162
- /**
163
- * Whether one record satisfies the criteria.
164
- *
165
- * `undefined` as a criteria value is skipped rather than compared — it is what an optional filter
166
- * looks like when nothing was chosen, and treating it as "must be undefined" makes an unset
167
- * dropdown filter the list down to nothing.
168
- *
169
- * @throws {UnsupportedArgumentError} on an unknown operator.
170
- */
171
- export const matchCriteria = <T extends ResourceRecord>(
172
- record: T, criteria?: ListCriteria
173
- ): boolean => {
174
- for (const [key, raw] of Object.entries(criteria ?? {})) {
175
- if (raw === undefined) {
176
- continue
177
- }
178
-
179
- if (key === '$and' || key === '$or') {
180
- const parts = (Array.isArray(raw) ? raw : [raw]) as ListCriteria[]
181
- if (parts.length < 1) continue
182
- const matched = key === '$and'
183
- ? parts.every(part => matchCriteria(record, part))
184
- : parts.some(part => matchCriteria(record, part))
185
- if (!matched) return false
186
- continue
187
- }
188
- if (key === '$not') {
189
- if (matchCriteria(record, raw as ListCriteria)) return false
190
- continue
191
- }
192
-
193
- const value = reach(record, key.split('.'))
194
-
195
- if (raw === null) {
196
- if (value != null) return false
197
- continue
198
- }
199
- if (isOperatorSpec(raw)) {
200
- if (!applyOperators(value, raw as Record<string, unknown>)) return false
201
- continue
202
- }
203
- if (Array.isArray(raw)) {
204
- /**
205
- * A bare array means "any of these", exactly as it does against a relational store.
206
- * Exact array equality stays reachable as `{ $eq: [...] }`.
207
- */
208
- if (!inList(value, raw)) return false
209
- continue
210
- }
211
- if (!equal(value, raw)) return false
212
- }
213
-
214
- return true
215
- }
216
-
217
- /** Every record the criteria accepts, in insertion order. */
218
- export const filterRecords = <T extends ResourceRecord>(
219
- records: T[], criteria?: ListCriteria
220
- ): T[] => criteria == null || Object.keys(criteria).length < 1
221
- ? [...records]
222
- : records.filter(record => matchCriteria(record, criteria))
223
-
224
- /**
225
- * Sort a copy by `ListPager.sort`. `[field, true]` is descending — the same mapping mongo's
226
- * `order ? -1 : 1` and the postgres builder use, so a pager written for one store orders the
227
- * same way against another.
228
- */
229
- export const sortRecords = <T extends ResourceRecord>(records: T[], sort?: ListSort[]): T[] => {
230
- if (sort == null || sort.length < 1) {
231
- return [...records]
232
- }
233
-
234
- const rules = sort.map(entry => Array.isArray(entry)
235
- ? { field: entry[0], desc: entry[1] === true }
236
- : { field: entry, desc: false })
237
-
238
- return [...records].sort((left, right) => {
239
- for (const { field, desc } of rules) {
240
- const a = comparable(reach(left, field.split('.')))
241
- const b = comparable(reach(right, field.split('.')))
242
- if (a == null && b == null) continue
243
- /** An absent value sorts last ascending, which is where a reader expects "no value". */
244
- if (a == null) return 1
245
- if (b == null) return -1
246
- if ((a as never) < (b as never)) return desc ? 1 : -1
247
- if ((a as never) > (b as never)) return desc ? -1 : 1
248
- }
249
-
250
- return 0
251
- })
252
- }
@@ -1,3 +0,0 @@
1
-
2
- export * from './model.js'
3
- export * from './criteria.js'
@@ -1,121 +0,0 @@
1
- import { describe, expect, test } from 'bun:test'
2
- import { filterRecords, matchCriteria, sortRecords } from '@owlmeans/state'
3
-
4
- interface Task {
5
- id?: string
6
- title?: string
7
- status?: string
8
- points?: number
9
- tags?: string[]
10
- owner?: { name?: string, team?: string }
11
- due?: Date
12
- }
13
-
14
- const tasks: Task[] = [
15
- { id: 'a', title: 'Alpha', status: 'open', points: 3, tags: ['ui', 'urgent'], owner: { name: 'ann', team: 'core' } },
16
- { id: 'b', title: 'Beta', status: 'done', points: 8, tags: ['api'], owner: { name: 'bob', team: 'core' } },
17
- { id: 'c', title: 'Gamma', status: 'open', points: 1, tags: [], owner: { name: 'cid', team: 'ops' } },
18
- { id: 'd', title: 'Delta', points: 5 },
19
- ]
20
-
21
- const ids = (records: Task[]): string[] => records.map(record => record.id!)
22
-
23
- describe('@owlmeans/state — criteria', () => {
24
- test('a bare value is field equality', () => {
25
- expect(ids(filterRecords(tasks, { status: 'open' }))).toEqual(['a', 'c'])
26
- })
27
-
28
- // The same shape reaching a postgres resource means IN, so it has to mean IN here too —
29
- // otherwise one filter object answers two different questions depending on who evaluates it.
30
- test('a bare array is $in', () => {
31
- expect(ids(filterRecords(tasks, { status: ['open', 'done'] }))).toEqual(['a', 'b', 'c'])
32
- })
33
-
34
- test('comparison operators', () => {
35
- expect(ids(filterRecords(tasks, { points: { $gte: 5 } }))).toEqual(['b', 'd'])
36
- expect(ids(filterRecords(tasks, { points: { $lt: 3 } }))).toEqual(['c'])
37
- expect(ids(filterRecords(tasks, { points: { $gt: 1, $lte: 5 } }))).toEqual(['a', 'd'])
38
- expect(ids(filterRecords(tasks, { status: { $ne: 'open' } }))).toEqual(['b', 'd'])
39
- })
40
-
41
- test('$in, $nin, $exists, $null', () => {
42
- expect(ids(filterRecords(tasks, { id: { $in: ['a', 'd'] } }))).toEqual(['a', 'd'])
43
- expect(ids(filterRecords(tasks, { id: { $nin: ['a', 'd'] } }))).toEqual(['b', 'c'])
44
- expect(ids(filterRecords(tasks, { status: { $exists: false } }))).toEqual(['d'])
45
- expect(ids(filterRecords(tasks, { status: { $null: true } }))).toEqual(['d'])
46
- })
47
-
48
- test('text operators', () => {
49
- expect(ids(filterRecords(tasks, { title: { $startsWith: 'A' } }))).toEqual(['a'])
50
- expect(ids(filterRecords(tasks, { title: { $endsWith: 'a' } }))).toEqual(['a', 'b', 'c', 'd'])
51
- expect(ids(filterRecords(tasks, { title: { $endsWith: 'ta' } }))).toEqual(['b', 'd'])
52
- expect(ids(filterRecords(tasks, { title: { $like: '%amm%' } }))).toEqual(['c'])
53
- expect(ids(filterRecords(tasks, { title: { $ilike: 'alpha' } }))).toEqual(['a'])
54
- expect(ids(filterRecords(tasks, { title: { $regex: '^(Al|Be)' } }))).toEqual(['a', 'b'])
55
- })
56
-
57
- test('$between spans inclusively and rejects a malformed operand', () => {
58
- expect(ids(filterRecords(tasks, { points: { $between: [3, 5] } }))).toEqual(['a', 'd'])
59
- expect(() => filterRecords(tasks, { points: { $between: [3] } as never })).toThrow()
60
- })
61
-
62
- test('array operators', () => {
63
- expect(ids(filterRecords(tasks, { tags: { $contains: 'urgent' } }))).toEqual(['a'])
64
- expect(ids(filterRecords(tasks, { tags: { $overlaps: ['api', 'urgent'] } }))).toEqual(['a', 'b'])
65
- })
66
-
67
- test('logical operators nest', () => {
68
- expect(ids(filterRecords(tasks, { $or: [{ status: 'done' }, { points: { $lt: 2 } }] }))).toEqual(['b', 'c'])
69
- expect(ids(filterRecords(tasks, { $and: [{ status: 'open' }, { points: { $gt: 2 } }] }))).toEqual(['a'])
70
- expect(ids(filterRecords(tasks, { $not: { status: 'open' } }))).toEqual(['b', 'd'])
71
- })
72
-
73
- test('a dotted key reaches into the record', () => {
74
- expect(ids(filterRecords(tasks, { 'owner.team': 'core' }))).toEqual(['a', 'b'])
75
- expect(ids(filterRecords(tasks, { 'owner.name': { $in: ['cid'] } }))).toEqual(['c'])
76
- })
77
-
78
- // An optional filter with nothing chosen arrives as `undefined`. Comparing against it would
79
- // filter the list down to records that lack the property — an empty screen for a dropdown the
80
- // user never touched.
81
- test('an undefined criteria value is skipped, null is a real IS NULL', () => {
82
- expect(ids(filterRecords(tasks, { status: undefined }))).toEqual(['a', 'b', 'c', 'd'])
83
- expect(ids(filterRecords(tasks, { status: null as never }))).toEqual(['d'])
84
- })
85
-
86
- // No schema means no way to tell a typo from a property this record simply lacks. Postgres
87
- // throws; here the honest answer is that nothing matches.
88
- test('an unknown key matches nothing rather than throwing', () => {
89
- expect(ids(filterRecords(tasks, { nonesuch: 'x' }))).toEqual([])
90
- expect(matchCriteria(tasks[0]!, { nonesuch: 'x' })).toBe(false)
91
- })
92
-
93
- test('an unknown operator is rejected', () => {
94
- expect(() => filterRecords(tasks, { points: { $nope: 1 } as never })).toThrow()
95
- })
96
-
97
- test('empty criteria keeps everything', () => {
98
- expect(ids(filterRecords(tasks, {}))).toEqual(['a', 'b', 'c', 'd'])
99
- expect(ids(filterRecords(tasks, undefined))).toEqual(['a', 'b', 'c', 'd'])
100
- })
101
-
102
- test('dates compare by instant', () => {
103
- const dated: Task[] = [
104
- { id: 'x', due: new Date('2026-01-01') },
105
- { id: 'y', due: new Date('2026-06-01') },
106
- ]
107
- expect(ids(filterRecords(dated, { due: { $gt: new Date('2026-03-01') } }))).toEqual(['y'])
108
- })
109
-
110
- test('sort ascending, descending and by a dotted key; absent values sort last', () => {
111
- expect(ids(sortRecords(tasks, ['points']))).toEqual(['c', 'a', 'd', 'b'])
112
- expect(ids(sortRecords(tasks, [['points', true]]))).toEqual(['b', 'd', 'a', 'c'])
113
- expect(ids(sortRecords(tasks, ['owner.name']))).toEqual(['a', 'b', 'c', 'd'])
114
- })
115
-
116
- test('sorting does not mutate the input', () => {
117
- const input = [...tasks]
118
- sortRecords(input, [['points', true]])
119
- expect(ids(input)).toEqual(['a', 'b', 'c', 'd'])
120
- })
121
- })