@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
package/README.md CHANGED
@@ -1,199 +1,400 @@
1
1
  # @owlmeans/resource
2
2
 
3
- Abstract CRUD interface and types for all data persistence in OwlMeans applications.
4
-
5
- ## Overview
6
-
7
- - `Resource<T>` interface: `get`, `load`, `list`, `count`, `create`, `update`, `save`, `delete`,
8
- `take`, `purge`
9
- - `ResourceRecord` base type (`{ id?: string }`) that all stored records extend
10
- - `ResourceMaker<R, T>` factory signature used by `makeMongoResource`, `createStateResource`, etc.
11
- - `Criteria<T>` and `Sort<T>` — one query language every backend speaks — plus `ListOptions<T>`,
12
- `ListQuery<T>` and `ListResult<T>`
13
- - Optional capabilities a backend composes in: `PubSubResource`, `WatchableResource`,
14
- `StreamResource`, `LockableResource`, `MigratableResource`
15
- - Error classes: `ResourceError`, `UnknownRecordError`, `RecordExists`
3
+ The storage-agnostic CRUD contract every OwlMeans data store implements: `Resource<T>`, the typed
4
+ criteria/sort/paging vocabulary, optional capabilities (pub/sub, watch, streams, field encryption),
5
+ the shared resource errors and the code-migration framework. An app imports it to **type** its
6
+ records, resource makers and queries, to throw and catch `UnknownRecordError` / `RecordExists`, and
7
+ to filter lists it already holds in memory. It does not store anything itself — pick a backend
8
+ (`@owlmeans/mongo-resource`, `@owlmeans/postgres-resource`, `@owlmeans/redis-resource`,
9
+ `@owlmeans/static-resource`, or `@owlmeans/state` on the client) for that; which one a feature
10
+ wants is the `resource-choice` decision.
16
11
 
17
12
  ## Installation
18
13
 
19
14
  ```bash
20
- bun add @owlmeans/resource@^0.1.18-rc.8
15
+ bun add @owlmeans/resource@^0.1.18
21
16
  ```
22
17
 
18
+ ## Concepts
19
+
20
+ - **Record** — any object extending `ResourceRecord` (`{ id?: string }`); `id` is the only field the
21
+ contract knows about.
22
+ - **Resource** — `Resource<T>`, typed CRUD over records, registered on a context under an alias and
23
+ read back with `ctx.resource<T>(alias)`. Every method returns records, never driver results.
24
+ - **Resource maker** — a `ResourceMaker<R, T>` factory `(dbAlias?, serviceAlias?) => T` that builds a
25
+ concrete resource from a backend and declares its schema, indexes and migrations.
26
+ - **Criteria** — `Criteria<T>`, one query language keyed by the record's own fields that every
27
+ backend (SQL, Mongo, Redis, in-memory) answers the same way.
28
+ - **Capability** — an optional interface (`PubSubResource`, `WatchableResource`, `StreamResource`,
29
+ `LockableResource`, `MigratableResource`) a backend composes in beside `Resource<T>` only when it
30
+ can honour it.
31
+ - **Migration** — a named, checksummed body registered on a `MigratableResource`, applied once per
32
+ database during resource initialization, `Pre` or `Post` structure reconciliation.
33
+
23
34
  ## Usage
24
35
 
25
- Define a resource maker following the standard factory pattern:
36
+ ### Declare a record type and a resource maker
26
37
 
27
- ```typescript
28
- import type { ResourceMaker, ResourceRecord } from '@owlmeans/resource'
38
+ The maker is typed with `ResourceMaker`, so an app registers `makeXResource()` without repeating the
39
+ argument list. Schema, indexes and migrations are backend-specific calls on the resource it returns.
40
+
41
+ ```ts
42
+ import type { BasicConfig, BasicContext } from '@owlmeans/context'
29
43
  import { makeMongoResource } from '@owlmeans/mongo-resource'
44
+ import type { MongoResource } from '@owlmeans/mongo-resource'
45
+ import type { ResourceMaker, ResourceRecord } from '@owlmeans/resource'
30
46
 
31
- interface ProjectRecord extends ResourceRecord {
47
+ export const RES_PROJECT = 'project'
48
+
49
+ export interface ProjectRecord extends ResourceRecord {
32
50
  entityId: string
33
51
  alias: string
52
+ status: 'active' | 'paused' | 'archived'
53
+ tags: string[]
34
54
  createdAt: Date
35
55
  }
36
56
 
37
- export const makeProjectResource: ResourceMaker<ProjectRecord> = (dbAlias, serviceAlias) => {
38
- return makeMongoResource<ProjectRecord>('project', dbAlias, serviceAlias, 'projects')
57
+ export interface ProjectResource extends MongoResource<ProjectRecord> {}
58
+
59
+ export const makeProjectResource: ResourceMaker<ProjectRecord, ProjectResource> = (dbAlias, serviceAlias) => {
60
+ const resource = makeMongoResource<ProjectRecord, ProjectResource>(RES_PROJECT, dbAlias, serviceAlias)
61
+ resource.index('entityAlias', { entityId: 1, alias: 1 }, { unique: true })
62
+ resource.index('createdAt', { createdAt: -1 })
63
+
64
+ return resource
39
65
  }
66
+
67
+ // In the server context factory
68
+ context.registerResource(makeProjectResource())
69
+
70
+ // Anywhere with the context
71
+ export const projectResource = (ctx: BasicContext<BasicConfig>) =>
72
+ ctx.resource<ProjectResource>(RES_PROJECT)
40
73
  ```
41
74
 
42
- Use resource methods in a handler:
75
+ ### CRUD in a handler
43
76
 
44
- ```typescript
45
- import { UnknownRecordError } from '@owlmeans/resource'
46
- import type { ListResult } from '@owlmeans/resource'
77
+ A read is addressed by an id **or** by criteria; a write takes the record, with its id inside it.
78
+ The organization is keyed by `entityId`, which a server handler takes from `makeEntityScope(req).requireEntityKey()`
79
+ (`@owlmeans/auth-common`), never from the token or the body.
47
80
 
48
- // Get or throw — by id, or by whatever set of fields identifies the record
49
- const project = await ctx.project().get(projectId)
50
- const byName = await ctx.project().get({ entityId, alias: 'main' })
81
+ ```ts
82
+ import { makeEntityScope } from '@owlmeans/auth-common'
83
+ import type { AbstractRequest } from '@owlmeans/entrypoint'
84
+ import { MisshapedRecord, UnknownRecordError } from '@owlmeans/resource'
85
+ import type { Resource } from '@owlmeans/resource'
51
86
 
52
- // Load (returns null if not found)
53
- const story = await ctx.story().load(storyId)
87
+ export const handleCreateProject = async (req: AbstractRequest, ctx: Context) => {
88
+ const entityId = makeEntityScope(req).requireEntityKey()
89
+ const { alias } = req.body as { alias?: string }
90
+ if (alias == null || alias.trim() === '') {
91
+ throw new MisshapedRecord('alias-required')
92
+ }
54
93
 
55
- // List with criteria, sorting and paging
56
- const result: ListResult<ProjectRecord> = await ctx.project().list(
57
- { entityId, alias: ['main', 'staging'] },
58
- { sort: [{ field: 'createdAt', order: 'desc' }], page: 0, size: 20 }
59
- )
94
+ // create() throws RecordExists on a duplicate; let it reach the client as a typed error
95
+ return await projectResource(ctx).create({ entityId, alias, status: 'active', tags: [], createdAt: new Date() })
96
+ }
97
+
98
+ export const handleGetProject = async (req: AbstractRequest, ctx: Context) => {
99
+ const entityId = makeEntityScope(req).requireEntityKey()
100
+ const { alias } = req.params as { alias: string }
101
+
102
+ // One record by several fields is one call — get() throws UnknownRecordError on a miss
103
+ return await projectResource(ctx).get({ entityId, alias })
104
+ }
105
+
106
+ export const handleRenameProject = async (req: AbstractRequest, ctx: Context) => {
107
+ const entityId = makeEntityScope(req).requireEntityKey()
108
+ const { id } = req.params as { id: string }
109
+ const project = await projectResource(ctx).load(id)
110
+ if (project == null || project.entityId !== entityId) {
111
+ throw new UnknownRecordError(id)
112
+ }
113
+
114
+ // update() replaces the whole record — spread the loaded one
115
+ return await projectResource(ctx).update({ ...project, alias: (req.body as { alias: string }).alias })
116
+ }
117
+
118
+ export const handleDestroyProject = async (req: AbstractRequest, ctx: Context) => {
119
+ const { id } = req.params as { id: string }
120
+ // Dependants first: purge() refuses an empty criteria object
121
+ await ctx.resource<Resource<StoryRecord>>('story').purge({ projectId: id })
122
+ const deleted = await projectResource(ctx).delete(id)
123
+
124
+ return { id, deleted: deleted != null }
125
+ }
60
126
  ```
61
127
 
62
- ## API
128
+ ### Criteria, sorting and paging
63
129
 
64
- ### `Resource<T>` methods
130
+ ```ts
131
+ import type { Criteria, ListQuery, ListResult } from '@owlmeans/resource'
65
132
 
66
- Every read takes either an id or a criteria object, so fetching one record by several fields is a
67
- single call rather than a list whose first element is taken.
133
+ export const handleListProjects = async (req: AbstractRequest, ctx: Context) => {
134
+ const entityId = makeEntityScope(req).requireEntityKey()
135
+ const { where, sort, page, size } = req.query as ListQuery<ProjectRecord>
68
136
 
69
- - `get(id)` / `get(where, { sort })` — fetch one; throws `UnknownRecordError` if missing
70
- - `load(id)` / `load(where, { sort })` — fetch one; returns `null` if missing
71
- - `list(where?, { sort, page, size })` — returns `ListResult<T>`
72
- - `count(where?)` — how many records the criteria match
73
- - `create(record, { ttl })` — insert; throws `RecordExists` if already exists
74
- - `update(record, { ttl })` — replaces the whole record; throws `UnknownRecordError` if missing
75
- - `save(record, { ttl })` — creates when the record carries no id, replaces otherwise
76
- - `delete(id)` — remove; returns the deleted record or `null`
77
- - `take(id)` — **delete and return** the record; throws `UnknownRecordError` if missing. Never use it
78
- for read-only checks
79
- - `purge(where)` — bulk delete; refuses an empty criteria object rather than emptying the resource
137
+ const criteria: Criteria<ProjectRecord> = {
138
+ ...where,
139
+ entityId, // always scoped, whatever the client sent
140
+ status: where?.status ?? ['active', 'paused'],
141
+ 'meta.tier': { $in: ['gold', 'silver'] }, // a dotted key reaches into a nested value
142
+ $or: [{ alias: { $ilike: 'owl%' } }, { tags: { $overlaps: ['pinned'] } }]
143
+ }
80
144
 
81
- `ttl` is seconds from now or the instant to expire at; backends without expiry refuse it.
145
+ const result: ListResult<ProjectRecord> = await projectResource(ctx).list(criteria, {
146
+ sort: sort ?? [{ field: 'createdAt', order: 'desc' }],
147
+ page: page ?? 0,
148
+ size: size ?? 20
149
+ })
82
150
 
83
- ### `Criteria<T>`
151
+ return result // { items, total, page, size } — total counts every match, not the window
152
+ }
84
153
 
85
- Keys are the record's own fields, so a typo is a compile error; a dotted key reaches into a nested
86
- value or a jsonb column and stays open.
154
+ // Counting without carrying the records back
155
+ const inProgress = await ctx.resource<Resource<StoryRecord>>('story').count({ projectId, status: { $ne: 'done' } })
87
156
 
88
- - A bare value is equality, a bare **array** is "any of these", `null` asks for the absence of a
89
- value, and `undefined` is **skipped** — an untouched filter must never empty a list
90
- - Operators: `$eq` `$ne` `$gt` `$gte` `$lt` `$lte` `$in` `$nin` `$exists` `$null` `$like` `$ilike`
91
- `$regex` `$startsWith` `$endsWith` `$between` `$contains` `$contained` `$overlaps`
92
- - Composition: `$and`, `$or`, `$not`
93
-
94
- ```typescript
95
- await ctx.project().list({
96
- status: ['active', 'paused'],
97
- archivedAt: null,
98
- 'meta.tier': { $in: ['gold', 'silver'] },
99
- $or: [{ ownerId }, { shared: true }]
100
- })
157
+ // The newest match, chosen by sort
158
+ const latest = await projectResource(ctx).load({ entityId }, { sort: [{ field: 'createdAt', order: 'desc' }] })
101
159
  ```
102
160
 
103
- ### `Sort<T>`
161
+ A shared `ListResult` schema for an entrypoint response comes from `createListSchema`:
104
162
 
105
- A bare field name sorts ascending; `{ field, order: 'asc' | 'desc' }` states it explicitly. `sort`
106
- takes a list, applied left to right.
163
+ ```ts
164
+ import { createListSchema } from '@owlmeans/resource'
107
165
 
108
- ### `ListResult<T>` and paging
166
+ export const ProjectListSchema = createListSchema(ProjectSchema) // JSONSchemaType<ListResult<ProjectRecord>>
167
+ ```
109
168
 
110
- `list()` answers `{ items, total, page?, size? }`.
169
+ ### The same criteria in memory
111
170
 
112
- Paging is opt-in per call and each backend decides what an unasked-for page means. Mongo and
113
- Postgres cap an unbounded read at **100** records when no `size` is given; Redis and the in-memory
114
- stores (state, static, client and config resources) return everything. `size: 0` means **no limit**
115
- everywhere. Asking for a `page` without a `size` on an unpaged backend throws
116
- `UnsupportedArgumentError('page-without-size')`.
171
+ The in-memory helpers evaluate a criteria object exactly as the backends do, so a filter written for
172
+ an endpoint selects the same records when a screen or a test applies it to records already in hand.
173
+ Client state hooks take the same `Criteria<T>`.
117
174
 
118
- ### `ResourceMaker<R, T>`
175
+ ```ts
176
+ import { useStoreList } from '@owlmeans/client'
177
+ import { recordQueryHelper } from '@owlmeans/resource'
178
+ import type { Criteria } from '@owlmeans/resource'
119
179
 
120
- ```typescript
121
- interface ResourceMaker<R extends ResourceRecord, T extends Resource<R> = Resource<R>> {
122
- (dbAlias?: string, serviceAlias?: string): T
123
- }
180
+ const open: Criteria<StoryRecord> = { status: ['open', 'review'], archivedAt: null, assignee: undefined }
181
+
182
+ recordQueryHelper.filterRecords(stories, open) // every match, insertion order
183
+ recordQueryHelper.firstMatch(stories, open, { sort: [{ field: 'updatedAt', order: 'desc' }] })
184
+ recordQueryHelper.applyQuery(stories, open, { sort: ['title'], page: 1, size: 10 }) // a full ListResult
185
+ stories.some(story => recordQueryHelper.matchCriteria(story, { priority: { $gte: 3 } }))
186
+
187
+ // Web state (hook from @owlmeans/client) — the query is the same vocabulary
188
+ export const useStoryList = (query: Criteria<StoryRecord> = {}) =>
189
+ useStoreList<StoryRecord>({ query, resource: STORY_STATE })
124
190
  ```
125
191
 
126
- ### Optional capabilities
192
+ ### Migrations and custom errors
193
+
194
+ A migratable backend runs registered migrations automatically during resource initialization;
195
+ registering one inside the maker is all an app does. Domain errors extend `ResourceError` and are
196
+ registered so they survive a service boundary.
197
+
198
+ ```ts
199
+ import { ResilientError } from '@owlmeans/error'
200
+ import { makePostgresResource } from '@owlmeans/postgres-resource'
201
+ import { MigrationStage, ResourceError } from '@owlmeans/resource'
202
+ import type { ResourceMaker } from '@owlmeans/resource'
203
+
204
+ export const makeStoryResource: ResourceMaker<StoryRecord, StoryResource> = (dbAlias, serviceAlias) => {
205
+ const resource = makePostgresResource<StoryRecord, StoryResource>('story', dbAlias, serviceAlias)
206
+ resource.schema = StorySchema
207
+
208
+ // Pre: before the table is reshaped — the only place to rescue data a reconciliation would drop
209
+ resource.migration('0001-rescue-legacy-code', async tx => {
210
+ await tx.execute(`UPDATE {{}} SET code = legacy_code WHERE code IS NULL`)
211
+ }, MigrationStage.Pre)
212
+
213
+ // Post: after reconciliation, so the new columns exist
214
+ resource.migration('0002-backfill-priority', async tx => {
215
+ await tx.execute(`UPDATE {{}} SET priority = 0 WHERE priority IS NULL`)
216
+ }, MigrationStage.Post)
217
+
218
+ return resource
219
+ }
127
220
 
128
- A backend composes in only what it can honour; the base `Resource<T>` contract stays small.
221
+ export class StoryResourceError extends ResourceError {
222
+ public static override typeName = `MyAppStory${ResourceError.typeName}`
129
223
 
130
- - `PubSubResource<T>` — `publish(value, channel?)`, `subscribe(handler, { channel?, once?, ttl? })`
131
- - `WatchableResource<T>` — `watch(id, handler, opts?)`, following ONE record
132
- - `StreamResource<T>` — `stream(key, value)` and `consume(key, { group?, consumer?, block? })`
133
- - `LockableResource<T>` — `lock(record, fields?)` / `unlock(record, fields?)` for field level
134
- encryption at rest
135
- - `MigratableResource<Tx, Self>` — see below
224
+ constructor(message: string = 'error') {
225
+ super(`my-app-story:${message}`)
226
+ this.type = StoryResourceError.typeName
227
+ }
228
+ }
136
229
 
137
- ### In-memory query engine
230
+ ResilientError.registerErrorClass(StoryResourceError)
231
+ ```
138
232
 
139
- The same criteria and sorting, evaluated over plain arrays. Every in-memory store uses these, and so
140
- can application code that has already loaded its records:
233
+ ## API
141
234
 
142
- - `matchCriteria(record, where)` — does one record satisfy the criteria
143
- - `filterRecords(records, where)` / `sortRecords(records, sort)`
144
- - `firstMatch(records, where, opts?)` — the `load(where)` semantics
145
- - `applyQuery(records, query)` — filter, sort and page in one step, answering a `ListResult`
235
+ ### `Resource<T>` methods
146
236
 
147
- ### Database naming
237
+ Every read takes either an id or a criteria object, so fetching one record by several fields is a
238
+ single call rather than a list whose first element is taken.
148
239
 
149
- `dbName()` answers `config.schema ?? config.alias ?? service.alias` — the database (or schema) a
150
- resource reads and writes.
240
+ | Method | Signature | Notes |
241
+ |---|---|---|
242
+ | `get` | `(id) → T` / `(where, { sort }?) → T` | throws `UnknownRecordError` on a miss |
243
+ | `load` | `(id) → T \| null` / `(where, { sort }?) → T \| null` | the miss-tolerant read |
244
+ | `list` | `(where?, { sort, page, size }?) → ListResult<T>` | `total` counts every match |
245
+ | `count` | `(where?) → number` | how many records the criteria match |
246
+ | `create` | `(record, { ttl }?) → T` | throws `RecordExists` |
247
+ | `update` | `(record, { ttl }?) → T` | replaces the whole record; throws `UnknownRecordError` |
248
+ | `save` | `(record, { ttl }?) → T` | creates when the record carries no id, replaces otherwise |
249
+ | `delete` | `(id) → T \| null` | returns what it removed |
250
+ | `take` | `(id) → T` | **deletes and returns** the record; throws `UnknownRecordError`. Never a read |
251
+ | `purge` | `(where) → number` | bulk delete; refuses an empty criteria object |
151
252
 
152
- ### Migration framework
253
+ `ttl` is seconds from now or the instant to expire at; backends without expiry refuse it.
153
254
 
154
- Storage-agnostic and optional — a backend that supports code migrations (mongo, postgres)
155
- extends `MigratableResource<Tx, Self>`; backends with nothing to migrate simply don't (the same way
156
- pub/sub is an optional capability of redis resources). Migrations run **automatically during
157
- resource initialization** (app setup) — registering one is all an app ever does.
255
+ ### `Criteria<T>`
158
256
 
159
- - `MigratableResource<Tx, Self>` — `migration(name, apply, stage?): Self` + `migrations()`
160
- - `createMigrationRegistry<Tx>()` — per-alias ledger of registered migrations; declaration
161
- order is application order; identical re-registration is a no-op, a changed body under a
162
- used name throws `MigrationConflict`
163
- - `MigrationStore<Tx>` — the *migration register* a database implements to track applied
164
- migrations (`ensure` / `applied` / `baseline` / `run`); both mongo and postgres persist it
165
- as `_owlmeans_migrations`
166
- - `runMigrations(alias, registry, store, opts?)` — the storage-agnostic runner; with
167
- `baseline: true` migrations are recorded instead of run (used on just-created structures);
168
- `strictChecksum` (default) rejects edited applied bodies
169
- - `MigrationStage.Pre` / `MigrationStage.Post` — run before / after the backend's structure
170
- reconciliation
257
+ Keys are the record's own fields, so a typo is a compile error; a dotted key reaches into a nested
258
+ value or a jsonb column and stays open.
171
259
 
172
- ### Helpers
260
+ | Written as | Means |
261
+ |---|---|
262
+ | `{ status: 'open' }` | equality |
263
+ | `{ status: ['open', 'done'] }` | any of these — exact array equality is `{ $eq: [...] }` |
264
+ | `{ archivedAt: null }` | the absence of a value |
265
+ | `{ status: undefined }` | skipped — an untouched filter never empties a list |
266
+ | `{ 'profile.city': 'Krakow' }` | a nested value or a jsonb column |
173
267
 
174
- - `filterObject(obj, keep?)` — strip null/undefined fields from a record before saving
175
- - `createListSchema(schema)` — the AJV schema for a `ListResult` of the given record schema
176
- - `createDbService(alias, override, init?)` — base plumbing for `ResourceDbService` implementations
268
+ Operators: `$eq` `$ne` `$gt` `$gte` `$lt` `$lte` `$in` `$nin` `$exists` `$null` `$like` `$ilike`
269
+ `$regex` `$startsWith` `$endsWith` `$between` `$contains` `$contained` `$overlaps`. Composition:
270
+ `$and`, `$or`, `$not`. An operator a store cannot express raises `UnsupportedArgumentError`.
177
271
 
178
- ### Error Classes
272
+ ### `Sort<T>`
179
273
 
180
- - `ResourceError` — base resource error
181
- - `UnknownRecordError` — record not found (has `.id` getter)
182
- - `RecordExists` — duplicate record on `create`
183
- - `RecordUpdateFailed` — a write reached the backend but changed nothing
184
- - `MisshapedRecord` — invalid record structure
185
- - `UnsupportedArgumentError` — an argument this backend cannot honour, e.g. `page-without-size`
186
- - `UnsupportedMethodError` — a method this backend does not implement
187
- - `MigrationError` — a migration body threw; initialization aborts
188
- - `MigrationConflict` — an already applied migration's body changed, or a name was redeclared with a different body
274
+ A bare field name sorts ascending; `{ field, order: 'asc' | 'desc' }` states it explicitly. `sort`
275
+ takes a list, applied left to right. Where an absent value lands is the store's rule: in memory and
276
+ in Postgres it sorts last ascending, in Mongo first.
189
277
 
190
- ## Related Packages
278
+ ### `ListResult<T>` and paging
191
279
 
192
- - [`@owlmeans/mongo-resource`](../mongo-resource) — MongoDB implementation
193
- - [`@owlmeans/postgres-resource`](../postgres-resource) — PostgreSQL implementation
194
- - [`@owlmeans/redis-resource`](../redis-resource) — Redis implementation
280
+ `list()` answers `{ items, total, page?, size? }`. Paging is opt-in per call and each backend decides
281
+ what an unasked-for page means:
282
+
283
+ | Backend | `list(where)` with no `size` |
284
+ |---|---|
285
+ | mongo, postgres | a page of 100 records |
286
+ | redis, static, client-resource, config, state | every match |
287
+
288
+ `size: 0` means **no limit** everywhere. Asking for a `page` without a `size` on an unpaged backend
289
+ throws `UnsupportedArgumentError('page-without-size')`.
290
+
291
+ ### Types
292
+
293
+ | Symbol | Kind | Purpose |
294
+ |---|---|---|
295
+ | `ResourceRecord` | interface | `{ id?: string }` — the constraint every record satisfies |
296
+ | `Resource<T>` | interface | the CRUD contract above; extends `BasicResource` from `@owlmeans/context` |
297
+ | `ResourceMaker<R, T>` | interface | `(dbAlias?, serviceAlias?) => T` — the maker signature |
298
+ | `Criteria<T>`, `FieldCriteria<V>`, `FieldOperators<V>` | types | the query language |
299
+ | `Sort<T>`, `SortField<T>` | types | ordering |
300
+ | `FirstOptions<T>` | interface | `{ sort? }` for `get`/`load` by criteria |
301
+ | `ListOptions<T>` | interface | `{ sort?, page?, size? }` for `list` |
302
+ | `ListQuery<T>` | interface | `ListOptions<T>` plus `where` — the one-object form an API carries |
303
+ | `ListResult<T>` | interface | `{ items, total, page?, size? }` |
304
+ | `WriteOptions`, `Ttl` | types | `{ ttl? }`; `number \| Date` |
305
+ | `PubSubResource<T>` | interface | `publish(value, channel?)`, `subscribe(handler, opts?) → Unsubscribe` |
306
+ | `WatchableResource<T>` | interface | `watch(id, handler, { once?, ttl? }?) → Unsubscribe` — one record |
307
+ | `StreamResource<T>` | interface | `stream(key, value)`, `consume(key, { group?, consumer?, block? }?)` |
308
+ | `LockableResource<T>` | interface | `lock(record, fields?)` / `unlock(record, fields?)` — field encryption at rest |
309
+ | `SubscribeOptions`, `Unsubscribe` | types | `{ channel?, once?, ttl? }`; `() => Promise<void>` |
310
+ | `MigratableResource<Tx, Self>` | interface | `migration(name, apply, stage?) → Self`, `migrations()` |
311
+ | `Migration<Tx>`, `MigrationRegistry<Tx>` | interfaces | a registered migration; the per-alias ledger |
312
+ | `MigrationStore<Tx>` | interface | the register a database implements: `ensure`/`applied`/`baseline`/`run` |
313
+ | `MigrationRunOptions`, `MigrationReport` | interfaces | `{ stage?, baseline?, strictChecksum? }`; `{ alias, stage, applied, baselined, skipped }` |
314
+ | `ResourceDbService<Db, Client>` | interface | a connection service: `db`/`client`/`clients`/`config`/`name`/`ensureConfigAlias`/`initialize` |
315
+ | `DbLocker<T>` | interface | `lock`/`unlock` by alias, for backends that encrypt fields |
316
+ | `DbConfig<P>` | interface | an entry of `cfg.dbs`: `service`, `alias?`, `host`, `port?`, `user?`, `secret?`, `schema?`, `resourcePrefix?`, `encryptionKey?`, `meta?` |
317
+ | `Config`, `Context` | interfaces | `BasicConfig` with `dbs?`; the matching `BasicContext` |
318
+
319
+ ### Functions and constants
320
+
321
+ | Symbol | Kind | Purpose |
322
+ |---|---|---|
323
+ | `recordQueryHelper` (`createRecordQueryHelper()`, `RecordQueryHelper`) | helper | the in-memory criteria engine below |
324
+ | `recordQueryHelper.matchCriteria(record, where?)` | member | does one record satisfy the criteria |
325
+ | `recordQueryHelper.filterRecords(records, where?)` | member | every match, in insertion order |
326
+ | `recordQueryHelper.sortRecords(records, sort?)` | member | a sorted copy |
327
+ | `recordQueryHelper.firstMatch(records, where?, { sort }?)` | member | the record `load(where)` returns |
328
+ | `recordQueryHelper.applyQuery(records, where?, opts?)` | member | filter, sort and page into a `ListResult` |
329
+ | `createListSchema(schema)` | function | AJV `JSONSchemaType<ListResult<T>>` for a record schema |
330
+ | `filterObject(obj, keep?)` | function | drop null/undefined properties, keeping names in `keep` |
331
+ | `createDbService(alias, override, init?)` | function | base for `ResourceDbService` implementations; `name()` resolves `config.schema ?? config.alias ?? service.alias` |
332
+ | `createMigrationRegistry<Tx>()` | function | per-alias migration ledger; identical re-registration is a no-op |
333
+ | `runMigrations(alias, registry, store, opts?)` | function | apply one stage's pending migrations → `MigrationReport` |
334
+ | `MigrationStage` | enum | `Pre` (before structure reconciliation) / `Post` (after) |
335
+
336
+ ### Errors
337
+
338
+ All extend `ResourceError` (itself a `ResilientError`) and are registered for marshalling.
339
+
340
+ | Symbol | Thrown when |
341
+ |---|---|
342
+ | `ResourceError` | base resource error |
343
+ | `UnknownRecordError` | a record is not found; `.id` returns the id |
344
+ | `RecordExists` | `create` hits a duplicate |
345
+ | `RecordUpdateFailed` | a write reached the backend but changed nothing |
346
+ | `MisshapedRecord` | a record has an invalid structure |
347
+ | `UnsupportedArgumentError` | an argument this backend cannot honour, e.g. `page-without-size` |
348
+ | `UnsupportedMethodError` | a method this backend does not implement |
349
+ | `MigrationError` | a migration body threw; initialization aborts |
350
+ | `MigrationConflict` | an applied migration's body changed, or a name was redeclared with a different body |
351
+
352
+ ## Migration framework
353
+
354
+ Storage-agnostic and optional — a backend that supports code migrations (mongo, postgres) extends
355
+ `MigratableResource<Tx, Self>`; backends with nothing to migrate simply don't.
356
+
357
+ - Declaration order is application order; the registry never sorts by name.
358
+ - Both mongo and postgres persist the ledger as `_owlmeans_migrations`.
359
+ - On a just-created structure `runMigrations(..., { baseline: true })` records every migration
360
+ instead of running it; otherwise a backend runs `Pre` → reconciles structure → runs `Post`.
361
+ - `strictChecksum` (default on) rejects an edited body of an already applied migration.
362
+
363
+ Adding migration support to a new backend: extend its interface with `MigratableResource<YourTx>`,
364
+ keep registrations in a module-scope declaration keyed by alias, implement `MigrationStore` over a
365
+ durable ledger, and make `run` atomic with the ledger write where the database allows it.
366
+
367
+ ## Common pitfalls
368
+
369
+ - `take(id)` **deletes** the record it returns — never use it to fetch something.
370
+ - Writes have no `(id, changes)` overload: pass the record with its id inside. `update` replaces the
371
+ whole record, so spread the loaded one first.
372
+ - Do not fetch one record with `list(...).items[0]` — `get(where)` / `load(where, { sort })` do it in
373
+ one call.
374
+ - A bare array in criteria means "any of these"; an `undefined` value is skipped, not matched.
375
+ - `list()` without `size` returns 100 records on mongo/postgres but everything elsewhere; pass
376
+ `size: 0` when you really want the whole set.
377
+ - `purge({})` is refused — bulk delete always names its criteria.
378
+ - Methods return records, never driver results — there is no `rowsAffected` to read.
379
+ - Sort on a required field when the position of absent values matters; it differs between stores.
380
+ - Never edit an applied migration — add a new one. Write its body inline in the callback: the
381
+ checksum hashes the callback's source, so a wrapper delegating to a captured function never
382
+ detects an edit.
383
+ - Install `@noble/hashes` and `@scure/base` alongside this package when you load
384
+ `createMigrationRegistry` or `runMigrations`; install peer `ajv` when you use `createListSchema`.
385
+ - Scope organization data by `entityId` from `makeEntityScope(req).requireEntityKey()`, never by an id taken from the
386
+ wire.
387
+
388
+ ## Related packages
389
+
390
+ - [`@owlmeans/mongo-resource`](../mongo-resource) — MongoDB implementation, migratable
391
+ - [`@owlmeans/postgres-resource`](../postgres-resource) — PostgreSQL implementation, migratable
392
+ - [`@owlmeans/redis-resource`](../redis-resource) — Redis implementation with pub/sub, watch and streams
195
393
  - [`@owlmeans/static-resource`](../static-resource) — in-memory implementation over config records
196
394
  - [`@owlmeans/state`](../state) — the client store, with live subscriptions
395
+ - [`@owlmeans/client-resource`](../client-resource) — client-side caching over resources
396
+ - [`@owlmeans/context`](../context) — `registerResource` / `resource(alias)` and the lifecycle
397
+ - [`@owlmeans/error`](../error) — `ResilientError`, the base of every resource error
197
398
 
198
399
  <!-- owlmeans:agent-guidance:start -->
199
400
  ## Agent guidance
@@ -203,7 +404,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
203
404
  your project's skill store (`.agents/skills/`):
204
405
 
205
406
  ```sh
206
- npx @owlmeans/agent-skills@^0.1.18-rc.12
407
+ npx @owlmeans/agent-skills@^0.1.18
207
408
  ```
208
409
 
209
410
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
- "schemaVersion": 2,
2
+ "schemaVersion": 3,
3
3
  "package": "@owlmeans/resource",
4
- "version": "0.1.18-rc.9",
5
- "generatedAt": "2026-09-04T22:43:25.438Z",
4
+ "version": "0.1.18",
5
+ "generatedAt": "2026-10-28T00:31:34.719Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -10,7 +10,83 @@
10
10
  "name": "resource",
11
11
  "category": "package-specific",
12
12
  "file": "skills/resource/SKILL.md",
13
- "canonicalPath": ".agents/skills/resource/SKILL.md"
13
+ "canonicalPath": ".agents/skills/resource/SKILL.md",
14
+ "parts": [
15
+ {
16
+ "name": "capabilities",
17
+ "file": "skills/resource/parts/capabilities.md",
18
+ "description": "The optional capability interfaces a concrete resource composes beside Resource (pub/sub, watch, stream, lock, migrations).",
19
+ "purposes": [
20
+ "data"
21
+ ],
22
+ "mentions": [
23
+ "PubSubResource",
24
+ "WatchableResource",
25
+ "StreamResource",
26
+ "LockableResource",
27
+ "MigratableResource",
28
+ "SubscribeOptions"
29
+ ]
30
+ },
31
+ {
32
+ "name": "criteria",
33
+ "file": "skills/resource/parts/criteria.md",
34
+ "description": "The criteria language (equality, any-of arrays, operators, branches, nested keys), sort and where absent values land, and paging per backend.",
35
+ "purposes": [
36
+ "query",
37
+ "list"
38
+ ],
39
+ "mentions": [
40
+ "Criteria",
41
+ "FieldCriteria",
42
+ "FieldOperators",
43
+ "$or",
44
+ "$in",
45
+ "$ilike",
46
+ "$contains",
47
+ "$overlaps",
48
+ "Sort",
49
+ "ListOptions",
50
+ "ListResult",
51
+ "DEFAULT_PAGE_SIZE",
52
+ "page-without-size"
53
+ ]
54
+ },
55
+ {
56
+ "name": "memory-engine",
57
+ "file": "skills/resource/parts/memory-engine.md",
58
+ "description": "recordQueryHelper, the shared in-memory engine every store without a query engine of its own filters, sorts and pages through.",
59
+ "purposes": [
60
+ "test"
61
+ ],
62
+ "mentions": [
63
+ "recordQueryHelper",
64
+ "matchCriteria",
65
+ "filterRecords",
66
+ "sortRecords",
67
+ "firstMatch",
68
+ "applyQuery"
69
+ ]
70
+ },
71
+ {
72
+ "name": "migrations",
73
+ "file": "skills/resource/parts/migrations.md",
74
+ "description": "The migration framework — automatic on init — and the five steps of adding migration support to a new database backend.",
75
+ "purposes": [
76
+ "migration"
77
+ ],
78
+ "mentions": [
79
+ "MigratableResource",
80
+ "MigrationStore",
81
+ "createMigrationRegistry",
82
+ "runMigrations",
83
+ "MigrationStage",
84
+ "mongoDeclarationHelper",
85
+ "pgDeclarationHelper",
86
+ "_owlmeans_migrations"
87
+ ]
88
+ }
89
+ ]
14
90
  },
15
91
  {
16
92
  "kind": "skill",