@owlmeans/resource 0.1.18-rc.3 → 0.1.18-rc.30
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 +355 -76
- package/agent-meta/manifest.json +9 -2
- package/agent-meta/skills/resource/SKILL.md +143 -11
- package/agent-meta/skills/resource-choice/SKILL.md +271 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +4 -27
- package/build/consts.js.map +1 -1
- package/build/helper.d.ts +0 -2
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +0 -24
- package/build/helper.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/types.d.ts +125 -44
- package/build/types.d.ts.map +1 -1
- package/build/utils/index.d.ts +1 -0
- package/build/utils/index.d.ts.map +1 -1
- package/build/utils/index.js +1 -0
- package/build/utils/index.js.map +1 -1
- package/build/utils/name.d.ts +1 -1
- package/build/utils/name.d.ts.map +1 -1
- package/build/utils/name.js +1 -19
- package/build/utils/name.js.map +1 -1
- package/build/utils/query.d.ts +29 -0
- package/build/utils/query.d.ts.map +1 -0
- package/build/utils/query.js +270 -0
- package/build/utils/query.js.map +1 -0
- package/package.json +3 -5
- package/src/consts.ts +4 -27
- package/src/helper.ts +0 -28
- package/src/index.ts +1 -0
- package/src/types.ts +125 -46
- package/src/utils/index.ts +1 -0
- package/src/utils/name.ts +3 -25
- package/src/utils/query.ts +277 -0
- package/build/.gitkeep +0 -0
package/README.md
CHANGED
|
@@ -1,120 +1,399 @@
|
|
|
1
1
|
# @owlmeans/resource
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
- `
|
|
10
|
-
|
|
11
|
-
- 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.
|
|
12
11
|
|
|
13
12
|
## Installation
|
|
14
13
|
|
|
15
14
|
```bash
|
|
16
|
-
bun add @owlmeans/resource
|
|
15
|
+
bun add @owlmeans/resource@^0.1.18-rc.30
|
|
17
16
|
```
|
|
18
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
|
+
|
|
19
34
|
## Usage
|
|
20
35
|
|
|
21
|
-
|
|
36
|
+
### Declare a record type and a resource maker
|
|
22
37
|
|
|
23
|
-
|
|
24
|
-
|
|
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'
|
|
25
43
|
import { makeMongoResource } from '@owlmeans/mongo-resource'
|
|
44
|
+
import type { MongoResource } from '@owlmeans/mongo-resource'
|
|
45
|
+
import type { ResourceMaker, ResourceRecord } from '@owlmeans/resource'
|
|
26
46
|
|
|
27
|
-
|
|
47
|
+
export const RES_PROJECT = 'project'
|
|
48
|
+
|
|
49
|
+
export interface ProjectRecord extends ResourceRecord {
|
|
28
50
|
entityId: string
|
|
29
51
|
alias: string
|
|
52
|
+
status: 'active' | 'paused' | 'archived'
|
|
53
|
+
tags: string[]
|
|
30
54
|
createdAt: Date
|
|
31
55
|
}
|
|
32
56
|
|
|
33
|
-
export
|
|
34
|
-
|
|
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
|
|
35
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)
|
|
36
73
|
```
|
|
37
74
|
|
|
38
|
-
|
|
75
|
+
### CRUD in a handler
|
|
39
76
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 `requireEntityKey(req)`
|
|
79
|
+
(`@owlmeans/auth-common`), never from the token or the body.
|
|
43
80
|
|
|
44
|
-
|
|
45
|
-
|
|
81
|
+
```ts
|
|
82
|
+
import { requireEntityKey } 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'
|
|
46
86
|
|
|
47
|
-
|
|
48
|
-
const
|
|
87
|
+
export const handleCreateProject = async (req: AbstractRequest, ctx: Context) => {
|
|
88
|
+
const entityId = requireEntityKey(req)
|
|
89
|
+
const { alias } = req.body as { alias?: string }
|
|
90
|
+
if (alias == null || alias.trim() === '') {
|
|
91
|
+
throw new MisshapedRecord('alias-required')
|
|
92
|
+
}
|
|
49
93
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 = requireEntityKey(req)
|
|
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 = requireEntityKey(req)
|
|
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
|
+
}
|
|
55
126
|
```
|
|
56
127
|
|
|
57
|
-
|
|
128
|
+
### Criteria, sorting and paging
|
|
58
129
|
|
|
59
|
-
|
|
130
|
+
```ts
|
|
131
|
+
import type { Criteria, ListQuery, ListResult } from '@owlmeans/resource'
|
|
60
132
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
- `create(record, opts?)` — insert; throws `RecordExists` if already exists
|
|
65
|
-
- `save(record, opts?)` — upsert by ID
|
|
66
|
-
- `update(record, opts?)` — update; throws `UnknownRecordError` if missing
|
|
67
|
-
- `delete(id, opts?)` — remove; returns the deleted record or `null`
|
|
68
|
-
- `pick(id, opts?)` — **delete and return** the record; throws `UnknownRecordError` if missing. Never use it for read-only checks
|
|
133
|
+
export const handleListProjects = async (req: AbstractRequest, ctx: Context) => {
|
|
134
|
+
const entityId = requireEntityKey(req)
|
|
135
|
+
const { where, sort, page, size } = req.query as ListQuery<ProjectRecord>
|
|
69
136
|
|
|
70
|
-
|
|
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
|
+
}
|
|
71
144
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
})
|
|
150
|
+
|
|
151
|
+
return result // { items, total, page, size } — total counts every match, not the window
|
|
75
152
|
}
|
|
153
|
+
|
|
154
|
+
// Counting without carrying the records back
|
|
155
|
+
const inProgress = await ctx.resource<Resource<StoryRecord>>('story').count({ projectId, status: { $ne: 'done' } })
|
|
156
|
+
|
|
157
|
+
// The newest match, chosen by sort
|
|
158
|
+
const latest = await projectResource(ctx).load({ entityId }, { sort: [{ field: 'createdAt', order: 'desc' }] })
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
A shared `ListResult` schema for an entrypoint response comes from `createListSchema`:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { createListSchema } from '@owlmeans/resource'
|
|
165
|
+
|
|
166
|
+
export const ProjectListSchema = createListSchema(ProjectSchema) // JSONSchemaType<ListResult<ProjectRecord>>
|
|
76
167
|
```
|
|
77
168
|
|
|
78
|
-
###
|
|
169
|
+
### The same criteria in memory
|
|
170
|
+
|
|
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>`.
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { useStoreList } from '@owlmeans/client'
|
|
177
|
+
import { applyQuery, filterRecords, firstMatch, matchCriteria } from '@owlmeans/resource'
|
|
178
|
+
import type { Criteria } from '@owlmeans/resource'
|
|
79
179
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
180
|
+
const open: Criteria<StoryRecord> = { status: ['open', 'review'], archivedAt: null, assignee: undefined }
|
|
181
|
+
|
|
182
|
+
filterRecords(stories, open) // every match, insertion order
|
|
183
|
+
firstMatch(stories, open, { sort: [{ field: 'updatedAt', order: 'desc' }] })
|
|
184
|
+
applyQuery(stories, open, { sort: ['title'], page: 1, size: 10 }) // a full ListResult
|
|
185
|
+
stories.some(story => 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 })
|
|
190
|
+
```
|
|
84
191
|
|
|
85
|
-
|
|
86
|
-
- `createMigrationRegistry<Tx>()` — per-alias ledger of registered migrations; declaration
|
|
87
|
-
order is application order; identical re-registration is a no-op, a changed body under a
|
|
88
|
-
used name throws `MigrationConflict`
|
|
89
|
-
- `MigrationStore<Tx>` — the *migration register* a database implements to track applied
|
|
90
|
-
migrations (`ensure` / `applied` / `baseline` / `run`); both mongo and postgres persist it
|
|
91
|
-
as `_owlmeans_migrations`
|
|
92
|
-
- `runMigrations(alias, registry, store, opts?)` — the storage-agnostic runner; with
|
|
93
|
-
`baseline: true` migrations are recorded instead of run (used on just-created structures);
|
|
94
|
-
`strictChecksum` (default) rejects edited applied bodies
|
|
95
|
-
- `MigrationStage.Pre` / `MigrationStage.Post` — run before / after the backend's structure
|
|
96
|
-
reconciliation
|
|
192
|
+
### Migrations and custom errors
|
|
97
193
|
|
|
98
|
-
|
|
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.
|
|
99
197
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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'
|
|
103
203
|
|
|
104
|
-
|
|
204
|
+
export const makeStoryResource: ResourceMaker<StoryRecord, StoryResource> = (dbAlias, serviceAlias) => {
|
|
205
|
+
const resource = makePostgresResource<StoryRecord, StoryResource>('story', dbAlias, serviceAlias)
|
|
206
|
+
resource.schema = StorySchema
|
|
105
207
|
|
|
106
|
-
|
|
107
|
-
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
- `MigrationError` — a migration body threw; initialization aborts
|
|
111
|
-
- `MigrationConflict` — an already applied migration's body changed, or a name was redeclared with a different body
|
|
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)
|
|
112
212
|
|
|
113
|
-
|
|
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
|
+
}
|
|
220
|
+
|
|
221
|
+
export class StoryResourceError extends ResourceError {
|
|
222
|
+
public static override typeName = `MyAppStory${ResourceError.typeName}`
|
|
223
|
+
|
|
224
|
+
constructor(message: string = 'error') {
|
|
225
|
+
super(`my-app-story:${message}`)
|
|
226
|
+
this.type = StoryResourceError.typeName
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
ResilientError.registerErrorClass(StoryResourceError)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## API
|
|
234
|
+
|
|
235
|
+
### `Resource<T>` methods
|
|
114
236
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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.
|
|
239
|
+
|
|
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 |
|
|
252
|
+
|
|
253
|
+
`ttl` is seconds from now or the instant to expire at; backends without expiry refuse it.
|
|
254
|
+
|
|
255
|
+
### `Criteria<T>`
|
|
256
|
+
|
|
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.
|
|
259
|
+
|
|
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 |
|
|
267
|
+
|
|
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`.
|
|
271
|
+
|
|
272
|
+
### `Sort<T>`
|
|
273
|
+
|
|
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.
|
|
277
|
+
|
|
278
|
+
### `ListResult<T>` and paging
|
|
279
|
+
|
|
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
|
+
| `matchCriteria(record, where?)` | function | does one record satisfy the criteria |
|
|
324
|
+
| `filterRecords(records, where?)` | function | every match, in insertion order |
|
|
325
|
+
| `sortRecords(records, sort?)` | function | a sorted copy |
|
|
326
|
+
| `firstMatch(records, where?, { sort }?)` | function | the record `load(where)` returns |
|
|
327
|
+
| `applyQuery(records, where?, opts?)` | function | filter, sort and page into a `ListResult` |
|
|
328
|
+
| `createListSchema(schema)` | function | AJV `JSONSchemaType<ListResult<T>>` for a record schema |
|
|
329
|
+
| `filterObject(obj, keep?)` | function | drop null/undefined properties, keeping names in `keep` |
|
|
330
|
+
| `createDbService(alias, override, init?)` | function | base for `ResourceDbService` implementations; `name()` resolves `config.schema ?? config.alias ?? service.alias` |
|
|
331
|
+
| `createMigrationRegistry<Tx>()` | function | per-alias migration ledger; identical re-registration is a no-op |
|
|
332
|
+
| `runMigrations(alias, registry, store, opts?)` | function | apply one stage's pending migrations → `MigrationReport` |
|
|
333
|
+
| `MigrationStage` | enum | `Pre` (before structure reconciliation) / `Post` (after) |
|
|
334
|
+
|
|
335
|
+
### Errors
|
|
336
|
+
|
|
337
|
+
All extend `ResourceError` (itself a `ResilientError`) and are registered for marshalling.
|
|
338
|
+
|
|
339
|
+
| Symbol | Thrown when |
|
|
340
|
+
|---|---|
|
|
341
|
+
| `ResourceError` | base resource error |
|
|
342
|
+
| `UnknownRecordError` | a record is not found; `.id` returns the id |
|
|
343
|
+
| `RecordExists` | `create` hits a duplicate |
|
|
344
|
+
| `RecordUpdateFailed` | a write reached the backend but changed nothing |
|
|
345
|
+
| `MisshapedRecord` | a record has an invalid structure |
|
|
346
|
+
| `UnsupportedArgumentError` | an argument this backend cannot honour, e.g. `page-without-size` |
|
|
347
|
+
| `UnsupportedMethodError` | a method this backend does not implement |
|
|
348
|
+
| `MigrationError` | a migration body threw; initialization aborts |
|
|
349
|
+
| `MigrationConflict` | an applied migration's body changed, or a name was redeclared with a different body |
|
|
350
|
+
|
|
351
|
+
## Migration framework
|
|
352
|
+
|
|
353
|
+
Storage-agnostic and optional — a backend that supports code migrations (mongo, postgres) extends
|
|
354
|
+
`MigratableResource<Tx, Self>`; backends with nothing to migrate simply don't.
|
|
355
|
+
|
|
356
|
+
- Declaration order is application order; the registry never sorts by name.
|
|
357
|
+
- Both mongo and postgres persist the ledger as `_owlmeans_migrations`.
|
|
358
|
+
- On a just-created structure `runMigrations(..., { baseline: true })` records every migration
|
|
359
|
+
instead of running it; otherwise a backend runs `Pre` → reconciles structure → runs `Post`.
|
|
360
|
+
- `strictChecksum` (default on) rejects an edited body of an already applied migration.
|
|
361
|
+
|
|
362
|
+
Adding migration support to a new backend: extend its interface with `MigratableResource<YourTx>`,
|
|
363
|
+
keep registrations in a module-scope declaration keyed by alias, implement `MigrationStore` over a
|
|
364
|
+
durable ledger, and make `run` atomic with the ledger write where the database allows it.
|
|
365
|
+
|
|
366
|
+
## Common pitfalls
|
|
367
|
+
|
|
368
|
+
- `take(id)` **deletes** the record it returns — never use it to fetch something.
|
|
369
|
+
- Writes have no `(id, changes)` overload: pass the record with its id inside. `update` replaces the
|
|
370
|
+
whole record, so spread the loaded one first.
|
|
371
|
+
- Do not fetch one record with `list(...).items[0]` — `get(where)` / `load(where, { sort })` do it in
|
|
372
|
+
one call.
|
|
373
|
+
- A bare array in criteria means "any of these"; an `undefined` value is skipped, not matched.
|
|
374
|
+
- `list()` without `size` returns 100 records on mongo/postgres but everything elsewhere; pass
|
|
375
|
+
`size: 0` when you really want the whole set.
|
|
376
|
+
- `purge({})` is refused — bulk delete always names its criteria.
|
|
377
|
+
- Methods return records, never driver results — there is no `rowsAffected` to read.
|
|
378
|
+
- Sort on a required field when the position of absent values matters; it differs between stores.
|
|
379
|
+
- Never edit an applied migration — add a new one. Write its body inline in the callback: the
|
|
380
|
+
checksum hashes the callback's source, so a wrapper delegating to a captured function never
|
|
381
|
+
detects an edit.
|
|
382
|
+
- Install `@noble/hashes` and `@scure/base` alongside this package when you load
|
|
383
|
+
`createMigrationRegistry` or `runMigrations`; install peer `ajv` when you use `createListSchema`.
|
|
384
|
+
- Scope organization data by `entityId` from `requireEntityKey(req)`, never by an id taken from the
|
|
385
|
+
wire.
|
|
386
|
+
|
|
387
|
+
## Related packages
|
|
388
|
+
|
|
389
|
+
- [`@owlmeans/mongo-resource`](../mongo-resource) — MongoDB implementation, migratable
|
|
390
|
+
- [`@owlmeans/postgres-resource`](../postgres-resource) — PostgreSQL implementation, migratable
|
|
391
|
+
- [`@owlmeans/redis-resource`](../redis-resource) — Redis implementation with pub/sub, watch and streams
|
|
392
|
+
- [`@owlmeans/static-resource`](../static-resource) — in-memory implementation over config records
|
|
393
|
+
- [`@owlmeans/state`](../state) — the client store, with live subscriptions
|
|
394
|
+
- [`@owlmeans/client-resource`](../client-resource) — client-side caching over resources
|
|
395
|
+
- [`@owlmeans/context`](../context) — `registerResource` / `resource(alias)` and the lifecycle
|
|
396
|
+
- [`@owlmeans/error`](../error) — `ResilientError`, the base of every resource error
|
|
118
397
|
|
|
119
398
|
<!-- owlmeans:agent-guidance:start -->
|
|
120
399
|
## Agent guidance
|
|
@@ -124,7 +403,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
124
403
|
your project's skill store (`.agents/skills/`):
|
|
125
404
|
|
|
126
405
|
```sh
|
|
127
|
-
npx @owlmeans/agent-skills
|
|
406
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.32
|
|
128
407
|
```
|
|
129
408
|
|
|
130
409
|
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
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/resource",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-
|
|
4
|
+
"version": "0.1.18-rc.30",
|
|
5
|
+
"generatedAt": "2026-09-22T12:36:21.806Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
"category": "package-specific",
|
|
12
12
|
"file": "skills/resource/SKILL.md",
|
|
13
13
|
"canonicalPath": ".agents/skills/resource/SKILL.md"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"kind": "skill",
|
|
17
|
+
"name": "resource-choice",
|
|
18
|
+
"category": "multi-package",
|
|
19
|
+
"file": "skills/resource-choice/SKILL.md",
|
|
20
|
+
"canonicalPath": ".agents/skills/resource-choice/SKILL.md"
|
|
14
21
|
}
|
|
15
22
|
]
|
|
16
23
|
}
|