@owlmeans/resource 0.1.18-rc.3 → 0.1.18-rc.31

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 CHANGED
@@ -1,120 +1,399 @@
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`, `save`, `create`, `update`, `delete`, `pick`
8
- - `ResourceRecord` base type (`{ id?: string }`) that all stored records extend
9
- - `ResourceMaker<R, T>` factory signature used by `makeMongoResource`, `createStateResource`, etc.
10
- - `ListResult<T>`, `ListOptions`, `ListPager` for paginated queries
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
- Define a resource maker following the standard factory pattern:
36
+ ### Declare a record type and a resource maker
22
37
 
23
- ```typescript
24
- 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'
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
- interface ProjectRecord extends ResourceRecord {
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 const makeProjectResource: ResourceMaker<ProjectRecord> = (dbAlias, serviceAlias) => {
34
- return makeMongoResource<ProjectRecord>('project', dbAlias, serviceAlias, makeProjectResource)
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
- Use resource methods in a handler:
75
+ ### CRUD in a handler
39
76
 
40
- ```typescript
41
- import { UnknownRecordError } from '@owlmeans/resource'
42
- 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 `requireEntityKey(req)`
79
+ (`@owlmeans/auth-common`), never from the token or the body.
43
80
 
44
- // Get or throw
45
- const project = await ctx.project().get(projectId)
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
- // Load (returns null if not found)
48
- const story = await ctx.story().load(storyId)
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
- // List with criteria and pagination
51
- const result: ListResult<ProjectRecord> = await ctx.project().list({
52
- criteria: { entityId: req.auth!.entityId },
53
- pager: { page: 0, size: 20 }
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
- ## API
128
+ ### Criteria, sorting and paging
58
129
 
59
- ### `Resource<T>` methods
130
+ ```ts
131
+ import type { Criteria, ListQuery, ListResult } from '@owlmeans/resource'
60
132
 
61
- - `get(id, field?, opts?)` — fetch by ID; throws `UnknownRecordError` if missing
62
- - `load(id, field?, opts?)` — fetch by ID; returns `null` if missing
63
- - `list(criteria?, opts?)` — paginated list; returns `ListResult<T>`
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
- ### `ResourceMaker<R, T>`
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
- ```typescript
73
- interface ResourceMaker<R extends ResourceRecord, T extends Resource<R> = Resource<R>> {
74
- (dbAlias?: string, serviceAlias?: string): T
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
- ### Migration framework
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
- Storage-agnostic and optional — a backend that supports code migrations (mongo, postgres)
81
- extends `MigratableResource<Tx>`; backends with nothing to migrate simply don't (the same way
82
- pub/sub is an optional capability of redis resources). Migrations run **automatically during
83
- resource initialization** (app setup) — registering one is all an app ever does.
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
- - `MigratableResource<Tx>` — `migration(name, apply, stage?): this` + `migrations()`
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
- ### Helpers
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
- - `prepareListOptions(defPageSize, criteria?, opts?)` — normalize list criteria + pager
101
- - `filterObject(obj, keep?)` — strip null/undefined fields from a record before saving
102
- - `createDbService(alias, override, init?)` — base plumbing for `ResourceDbService` implementations
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
- ### Error Classes
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
- - `ResourceError` — base resource error
107
- - `UnknownRecordError` — record not found (has `.id` getter)
108
- - `RecordExists` — duplicate record on `create`
109
- - `MisshapedRecord` — invalid record structure
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
- ## Related Packages
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
- - [`@owlmeans/mongo-resource`](../mongo-resource) — MongoDB implementation
116
- - [`@owlmeans/redis-resource`](../redis-resource) — Redis implementation
117
- - [`@owlmeans/state`](../state) — in-memory implementation for client state
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.33
128
407
  ```
129
408
 
130
409
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/resource",
4
- "version": "0.1.18-rc.0",
5
- "generatedAt": "2026-08-16T22:20:50.500Z",
4
+ "version": "0.1.18-rc.31",
5
+ "generatedAt": "2026-09-22T17:39:02.508Z",
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
  }