@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 +102 -23
- package/agent-meta/manifest.json +9 -2
- package/agent-meta/skills/resource/SKILL.md +125 -27
- package/agent-meta/skills/resource-choice/SKILL.md +264 -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
|
@@ -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`, `
|
|
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
|
-
- `
|
|
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,
|
|
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
|
|
51
|
-
const result: ListResult<ProjectRecord> = await ctx.project().list(
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
- `
|
|
65
|
-
- `
|
|
66
|
-
- `
|
|
67
|
-
- `
|
|
68
|
-
- `
|
|
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?):
|
|
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/
|
|
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
|
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.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.
|
|
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
|
-
| `
|
|
18
|
-
| `
|
|
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
|
-
| `
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
)
|
|
48
|
-
|
|
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` |
|
|
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**
|
|
64
|
-
|
|
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,
|
|
69
|
-
| `get(id,
|
|
70
|
-
| `list(
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
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**
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
- `@
|
|
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
|
|