@owlmeans/resource 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 (56) hide show
  1. package/README.md +336 -135
  2. package/agent-meta/manifest.json +80 -4
  3. package/agent-meta/skills/resource/SKILL.md +24 -105
  4. package/agent-meta/skills/resource/parts/capabilities.md +23 -0
  5. package/agent-meta/skills/resource/parts/criteria.md +60 -0
  6. package/agent-meta/skills/resource/parts/memory-engine.md +27 -0
  7. package/agent-meta/skills/resource/parts/migrations.md +35 -0
  8. package/agent-meta/skills/resource-choice/SKILL.md +16 -9
  9. package/build/consts.d.ts +0 -3
  10. package/build/consts.d.ts.map +1 -1
  11. package/build/consts.js +0 -23
  12. package/build/consts.js.map +1 -1
  13. package/build/errors.d.ts +13 -0
  14. package/build/errors.d.ts.map +1 -1
  15. package/build/errors.js +15 -1
  16. package/build/errors.js.map +1 -1
  17. package/build/index.d.ts +2 -0
  18. package/build/index.d.ts.map +1 -1
  19. package/build/index.js +1 -0
  20. package/build/index.js.map +1 -1
  21. package/build/migration.js +0 -0
  22. package/build/migration.js.map +1 -1
  23. package/build/types.d.ts +3 -1
  24. package/build/types.d.ts.map +1 -1
  25. package/build/utils/consts.local.d.ts +2 -0
  26. package/build/utils/consts.local.d.ts.map +1 -0
  27. package/build/utils/consts.local.js +2 -0
  28. package/build/utils/consts.local.js.map +1 -0
  29. package/build/utils/name.d.ts.map +1 -1
  30. package/build/utils/name.js.map +1 -1
  31. package/build/utils/query/types.d.ts +35 -0
  32. package/build/utils/query/types.d.ts.map +1 -0
  33. package/build/utils/query/types.js +2 -0
  34. package/build/utils/query/types.js.map +1 -0
  35. package/build/utils/query.d.ts +8 -20
  36. package/build/utils/query.d.ts.map +1 -1
  37. package/build/utils/query.js +97 -103
  38. package/build/utils/query.js.map +1 -1
  39. package/build/utils.d.ts +4 -0
  40. package/build/utils.d.ts.map +1 -0
  41. package/build/utils.js +24 -0
  42. package/build/utils.js.map +1 -0
  43. package/package.json +7 -5
  44. package/src/consts.ts +0 -25
  45. package/src/errors.ts +16 -1
  46. package/src/index.ts +2 -0
  47. package/src/migration.ts +0 -0
  48. package/src/types.ts +1 -1
  49. package/src/utils/consts.local.ts +1 -0
  50. package/src/utils/name.ts +2 -2
  51. package/src/utils/query/types.ts +37 -0
  52. package/src/utils/query.ts +106 -98
  53. package/src/utils.ts +26 -0
  54. package/tests/errors.spec.ts +60 -0
  55. package/tests/tsconfig.json +17 -0
  56. package/tsconfig.json +1 -1
@@ -0,0 +1 @@
1
+ export const COMPARISON = new Set(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte'])
package/src/utils/name.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type {Context, Config, ResourceDbService, DbConfig} from '../types.js'
1
+ import type { Context, Config, ResourceDbService, DbConfig } from '../types.js'
2
2
 
3
3
  export const dbName = <C extends Config, T extends Context<C> = Context<C>>(
4
4
  _context: T, service: ResourceDbService<any, any>, config: DbConfig
5
- ) => config.schema ?? config.alias ?? service.alias
5
+ ): string => config.schema ?? config.alias ?? service.alias
@@ -0,0 +1,37 @@
1
+ import type { Criteria, ListOptions, ListResult, ResourceRecord, Sort } from '../../types.js'
2
+
3
+ /**
4
+ * In-memory evaluation of `Criteria<any>` — the engine every store without a query language of its
5
+ * own (state, static, client, redis) answers a criteria object through.
6
+ */
7
+ export interface RecordQueryHelper {
8
+ /**
9
+ * Whether one record satisfies the criteria.
10
+ *
11
+ * `undefined` as a criteria value is skipped rather than compared — it is what an optional filter
12
+ * looks like when nothing was chosen, and treating it as "must be undefined" makes an unset
13
+ * dropdown filter the list down to nothing.
14
+ *
15
+ * @throws {UnsupportedArgumentError} on an unknown operator.
16
+ */
17
+ matchCriteria: <T extends ResourceRecord>(record: T, criteria?: Criteria<any>) => boolean
18
+ /** Every record the criteria accepts, in insertion order. */
19
+ filterRecords: <T extends ResourceRecord>(records: T[], criteria?: Criteria<any>) => T[]
20
+ /**
21
+ * Sort a copy. A bare field name is ascending; `{ field, order: 'desc' }` reverses it — the same
22
+ * meaning every backend gives it, so a sort written for one store orders the same way against
23
+ * another.
24
+ */
25
+ sortRecords: <T extends ResourceRecord>(records: T[], sort?: Sort<any>[]) => T[]
26
+ /** The first record the criteria accepts, honouring the sort. */
27
+ firstMatch: <T extends ResourceRecord>(
28
+ records: T[], where?: Criteria<any>, opts?: { sort?: Sort<any>[] }
29
+ ) => T | null
30
+ /**
31
+ * Filter, sort and page an in-memory record set into the shape every resource answers with.
32
+ * `size` is opt-in: an in-memory store returns everything when none is asked for.
33
+ */
34
+ applyQuery: <T extends ResourceRecord>(
35
+ records: T[], where?: Criteria<any>, opts?: ListOptions<any>
36
+ ) => ListResult<T>
37
+ }
@@ -1,5 +1,7 @@
1
1
  import { UnsupportedArgumentError } from '../errors.js'
2
2
  import type { Criteria, ListOptions, ListResult, ResourceRecord, Sort } from '../types.js'
3
+ import { COMPARISON } from './consts.local.js'
4
+ import type { RecordQueryHelper } from './query/types.js'
3
5
 
4
6
  /**
5
7
  * In-memory evaluation of `Criteria<any>`.
@@ -17,8 +19,6 @@ import type { Criteria, ListOptions, ListResult, ResourceRecord, Sort } from '..
17
19
  * - There is no jsonb/column distinction, so a dotted key always reaches into the record.
18
20
  */
19
21
 
20
- const COMPARISON = new Set(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte'])
21
-
22
22
  /** Dates compare by instant; everything else compares as it is. */
23
23
  const comparable = (value: unknown): unknown =>
24
24
  value instanceof Date ? value.getTime() : value
@@ -159,119 +159,127 @@ const applyOperators = (value: unknown, spec: Record<string, unknown>): boolean
159
159
  return true
160
160
  }
161
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?: Criteria<any>
173
- ): boolean => {
174
- for (const [key, raw] of Object.entries(criteria ?? {})) {
175
- if (raw === undefined) {
176
- continue
177
- }
162
+ export const createRecordQueryHelper = (): RecordQueryHelper => {
163
+ const matchCriteria = <T extends ResourceRecord>(
164
+ record: T, criteria?: Criteria<any>
165
+ ): boolean => {
166
+ for (const [key, raw] of Object.entries(criteria ?? {})) {
167
+ if (raw === undefined) {
168
+ continue
169
+ }
178
170
 
179
- if (key === '$and' || key === '$or') {
180
- const parts = (Array.isArray(raw) ? raw : [raw]) as Criteria<any>[]
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 Criteria<any>)) return false
190
- continue
191
- }
171
+ if (key === '$and' || key === '$or') {
172
+ const parts = (Array.isArray(raw) ? raw : [raw]) as Criteria<any>[]
173
+ if (parts.length < 1) continue
174
+ const matched = key === '$and'
175
+ ? parts.every(part => matchCriteria(record, part))
176
+ : parts.some(part => matchCriteria(record, part))
177
+ if (!matched) return false
178
+ continue
179
+ }
180
+ if (key === '$not') {
181
+ if (matchCriteria(record, raw as Criteria<any>)) return false
182
+ continue
183
+ }
192
184
 
193
- const value = reach(record, key.split('.'))
185
+ const value = reach(record, key.split('.'))
194
186
 
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
187
+ if (raw === null) {
188
+ if (value != null) return false
189
+ continue
190
+ }
191
+ if (isOperatorSpec(raw)) {
192
+ if (!applyOperators(value, raw as Record<string, unknown>)) return false
193
+ continue
194
+ }
195
+ if (Array.isArray(raw)) {
196
+ /**
197
+ * A bare array means "any of these", exactly as it does against a relational store.
198
+ * Exact array equality stays reachable as `{ $eq: [...] }`.
199
+ */
200
+ if (!inList(value, raw)) return false
201
+ continue
202
+ }
203
+ if (!equal(value, raw)) return false
210
204
  }
211
- if (!equal(value, raw)) return false
205
+
206
+ return true
212
207
  }
213
208
 
214
- return true
215
- }
209
+ const filterRecords = <T extends ResourceRecord>(
210
+ records: T[], criteria?: Criteria<any>
211
+ ): T[] => criteria == null || Object.keys(criteria).length < 1
212
+ ? [...records]
213
+ : records.filter(record => matchCriteria(record, criteria))
216
214
 
217
- /** Every record the criteria accepts, in insertion order. */
218
- export const filterRecords = <T extends ResourceRecord>(
219
- records: T[], criteria?: Criteria<any>
220
- ): T[] => criteria == null || Object.keys(criteria).length < 1
221
- ? [...records]
222
- : records.filter(record => matchCriteria(record, criteria))
215
+ const sortRecords = <T extends ResourceRecord>(records: T[], sort?: Sort<any>[]): T[] => {
216
+ if (sort == null || sort.length < 1) {
217
+ return [...records]
218
+ }
223
219
 
224
- /**
225
- * Sort a copy. A bare field name is ascending; `{ field, order: 'desc' }` reverses it — the same
226
- * meaning every backend gives it, so a sort written for one store orders the same way against
227
- * another.
228
- */
229
- export const sortRecords = <T extends ResourceRecord>(records: T[], sort?: Sort<any>[]): T[] => {
230
- if (sort == null || sort.length < 1) {
231
- return [...records]
220
+ const rules = sort.map(entry => typeof entry === 'string'
221
+ ? { field: entry, desc: false }
222
+ : { field: entry.field, desc: entry.order === 'desc' })
223
+
224
+ return [...records].sort((left, right) => {
225
+ for (const { field, desc } of rules) {
226
+ const a = comparable(reach(left, field.split('.')))
227
+ const b = comparable(reach(right, field.split('.')))
228
+ if (a == null && b == null) continue
229
+ /** An absent value sorts last ascending, which is where a reader expects "no value". */
230
+ if (a == null) return 1
231
+ if (b == null) return -1
232
+ if ((a as never) < (b as never)) return desc ? 1 : -1
233
+ if ((a as never) > (b as never)) return desc ? -1 : 1
234
+ }
235
+
236
+ return 0
237
+ })
232
238
  }
233
239
 
234
- const rules = sort.map(entry => typeof entry === 'string'
235
- ? { field: entry, desc: false }
236
- : { field: entry.field, desc: entry.order === 'desc' })
240
+ const firstMatch = <T extends ResourceRecord>(
241
+ records: T[], where?: Criteria<any>, opts?: { sort?: Sort<any>[] }
242
+ ): T | null => sortRecords(filterRecords(records, where), opts?.sort)[0] ?? null
237
243
 
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
244
+ const applyQuery = <T extends ResourceRecord>(
245
+ records: T[], where?: Criteria<any>, opts?: ListOptions<any>
246
+ ): ListResult<T> => {
247
+ const matched = sortRecords(filterRecords(records, where), opts?.sort)
248
+ const total = matched.length
249
+ // `size: 0` is the explicit ask for everything — the same meaning the paged backends give it, so
250
+ // one query object means one thing whichever store answers it. An omitted size is unpaged here.
251
+ if (opts?.size == null || opts.size === 0) {
252
+ return { items: matched, total }
248
253
  }
254
+ const page = opts.page ?? 0
255
+ const from = page * opts.size
249
256
 
250
- return 0
251
- })
257
+ return { items: matched.slice(from, from + opts.size), total, page, size: opts.size }
258
+ }
259
+
260
+ return { matchCriteria, filterRecords, sortRecords, firstMatch, applyQuery }
252
261
  }
253
262
 
254
- /** The first record the criteria accepts, honouring the sort. */
263
+ export const recordQueryHelper = createRecordQueryHelper()
264
+
265
+ /** @deprecated compat:factory-refactor — use `recordQueryHelper.matchCriteria(…)` */
266
+ export const matchCriteria = <T extends ResourceRecord>(record: T, criteria?: Criteria<any>): boolean =>
267
+ recordQueryHelper.matchCriteria(record, criteria)
268
+
269
+ /** @deprecated compat:factory-refactor — use `recordQueryHelper.filterRecords(…)` */
270
+ export const filterRecords = <T extends ResourceRecord>(records: T[], criteria?: Criteria<any>): T[] =>
271
+ recordQueryHelper.filterRecords(records, criteria)
272
+
273
+ /** @deprecated compat:factory-refactor — use `recordQueryHelper.sortRecords(…)` */
274
+ export const sortRecords = <T extends ResourceRecord>(records: T[], sort?: Sort<any>[]): T[] =>
275
+ recordQueryHelper.sortRecords(records, sort)
276
+
277
+ /** @deprecated compat:factory-refactor — use `recordQueryHelper.firstMatch(…)` */
255
278
  export const firstMatch = <T extends ResourceRecord>(
256
279
  records: T[], where?: Criteria<any>, opts?: { sort?: Sort<any>[] }
257
- ): T | null => sortRecords(filterRecords(records, where), opts?.sort)[0] ?? null
280
+ ): T | null => recordQueryHelper.firstMatch(records, where, opts)
258
281
 
259
- /**
260
- * Filter, sort and page an in-memory record set into the shape every resource answers with.
261
- * `size` is opt-in: an in-memory store returns everything when none is asked for.
262
- */
282
+ /** @deprecated compat:factory-refactor — use `recordQueryHelper.applyQuery(…)` */
263
283
  export const applyQuery = <T extends ResourceRecord>(
264
284
  records: T[], where?: Criteria<any>, opts?: ListOptions<any>
265
- ): ListResult<T> => {
266
- const matched = sortRecords(filterRecords(records, where), opts?.sort)
267
- const total = matched.length
268
- // `size: 0` is the explicit ask for everything — the same meaning the paged backends give it, so
269
- // one query object means one thing whichever store answers it. An omitted size is unpaged here.
270
- if (opts?.size == null || opts.size === 0) {
271
- return { items: matched, total }
272
- }
273
- const page = opts.page ?? 0
274
- const from = page * opts.size
275
-
276
- return { items: matched.slice(from, from + opts.size), total, page, size: opts.size }
277
- }
285
+ ): ListResult<T> => recordQueryHelper.applyQuery(records, where, opts)
package/src/utils.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { ListResult, ResourceRecord } from './types.js'
2
+ import type { JSONSchemaType } from 'ajv'
3
+
4
+ export const createListSchema = <T extends ResourceRecord>(schema: JSONSchemaType<T>): JSONSchemaType<ListResult<T>> => ({
5
+ type: 'object',
6
+ properties: {
7
+ items: {
8
+ type: 'array',
9
+ items: {
10
+ ...schema,
11
+ type: 'object',
12
+ properties: {
13
+ id: { type: 'string', nullable: true },
14
+ ...schema.properties
15
+ },
16
+ ...(schema.required ? { required: [...schema.required] } : undefined),
17
+ additionalProperties: false,
18
+ }
19
+ },
20
+ total: { type: 'number' },
21
+ page: { type: 'number', nullable: true },
22
+ size: { type: 'number', nullable: true }
23
+ },
24
+ required: ['items', 'total'],
25
+ additionalProperties: false,
26
+ })
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { ResilientError } from '@owlmeans/error'
3
+ import * as errors from '../src/errors.js'
4
+
5
+ /**
6
+ * The status an HTTP boundary (`@owlmeans/server-api` `errorStatus`) reads off a class: a static
7
+ * `httpStatus` inherited through the constructor chain. A refusal of the caller's request declares
8
+ * a 4xx; a fault declares nothing and answers 500.
9
+ */
10
+ const statusOf = (error: Error): unknown => (error.constructor as { httpStatus?: unknown }).httpStatus
11
+
12
+ const DECLARED: Record<string, number | undefined> = {
13
+ ResourceError: undefined,
14
+ UnknownRecordError: 404,
15
+ RecordExists: 409,
16
+ MisshapedRecord: undefined,
17
+ RecordUpdateFailed: undefined,
18
+ UnsupportedArgumentError: undefined,
19
+ UnsupportedMethodError: undefined,
20
+ MigrationError: undefined,
21
+ MigrationConflict: undefined,
22
+ }
23
+
24
+ const classes = Object.entries(errors).filter(([, value]) =>
25
+ typeof value === 'function' && value.prototype instanceof ResilientError
26
+ ) as [string, new (message: string) => ResilientError][]
27
+
28
+ describe('resource errors — declared HTTP statuses', () => {
29
+ test('every class of the family has decided its status', () => {
30
+ expect(classes.map(([name]) => name).sort()).toEqual(Object.keys(DECLARED).sort())
31
+ })
32
+
33
+ test('an absent record is 404, a taken id or key 409; faults declare nothing', () => {
34
+ for (const [name, Class] of classes) {
35
+ expect([name, statusOf(new Class('x'))]).toEqual([name, DECLARED[name]])
36
+ }
37
+ })
38
+
39
+ test('an error rebuilt from its marshalled form keeps its class and its status', () => {
40
+ for (const [name, Class] of classes) {
41
+ const back = ResilientError.ensure(new Class('x').marshal())
42
+ expect([name, back.constructor === Class, statusOf(back)]).toEqual([name, true, DECLARED[name]])
43
+ }
44
+ })
45
+ })
46
+
47
+ describe('UnknownRecordError.id', () => {
48
+ test('is everything after the first separator, however many the id carries', () => {
49
+ expect(new errors.UnknownRecordError('abc').id).toBe('abc')
50
+ expect(new errors.UnknownRecordError('shelf/3/hammer').id).toBe('shelf/3/hammer')
51
+ expect(new errors.UnknownRecordError('{"url":"https://example.org/a"}').id)
52
+ .toBe('{"url":"https://example.org/a"}')
53
+ })
54
+
55
+ test('survives a marshal round trip', () => {
56
+ const back = ResilientError.ensure(new errors.UnknownRecordError('shelf/3').marshal())
57
+ expect(back).toBeInstanceOf(errors.UnknownRecordError)
58
+ expect((back as errors.UnknownRecordError).id).toBe('shelf/3')
59
+ })
60
+ })
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "types": [
8
+ "bun"
9
+ ],
10
+ "rootDir": "../",
11
+ "noEmit": true
12
+ },
13
+ "include": [
14
+ "./**/*",
15
+ "../src/**/*"
16
+ ]
17
+ }
package/tsconfig.json CHANGED
@@ -6,5 +6,5 @@
6
6
  "rootDir": "./src/",
7
7
  "outDir": "./build/"
8
8
  },
9
- "exclude": ["./dist/**/*", "./build/**/*", "./*.ts"]
9
+ "exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
10
10
  }