@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.
- package/README.md +336 -135
- package/agent-meta/manifest.json +80 -4
- package/agent-meta/skills/resource/SKILL.md +24 -105
- package/agent-meta/skills/resource/parts/capabilities.md +23 -0
- package/agent-meta/skills/resource/parts/criteria.md +60 -0
- package/agent-meta/skills/resource/parts/memory-engine.md +27 -0
- package/agent-meta/skills/resource/parts/migrations.md +35 -0
- package/agent-meta/skills/resource-choice/SKILL.md +16 -9
- package/build/consts.d.ts +0 -3
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +0 -23
- package/build/consts.js.map +1 -1
- package/build/errors.d.ts +13 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +15 -1
- package/build/errors.js.map +1 -1
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/migration.js +0 -0
- package/build/migration.js.map +1 -1
- package/build/types.d.ts +3 -1
- package/build/types.d.ts.map +1 -1
- package/build/utils/consts.local.d.ts +2 -0
- package/build/utils/consts.local.d.ts.map +1 -0
- package/build/utils/consts.local.js +2 -0
- package/build/utils/consts.local.js.map +1 -0
- package/build/utils/name.d.ts.map +1 -1
- package/build/utils/name.js.map +1 -1
- package/build/utils/query/types.d.ts +35 -0
- package/build/utils/query/types.d.ts.map +1 -0
- package/build/utils/query/types.js +2 -0
- package/build/utils/query/types.js.map +1 -0
- package/build/utils/query.d.ts +8 -20
- package/build/utils/query.d.ts.map +1 -1
- package/build/utils/query.js +97 -103
- package/build/utils/query.js.map +1 -1
- package/build/utils.d.ts +4 -0
- package/build/utils.d.ts.map +1 -0
- package/build/utils.js +24 -0
- package/build/utils.js.map +1 -0
- package/package.json +7 -5
- package/src/consts.ts +0 -25
- package/src/errors.ts +16 -1
- package/src/index.ts +2 -0
- package/src/migration.ts +0 -0
- package/src/types.ts +1 -1
- package/src/utils/consts.local.ts +1 -0
- package/src/utils/name.ts +2 -2
- package/src/utils/query/types.ts +37 -0
- package/src/utils/query.ts +106 -98
- package/src/utils.ts +26 -0
- package/tests/errors.spec.ts +60 -0
- package/tests/tsconfig.json +17 -0
- 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
|
+
}
|
package/src/utils/query.ts
CHANGED
|
@@ -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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
185
|
+
const value = reach(record, key.split('.'))
|
|
194
186
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
205
|
+
|
|
206
|
+
return true
|
|
212
207
|
}
|
|
213
208
|
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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
|
-
|
|
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 =>
|
|
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