@owlmeans/resource 0.1.18-rc.7 → 0.1.18-rc.9

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
@@ -4,16 +4,20 @@ Abstract CRUD interface and types for all data persistence in OwlMeans applicati
4
4
 
5
5
  ## Overview
6
6
 
7
- - `Resource<T>` interface: `get`, `load`, `list`, `save`, `create`, `update`, `delete`, `pick`
7
+ - `Resource<T>` interface: `get`, `load`, `list`, `count`, `create`, `update`, `save`, `delete`,
8
+ `take`, `purge`
8
9
  - `ResourceRecord` base type (`{ id?: string }`) that all stored records extend
9
10
  - `ResourceMaker<R, T>` factory signature used by `makeMongoResource`, `createStateResource`, etc.
10
- - `ListResult<T>`, `ListOptions`, `ListPager` for paginated queries
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`
11
15
  - Error classes: `ResourceError`, `UnknownRecordError`, `RecordExists`
12
16
 
13
17
  ## Installation
14
18
 
15
19
  ```bash
16
- bun add @owlmeans/resource
20
+ bun add @owlmeans/resource@^0.1.18-rc.8
17
21
  ```
18
22
 
19
23
  ## Usage
@@ -31,7 +35,7 @@ interface ProjectRecord extends ResourceRecord {
31
35
  }
32
36
 
33
37
  export const makeProjectResource: ResourceMaker<ProjectRecord> = (dbAlias, serviceAlias) => {
34
- return makeMongoResource<ProjectRecord>('project', dbAlias, serviceAlias, makeProjectResource)
38
+ return makeMongoResource<ProjectRecord>('project', dbAlias, serviceAlias, 'projects')
35
39
  }
36
40
  ```
37
41
 
@@ -41,31 +45,75 @@ Use resource methods in a handler:
41
45
  import { UnknownRecordError } from '@owlmeans/resource'
42
46
  import type { ListResult } from '@owlmeans/resource'
43
47
 
44
- // Get or throw
48
+ // Get or throw — by id, or by whatever set of fields identifies the record
45
49
  const project = await ctx.project().get(projectId)
50
+ const byName = await ctx.project().get({ entityId, alias: 'main' })
46
51
 
47
52
  // Load (returns null if not found)
48
53
  const story = await ctx.story().load(storyId)
49
54
 
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
- })
55
+ // List with criteria, sorting and paging
56
+ const result: ListResult<ProjectRecord> = await ctx.project().list(
57
+ { entityId, alias: ['main', 'staging'] },
58
+ { sort: [{ field: 'createdAt', order: 'desc' }], page: 0, size: 20 }
59
+ )
55
60
  ```
56
61
 
57
62
  ## API
58
63
 
59
64
  ### `Resource<T>` methods
60
65
 
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
66
+ Every read takes either an id or a criteria object, so fetching one record by several fields is a
67
+ single call rather than a list whose first element is taken.
68
+
69
+ - `get(id)` / `get(where, { sort })` — fetch one; throws `UnknownRecordError` if missing
70
+ - `load(id)` / `load(where, { sort })` — fetch one; returns `null` if missing
71
+ - `list(where?, { sort, page, size })` — returns `ListResult<T>`
72
+ - `count(where?)` — how many records the criteria match
73
+ - `create(record, { ttl })` — insert; throws `RecordExists` if already exists
74
+ - `update(record, { ttl })` — replaces the whole record; throws `UnknownRecordError` if missing
75
+ - `save(record, { ttl })` — creates when the record carries no id, replaces otherwise
76
+ - `delete(id)` — remove; returns the deleted record or `null`
77
+ - `take(id)` — **delete and return** the record; throws `UnknownRecordError` if missing. Never use it
78
+ for read-only checks
79
+ - `purge(where)` — bulk delete; refuses an empty criteria object rather than emptying the resource
80
+
81
+ `ttl` is seconds from now or the instant to expire at; backends without expiry refuse it.
82
+
83
+ ### `Criteria<T>`
84
+
85
+ Keys are the record's own fields, so a typo is a compile error; a dotted key reaches into a nested
86
+ value or a jsonb column and stays open.
87
+
88
+ - A bare value is equality, a bare **array** is "any of these", `null` asks for the absence of a
89
+ value, and `undefined` is **skipped** — an untouched filter must never empty a list
90
+ - Operators: `$eq` `$ne` `$gt` `$gte` `$lt` `$lte` `$in` `$nin` `$exists` `$null` `$like` `$ilike`
91
+ `$regex` `$startsWith` `$endsWith` `$between` `$contains` `$contained` `$overlaps`
92
+ - Composition: `$and`, `$or`, `$not`
93
+
94
+ ```typescript
95
+ await ctx.project().list({
96
+ status: ['active', 'paused'],
97
+ archivedAt: null,
98
+ 'meta.tier': { $in: ['gold', 'silver'] },
99
+ $or: [{ ownerId }, { shared: true }]
100
+ })
101
+ ```
102
+
103
+ ### `Sort<T>`
104
+
105
+ A bare field name sorts ascending; `{ field, order: 'asc' | 'desc' }` states it explicitly. `sort`
106
+ takes a list, applied left to right.
107
+
108
+ ### `ListResult<T>` and paging
109
+
110
+ `list()` answers `{ items, total, page?, size? }`.
111
+
112
+ Paging is opt-in per call and each backend decides what an unasked-for page means. Mongo and
113
+ Postgres cap an unbounded read at **100** records when no `size` is given; Redis and the in-memory
114
+ stores (state, static, client and config resources) return everything. `size: 0` means **no limit**
115
+ everywhere. Asking for a `page` without a `size` on an unpaged backend throws
116
+ `UnsupportedArgumentError('page-without-size')`.
69
117
 
70
118
  ### `ResourceMaker<R, T>`
71
119
 
@@ -75,14 +123,40 @@ interface ResourceMaker<R extends ResourceRecord, T extends Resource<R> = Resour
75
123
  }
76
124
  ```
77
125
 
126
+ ### Optional capabilities
127
+
128
+ A backend composes in only what it can honour; the base `Resource<T>` contract stays small.
129
+
130
+ - `PubSubResource<T>` — `publish(value, channel?)`, `subscribe(handler, { channel?, once?, ttl? })`
131
+ - `WatchableResource<T>` — `watch(id, handler, opts?)`, following ONE record
132
+ - `StreamResource<T>` — `stream(key, value)` and `consume(key, { group?, consumer?, block? })`
133
+ - `LockableResource<T>` — `lock(record, fields?)` / `unlock(record, fields?)` for field level
134
+ encryption at rest
135
+ - `MigratableResource<Tx, Self>` — see below
136
+
137
+ ### In-memory query engine
138
+
139
+ The same criteria and sorting, evaluated over plain arrays. Every in-memory store uses these, and so
140
+ can application code that has already loaded its records:
141
+
142
+ - `matchCriteria(record, where)` — does one record satisfy the criteria
143
+ - `filterRecords(records, where)` / `sortRecords(records, sort)`
144
+ - `firstMatch(records, where, opts?)` — the `load(where)` semantics
145
+ - `applyQuery(records, query)` — filter, sort and page in one step, answering a `ListResult`
146
+
147
+ ### Database naming
148
+
149
+ `dbName()` answers `config.schema ?? config.alias ?? service.alias` — the database (or schema) a
150
+ resource reads and writes.
151
+
78
152
  ### Migration framework
79
153
 
80
154
  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
155
+ extends `MigratableResource<Tx, Self>`; backends with nothing to migrate simply don't (the same way
82
156
  pub/sub is an optional capability of redis resources). Migrations run **automatically during
83
157
  resource initialization** (app setup) — registering one is all an app ever does.
84
158
 
85
- - `MigratableResource<Tx>` — `migration(name, apply, stage?): this` + `migrations()`
159
+ - `MigratableResource<Tx, Self>` — `migration(name, apply, stage?): Self` + `migrations()`
86
160
  - `createMigrationRegistry<Tx>()` — per-alias ledger of registered migrations; declaration
87
161
  order is application order; identical re-registration is a no-op, a changed body under a
88
162
  used name throws `MigrationConflict`
@@ -97,8 +171,8 @@ resource initialization** (app setup) — registering one is all an app ever doe
97
171
 
98
172
  ### Helpers
99
173
 
100
- - `prepareListOptions(defPageSize, criteria?, opts?)` — normalize list criteria + pager
101
174
  - `filterObject(obj, keep?)` — strip null/undefined fields from a record before saving
175
+ - `createListSchema(schema)` — the AJV schema for a `ListResult` of the given record schema
102
176
  - `createDbService(alias, override, init?)` — base plumbing for `ResourceDbService` implementations
103
177
 
104
178
  ### Error Classes
@@ -106,15 +180,20 @@ resource initialization** (app setup) — registering one is all an app ever doe
106
180
  - `ResourceError` — base resource error
107
181
  - `UnknownRecordError` — record not found (has `.id` getter)
108
182
  - `RecordExists` — duplicate record on `create`
183
+ - `RecordUpdateFailed` — a write reached the backend but changed nothing
109
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
110
187
  - `MigrationError` — a migration body threw; initialization aborts
111
188
  - `MigrationConflict` — an already applied migration's body changed, or a name was redeclared with a different body
112
189
 
113
190
  ## Related Packages
114
191
 
115
192
  - [`@owlmeans/mongo-resource`](../mongo-resource) — MongoDB implementation
193
+ - [`@owlmeans/postgres-resource`](../postgres-resource) — PostgreSQL implementation
116
194
  - [`@owlmeans/redis-resource`](../redis-resource) — Redis implementation
117
- - [`@owlmeans/state`](../state) — in-memory implementation for client state
195
+ - [`@owlmeans/static-resource`](../static-resource) — in-memory implementation over config records
196
+ - [`@owlmeans/state`](../state) — the client store, with live subscriptions
118
197
 
119
198
  <!-- owlmeans:agent-guidance:start -->
120
199
  ## Agent guidance
@@ -124,7 +203,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
124
203
  your project's skill store (`.agents/skills/`):
125
204
 
126
205
  ```sh
127
- npx @owlmeans/agent-skills
206
+ npx @owlmeans/agent-skills@^0.1.18-rc.12
128
207
  ```
129
208
 
130
209
  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.7",
5
- "generatedAt": "2026-08-24T21:14:00.098Z",
4
+ "version": "0.1.18-rc.9",
5
+ "generatedAt": "2026-09-04T22:43:25.438Z",
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
  }
@@ -8,20 +8,28 @@ user-invocable: false
8
8
  # @owlmeans/resource
9
9
 
10
10
  **Layer:** Core
11
- **Install:** `"@owlmeans/resource": "^0.1.18-rc.7"` in `dependencies`
11
+ **Install:** `"@owlmeans/resource": "^0.1.18-rc.9"` in `dependencies`
12
12
 
13
13
  ## Key Exports
14
14
 
15
15
  | Export | Description |
16
16
  |--------|-------------|
17
- | `Resource<T>` | Generic resource contract: `get`/`load`/`list`/`save`/`create`/`update`/`delete`/`pick`. `list(criteria?, opts?)` paginates through `opts.pager: { page, size, sort }` — there is no `limit`. |
18
- | `MigratableResource<Tx>` | **Optional migration capability** — `migration(name, apply, stage?)` (chainable) + `migrations()`. Optional the way pub/sub is on redis resources: mongo and postgres extend it, backends with nothing to migrate don't. |
17
+ | `ResourceRecord` | `{ id?: string }` — the constraint every record type satisfies, and the only field the contract itself knows about. |
18
+ | `Resource<T>` | Generic resource contract: `get`/`load`/`list`/`count`/`create`/`update`/`save`/`delete`/`take`/`purge`. Reads take **an id or a criteria object**; `list(where?, opts?)` takes `{ page, size, sort }` flat in the second argument. |
19
+ | `Criteria<T>`, `FieldCriteria<V>`, `FieldOperators<V>` | The query language, typed by the record — a mistyped field is a compile error. |
20
+ | `Sort<T>`, `SortField<T>`, `FirstOptions<T>`, `ListOptions<T>`, `ListQuery<T>`, `ListResult<T>` | Ordering, the read options, the one-object form an API carries over the wire, and the answer shape `{ items, total, page?, size? }`. |
21
+ | `WriteOptions`, `Ttl` | `{ ttl }` on `create`/`update`/`save`; seconds from now, or the instant to expire at. Backends without expiry refuse it. |
22
+ | `matchCriteria`, `filterRecords`, `sortRecords`, `firstMatch`, `applyQuery` | The shared in-memory engine — one criteria object means the same thing in a browser store as it does in SQL. |
23
+ | `PubSubResource<T>`, `WatchableResource<T>`, `StreamResource<T>`, `LockableResource<T>` | **Optional capabilities**, composed into a concrete resource interface alongside `Resource<T>`. `SubscribeOptions` (`{ channel, once, ttl }`) and `Unsubscribe` come with the first two. |
24
+ | `MigratableResource<Tx, Self>` | **Optional migration capability** — `migration(name, apply, stage?)` (chainable) + `migrations()`. Optional the way pub/sub is on redis resources: mongo and postgres extend it, backends with nothing to migrate don't. |
19
25
  | `Migration`, `MigrationRegistry`, `MigrationStore`, `MigrationReport`, `MigrationRunOptions` | The framework's contracts. `MigrationStore` is the *migration register* a database implements to track applied migrations (`ensure`/`applied`/`baseline`/`run`). |
20
26
  | `createMigrationRegistry<Tx>()` | Storage-agnostic ledger of code-registered migrations per alias. Declaration order = application order; re-registering an identical body is a no-op, a changed body under a used name throws `MigrationConflict`. |
21
27
  | `runMigrations(alias, registry, store, opts?)` | Apply one stage's pending migrations through a store. `baseline: true` records instead of running (fresh structures); `strictChecksum` (default on) rejects edited applied bodies. |
22
28
  | `MigrationStage` | `Pre` (before structure reconciliation — renames, casts, rescues) / `Post` (after — backfills into new structure). |
23
- | `createDbService` | Base for `ResourceDbService` implementations (config/alias/name/client plumbing). |
24
- | `prepareListOptions`, `filterObject`, `createListSchema` | List/criteria helpers. |
29
+ | `createDbService` | Base for `ResourceDbService` implementations (config/alias/name/client plumbing). The service's `name(alias?)` resolves to `config.schema ?? config.alias ?? service.alias`. |
30
+ | `ResourceDbService<Db, Client>`, `DbLocker<T>` | What a connection service implements: `db`/`client`/`clients`/`config`/`name`/`ensureConfigAlias`/`initialize`, plus `lock`/`unlock` when the backend encrypts fields. |
31
+ | `ResourceMaker<R, T>` | `(dbAlias?, serviceAlias?) => T` — the signature every resource maker is typed with, so an app registers `makeXResource()` without repeating the argument list. |
32
+ | `filterObject(obj, keep?)`, `createListSchema` | Drop null and undefined properties, keeping the names listed in `keep`; the AJV schema for a `ListResult<T>` envelope. |
25
33
  | Errors | `ResourceError`, `UnknownRecordError`, `MisshapedRecord`, `RecordExists`, `RecordUpdateFailed`, `UnsupportedArgumentError`, `UnsupportedMethodError`, `MigrationError`, `MigrationConflict`. |
26
34
  | `DbConfig`, `Config`, `Context` | Database config (`cfg.dbs`) types. |
27
35
 
@@ -35,17 +43,18 @@ A resource is a typed CRUD-over-records abstraction. Concrete implementations co
35
43
  ```typescript
36
44
  import type { Resource } from '@owlmeans/resource'
37
45
 
38
- interface Project { id: string; name: string; entityId: string }
46
+ interface Project { id: string; name: string; entityId: string; createdAt: Date }
39
47
  const projects = ctx.resource<Resource<Project>>('projects')
40
- const { items } = await projects.list({ entityId: 'abc' })
41
-
42
- // Pagination and sort ride under `pager` in the SECOND argument — there is NO `limit`,
43
- // and neither `page` nor `size` exists at the top level of either argument:
44
- const page2 = await projects.list(
45
- { entityId: 'abc' },
46
- { pager: { page: 1, size: 20, sort: ['createdAt'] } }
47
- )
48
- // page2.pager echoes { page, size, total } for building pagination UI.
48
+
49
+ // A read is addressed by an id OR by criteria — fetching one record by several fields is a
50
+ // single call, never a list whose first element is taken.
51
+ const project = await projects.get('abc123')
52
+ const newest = await projects.load({ entityId: 'abc' }, { sort: [{ field: 'createdAt', order: 'desc' }] })
53
+
54
+ // Paging and sort are FLAT in the second argument.
55
+ const page2 = await projects.list({ entityId: 'abc' }, { page: 1, size: 20, sort: ['createdAt'] })
56
+ page2.items // the window
57
+ page2.total // every record the criteria match, not just this page
49
58
  ```
50
59
 
51
60
  ### Calling the CRUD surface
@@ -57,23 +66,105 @@ A **write** takes the record as its only data argument, and the id travels insid
57
66
  | Call | Signature | Notes |
58
67
  |---|---|---|
59
68
  | `create(record, opts?)` | `Partial<T> → T` | throws `RecordExists`; implementations may refuse a caller-supplied id |
60
- | `save(record, opts?)` | `Partial<T> → T` | create-or-replace |
69
+ | `save(record, opts?)` | `Partial<T> → T` | creates when the record carries no id, replaces otherwise |
61
70
  | `update(record, opts?)` | `Partial<T> → T` | replaces the whole record; throws `UnknownRecordError` |
62
71
 
63
- A **read** takes the id first, plus an optional field to look it up by (`opts` is that field name,
64
- or `{ field, ttl }`):
72
+ A **read** is addressed either by an id or by a criteria object. The id overload takes nothing
73
+ else; the criteria overload takes `{ sort }` to say which match is "the" one:
65
74
 
66
75
  | Call | Signature | Notes |
67
76
  |---|---|---|
68
- | `load(id, field?, opts?)` | `string → T \| null` | the miss-tolerant read |
69
- | `get(id, field?, opts?)` | `string → T` | throws `UnknownRecordError` instead of returning `null` |
70
- | `list(criteria?, opts?)` | `→ { items, pager }` | |
71
- | `delete(id \| record, opts?)` | `→ T \| null` | returns what it removed |
72
- | `pick(id \| record, opts?)` | `→ T` | **also deletes** — it is `delete` that throws on a miss, never a read |
77
+ | `load(id)` / `load(where, { sort })` | `→ T \| null` | the miss-tolerant read |
78
+ | `get(id)` / `get(where, { sort })` | `→ T` | throws `UnknownRecordError` instead of returning `null` |
79
+ | `list(where?, { page, size, sort })` | `→ { items, total, page?, size? }` | `total` counts every match, not the window |
80
+ | `count(where?)` | `→ number` | the same question without carrying the records back |
81
+ | `delete(id)` | `→ T \| null` | returns what it removed |
82
+ | `take(id)` | `→ T` | **deletes the record it returns.** It is a `delete` that throws `UnknownRecordError` on a miss, never a read — never reach for it to fetch something |
83
+ | `purge(where)` | `→ number` | bulk delete; refuses an empty criteria object rather than emptying the resource |
73
84
 
74
85
  Every method returns the record(s) themselves, never a driver result object — there is no
75
86
  `rowsAffected`/`rowCount` anywhere on this contract.
76
87
 
88
+ ### The criteria language
89
+
90
+ `Criteria<T>` is keyed by the record's own fields, so a typo is a compile error. A dotted key
91
+ reaches into a nested value (or a jsonb column) and stays open.
92
+
93
+ | Written as | Means |
94
+ |---|---|
95
+ | `{ status: 'open' }` | equality |
96
+ | `{ status: ['open', 'done'] }` | **any of these** — a bare array is never exact-array equality (that is `{ $eq: [...] }`) |
97
+ | `{ archivedAt: null }` | the absence of a value |
98
+ | `{ status: undefined }` | **skipped** — an untouched filter must never empty a list |
99
+ | `{ 'profile.city': 'Kraków' }` | reach into a nested value or a jsonb column |
100
+
101
+ Operators go in an object under the field: `$eq $ne $gt $gte $lt $lte $in $nin $exists $null
102
+ $like $ilike $regex $startsWith $endsWith $between $contains $contained $overlaps`. Branches
103
+ combine with `$and` / `$or` / `$not`, each taking criteria of the same shape.
104
+
105
+ ```typescript
106
+ await projects.list({
107
+ entityId: 'abc',
108
+ createdAt: { $gte: since },
109
+ $or: [{ name: { $ilike: 'owl%' } }, { tags: { $overlaps: ['pinned'] } }]
110
+ })
111
+ ```
112
+
113
+ Every backend answers the same object the same way — that is the point of the vocabulary. An
114
+ operator a store cannot express raises `UnsupportedArgumentError` rather than being quietly
115
+ dropped.
116
+
117
+ `Sort<T>` is a bare field name (ascending) or `{ field, order: 'asc' | 'desc' }`, and `sort` takes
118
+ a list of them. Where an **absent** value lands is the store's own rule, not the vocabulary's: the
119
+ in-memory sorter puts it last ascending, Postgres emits a bare `ASC` and so leaves it last (the
120
+ server's `NULLS LAST` default), and Mongo hands the sort document to the driver untouched, so BSON
121
+ ordering puts null and missing first. Sort on a field the schema requires when the position of an
122
+ absent value matters.
123
+
124
+ ### Paging is a property of the backend
125
+
126
+ `ListResult<T>` is `{ items, total, page?, size? }` — `total` always counts every match. Whether a
127
+ missing `size` means "everything" depends on what the store can afford:
128
+
129
+ | Backend | `list(where)` with no `size` |
130
+ |---|---|
131
+ | mongo, postgres | a page of `DEFAULT_PAGE_SIZE` (100) — an unbounded read of a growing table is an incident waiting to happen |
132
+ | redis, static, client-resource, config, state | every match — answering the criteria already read the whole namespace, so an implied window would only hide records without saving work |
133
+
134
+ `size: 0` means **no limit** on every backend — the explicit, greppable way to ask for a whole
135
+ result set. `page` without `size` against an unpaged backend throws
136
+ `UnsupportedArgumentError('page-without-size')`: there is no implied default to take a window of.
137
+
138
+ ### The shared in-memory engine
139
+
140
+ Every store without a query engine of its own — redis after its SCAN, the client and static
141
+ stores, state — filters through the same helpers, so a criteria object written for an endpoint
142
+ selects the same records when a screen applies it locally:
143
+
144
+ | Helper | Answers |
145
+ |---|---|
146
+ | `matchCriteria(record, where)` | does this one record match |
147
+ | `filterRecords(records, where)` | every match, in insertion order |
148
+ | `sortRecords(records, sort)` | a sorted copy |
149
+ | `firstMatch(records, where, { sort })` | the record `load(where)` / `get(where)` return |
150
+ | `applyQuery(records, where, opts)` | the whole `ListResult` — filter, sort and page in one call |
151
+
152
+ Reach for them when implementing a backend, and when narrowing a list already in hand rather than
153
+ going back to the store.
154
+
155
+ ### Optional capabilities
156
+
157
+ A concrete resource interface composes the capabilities its backend actually has beside
158
+ `Resource<T>`; a consumer types the **resource** once and gets exactly those methods.
159
+
160
+ | Interface | Surface |
161
+ |---|---|
162
+ | `PubSubResource<T>` | `publish(value, channel?)` · `subscribe(handler, { channel?, once?, ttl? })` |
163
+ | `WatchableResource<T>` | `watch(id, handler, { once?, ttl? })` — changes to ONE record |
164
+ | `StreamResource<T>` | `stream(key, value)` · `consume(key, { group?, consumer?, block? })` |
165
+ | `LockableResource<T>` | `lock`/`unlock` — field level encryption at rest |
166
+ | `MigratableResource<Tx, Self>` | `migration(name, apply, stage?)` · `migrations()` |
167
+
77
168
  ## The migration framework
78
169
 
79
170
  Migrations are **automatic on app setup**: a backend runs them inside the resource's
@@ -85,9 +176,11 @@ Adding migration support to a new database backend:
85
176
 
86
177
  1. Extend the concrete resource interface with `MigratableResource<YourTx>` and design the
87
178
  `Tx` façade a migration receives.
88
- 2. Keep registrations in a **module-scope declaration keyed by alias** (see either
89
- implementation's `declarations.ts`) — `reinitializeContext` rebuilds resource objects, and
90
- a registry lost to a rebuild silently loses data transformations.
179
+ 2. Keep registrations in a **module-scope declaration keyed by alias** — the shape mongo and
180
+ postgres both expose as `getDeclaration(alias)` / `resetDeclarations(alias?)`. A maker that
181
+ runs more than once for the same alias (a custom maker, a test) then re-declares the same
182
+ entries and loses nothing, where a registry held on the resource object would silently lose
183
+ data transformations.
91
184
  3. Implement `MigrationStore` over a durable ledger when the database can store one
92
185
  (`_owlmeans_migrations` in both mongo and postgres); a store-less backend may run
93
186
  migrations unconditionally if its bodies are self-checking.
@@ -100,7 +193,12 @@ Adding migration support to a new database backend:
100
193
  ## Depends On
101
194
 
102
195
  - `@owlmeans/context` — service/resource lifecycle
103
- - `@noble/hashes` — migration body checksums
196
+ - `@owlmeans/error` — `ResilientError`, the base every error class above extends
197
+ - peer `ajv` — `createListSchema` builds a `JSONSchemaType`; install it when you use that schema
198
+
199
+ The migration framework checksums bodies with `@noble/hashes` and `@scure/base`, and the package
200
+ declares neither. Install both alongside it in any project that loads `createMigrationRegistry` or
201
+ `runMigrations`, or the import fails at load time.
104
202
 
105
203
  ## Related
106
204