@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
package/README.md
CHANGED
|
@@ -1,199 +1,400 @@
|
|
|
1
1
|
# @owlmeans/resource
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
36
|
+
### Declare a record type and a resource maker
|
|
26
37
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
75
|
+
### CRUD in a handler
|
|
43
76
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
const
|
|
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
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
128
|
+
### Criteria, sorting and paging
|
|
63
129
|
|
|
64
|
-
|
|
130
|
+
```ts
|
|
131
|
+
import type { Criteria, ListQuery, ListResult } from '@owlmeans/resource'
|
|
65
132
|
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
+
return result // { items, total, page, size } — total counts every match, not the window
|
|
152
|
+
}
|
|
84
153
|
|
|
85
|
-
|
|
86
|
-
|
|
154
|
+
// Counting without carrying the records back
|
|
155
|
+
const inProgress = await ctx.resource<Resource<StoryRecord>>('story').count({ projectId, status: { $ne: 'done' } })
|
|
87
156
|
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
161
|
+
A shared `ListResult` schema for an entrypoint response comes from `createListSchema`:
|
|
104
162
|
|
|
105
|
-
|
|
106
|
-
|
|
163
|
+
```ts
|
|
164
|
+
import { createListSchema } from '@owlmeans/resource'
|
|
107
165
|
|
|
108
|
-
|
|
166
|
+
export const ProjectListSchema = createListSchema(ProjectSchema) // JSONSchemaType<ListResult<ProjectRecord>>
|
|
167
|
+
```
|
|
109
168
|
|
|
110
|
-
|
|
169
|
+
### The same criteria in memory
|
|
111
170
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
175
|
+
```ts
|
|
176
|
+
import { useStoreList } from '@owlmeans/client'
|
|
177
|
+
import { recordQueryHelper } from '@owlmeans/resource'
|
|
178
|
+
import type { Criteria } from '@owlmeans/resource'
|
|
119
179
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
221
|
+
export class StoryResourceError extends ResourceError {
|
|
222
|
+
public static override typeName = `MyAppStory${ResourceError.typeName}`
|
|
129
223
|
|
|
130
|
-
|
|
131
|
-
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
230
|
+
ResilientError.registerErrorClass(StoryResourceError)
|
|
231
|
+
```
|
|
138
232
|
|
|
139
|
-
|
|
140
|
-
can application code that has already loaded its records:
|
|
233
|
+
## API
|
|
141
234
|
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
253
|
+
`ttl` is seconds from now or the instant to expire at; backends without expiry refuse it.
|
|
153
254
|
|
|
154
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
###
|
|
272
|
+
### `Sort<T>`
|
|
179
273
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
278
|
+
### `ListResult<T>` and paging
|
|
191
279
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"schemaVersion":
|
|
2
|
+
"schemaVersion": 3,
|
|
3
3
|
"package": "@owlmeans/resource",
|
|
4
|
-
"version": "0.1.18
|
|
5
|
-
"generatedAt": "2026-
|
|
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",
|