@owlmeans/mongo-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.
Files changed (47) hide show
  1. package/README.md +232 -71
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/mongo-resource/SKILL.md +80 -30
  4. package/build/consts.d.ts +8 -4
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +8 -4
  7. package/build/consts.js.map +1 -1
  8. package/build/declarations.d.ts.map +1 -1
  9. package/build/declarations.js +5 -6
  10. package/build/declarations.js.map +1 -1
  11. package/build/index.d.ts +1 -0
  12. package/build/index.d.ts.map +1 -1
  13. package/build/index.js +1 -0
  14. package/build/index.js.map +1 -1
  15. package/build/resource.d.ts +2 -2
  16. package/build/resource.d.ts.map +1 -1
  17. package/build/resource.js +91 -118
  18. package/build/resource.js.map +1 -1
  19. package/build/types.d.ts +16 -5
  20. package/build/types.d.ts.map +1 -1
  21. package/build/utils/criteria.d.ts +24 -0
  22. package/build/utils/criteria.d.ts.map +1 -0
  23. package/build/utils/criteria.js +221 -0
  24. package/build/utils/criteria.js.map +1 -0
  25. package/build/utils/index.d.ts +1 -0
  26. package/build/utils/index.d.ts.map +1 -1
  27. package/build/utils/index.js +1 -0
  28. package/build/utils/index.js.map +1 -1
  29. package/build/utils/migrations.d.ts.map +1 -1
  30. package/build/utils/migrations.js +16 -1
  31. package/build/utils/migrations.js.map +1 -1
  32. package/build/utils/refs.d.ts +5 -3
  33. package/build/utils/refs.d.ts.map +1 -1
  34. package/build/utils/refs.js +4 -1
  35. package/build/utils/refs.js.map +1 -1
  36. package/package.json +6 -6
  37. package/src/consts.ts +8 -4
  38. package/src/declarations.ts +5 -6
  39. package/src/index.ts +1 -0
  40. package/src/resource.ts +115 -144
  41. package/src/types.ts +17 -5
  42. package/src/utils/criteria.ts +248 -0
  43. package/src/utils/index.ts +1 -0
  44. package/src/utils/migrations.ts +17 -2
  45. package/src/utils/refs.ts +8 -6
  46. package/tests/criteria.spec.ts +114 -0
  47. package/build/.gitkeep +0 -0
package/README.md CHANGED
@@ -1,27 +1,46 @@
1
1
  # @owlmeans/mongo-resource
2
2
 
3
- MongoDB-backed `Resource<T>` implementation — the primary database resource for OwlMeans server apps.
4
-
5
- ## Overview
6
-
7
- - `makeMongoResource<R, T>(alias, dbAlias?, serviceAlias?, maker?, collectionName?)` — factory for MongoDB resources
8
- - `MongoResource<T>` — extends `Resource<T>` with MongoDB collection, indexing, field encryption, code migrations and ObjectId references
9
- - Supports CRUD, list/pagination, AJV schema validation ($jsonSchema collection validators), and field-level locking (encryption)
10
- - Declared references convert between the string ids records carry and the `ObjectId`s the collection stores — the same way `_id` already does
11
- - Code migrations run automatically at resource initialization, tracked in a per-database `_owlmeans_migrations` ledger
12
- - Used for all persistent data models in server applications
3
+ MongoDB-backed `Resource<T>` for server apps. The AJV schema becomes the collection validator, and
4
+ the package also handles indexes, code migrations and the conversion between string ids and
5
+ `ObjectId`. Use it for documents whose shape varies per record or per tenant and that are read
6
+ whole by id: per-tenant form submissions, generated content documents, compacted agent
7
+ conversations. Postgres, not Mongo, is the default for records you filter, join, sum or report
8
+ across (see [`@owlmeans/postgres-resource`](../postgres-resource)). Expiring or coordinating data
9
+ (sessions, locks, counters, pub/sub) goes in [`@owlmeans/redis-resource`](../redis-resource).
10
+ Client-side records go in [`@owlmeans/state`](../state).
13
11
 
14
12
  ## Installation
15
13
 
16
14
  ```bash
17
- bun add @owlmeans/mongo-resource
15
+ bun add @owlmeans/mongo-resource@^0.1.18-rc.30
18
16
  ```
19
17
 
18
+ `mongodb` and `ajv` are peer dependencies. The connection service comes from
19
+ [`@owlmeans/mongo`](../mongo) (`appendMongo`).
20
+
21
+ ## Concepts
22
+
23
+ - **Resource maker**: a `ResourceMaker<R, T>` function that calls `makeMongoResource`, assigns
24
+ `schema` and declares indexes, references and migrations. The server context factory registers
25
+ its result once with `context.registerResource(makeXResource())`.
26
+ - **Validator**: the resource's AJV schema compiled into a `$jsonSchema` collection validator. It
27
+ is reapplied, and indexes are reconciled, on every boot.
28
+ - **Reference**: a field declared with `reference(field, targetAlias?)` that stores another
29
+ record's id. Records and criteria carry strings, the collection stores `ObjectId`s, and the
30
+ resource converts between them the same way it does for `_id`.
31
+ - **Migration**: a code body registered with `migration(name, apply, stage?)`. It runs once per
32
+ database at `Pre` or `Post` stage around structure reconciliation, and is recorded in the
33
+ `_owlmeans_migrations` ledger.
34
+ - **Declaration**: the module-scope, per-alias store that holds references and migrations. A maker
35
+ called twice for the same alias extends it; the second call does not replace it.
36
+ - **Paged by default**: `list(where)` returns at most `DEFAULT_PAGE_SIZE` (100) documents.
37
+ `{ size: 0 }` explicitly asks for all of them.
38
+
20
39
  ## Usage
21
40
 
22
- Define a resource:
41
+ ### Define and register a resource
23
42
 
24
- ```typescript
43
+ ```ts
25
44
  import { makeMongoResource } from '@owlmeans/mongo-resource'
26
45
  import type { MongoResource } from '@owlmeans/mongo-resource'
27
46
  import type { ResourceMaker } from '@owlmeans/resource'
@@ -29,49 +48,145 @@ import type { ResourceMaker } from '@owlmeans/resource'
29
48
  export interface ProjectResource extends MongoResource<ProjectRecord> {}
30
49
 
31
50
  export const makeProjectResource: ResourceMaker<ProjectRecord, ProjectResource> = (dbAlias, serviceAlias) => {
32
- const resource = makeMongoResource<ProjectRecord>(
33
- RES_PROJECT, dbAlias, serviceAlias, makeProjectResource
34
- )
51
+ const resource = makeMongoResource<ProjectRecord, ProjectResource>(RES_PROJECT, dbAlias, serviceAlias)
35
52
  resource.schema = ProjectSchema
36
53
  resource.index('entity', { entityId: 1 })
37
- resource.index('alias', { alias: 1 })
54
+ resource.index('alias', { entityId: 1, alias: 1 }, { unique: true })
55
+
38
56
  return resource
39
57
  }
40
- ```
41
58
 
42
- Register in context:
43
-
44
- ```typescript
59
+ // in the server context factory, next to appendMongo(context)
45
60
  context.registerResource(makeProjectResource())
46
61
  ```
47
62
 
48
- Use in a handler:
63
+ ### Read and write from a handler
49
64
 
50
- ```typescript
65
+ ```ts
51
66
  const projects = context.resource<ProjectResource>(RES_PROJECT)
52
- const record = await projects.create({ entityId, alias, title })
53
- const list = await projects.list({ criteria: { entityId } })
67
+
68
+ const created = await projects.create({ entityId, alias, title, status: 'draft' })
69
+ const one = await projects.load({ entityId, alias }) // null when absent
70
+ const newest = await projects.get({ entityId }, { sort: [{ field: 'createdAt', order: 'desc' }] })
71
+ const page = await projects.list(
72
+ { entityId, status: ['draft', 'active'] }, // an array means "any of these"
73
+ { page: 0, size: 20, sort: [{ field: 'createdAt', order: 'desc' }] }
74
+ )
75
+ const open = await projects.count({ entityId, status: { $ne: 'archived' } })
76
+
77
+ await projects.update({ ...created, title: 'Renamed' }) // replaces the whole record
78
+ await projects.purge({ entityId, status: 'archived' }) // refuses empty criteria
54
79
  ```
55
80
 
81
+ `entityId` here is the organization's stable record id, taken from `requireEntityKey(req)` in the
82
+ handler, never from the token. Only `entitySlug` travels on the wire.
83
+
84
+ ### References, compound indexes and a domain method
85
+
86
+ A revisioned design document per story, modelled on a real application resource. `current` is a
87
+ domain method the app adds to its own resource interface:
88
+
89
+ ```ts
90
+ export interface DesignResource extends MongoResource<DesignRecord> {
91
+ current: (projectId: string, code: string) => Promise<DesignRecord | null>
92
+ }
93
+
94
+ export const makeDesignResource: ResourceMaker<DesignRecord, DesignResource> = (dbAlias, serviceAlias) => {
95
+ const resource = makeMongoResource<DesignRecord, DesignResource>(RES_DESIGN, dbAlias, serviceAlias)
96
+
97
+ resource.current = async (projectId, code) => {
98
+ const { items } = await resource.list(
99
+ { projectId, code }, { size: 1, sort: [{ field: 'revision', order: 'desc' }] }
100
+ )
101
+
102
+ return items[0] ?? null
103
+ }
104
+
105
+ resource.schema = DesignSchema
106
+ resource.reference('projectId', RES_PROJECT) // stored as ObjectId, indexed ref_projectId
107
+ resource.reference('storyId', RES_STORY)
108
+ resource.index('current', { projectId: 1, code: 1, revision: -1 })
109
+ resource.index('story', { storyId: 1, revision: -1 }, { sparse: true })
110
+
111
+ return resource
112
+ }
113
+ ```
114
+
115
+ `code` is a business key and stays a plain string. Only fields assigned from another record's
116
+ `.id` are references.
117
+
118
+ ### A migration
119
+
120
+ ```ts
121
+ import { MigrationStage } from '@owlmeans/resource'
122
+ import type { MongoTx } from '@owlmeans/mongo-resource'
123
+
124
+ // Module scope, so the checksum fingerprints this body and nothing else.
125
+ const backfillStatus = async (tx: MongoTx) => {
126
+ await tx.collection.updateMany({ status: { $exists: false } }, { $set: { status: 'draft' } })
127
+ }
128
+
129
+ resource.migration('0001-backfill-status', backfillStatus, MigrationStage.Post)
130
+ ```
131
+
132
+ The body receives a `MongoTx` (`db`, `collection`, `use(alias)`, `ref(alias)`) and must be
133
+ idempotent: without transactions an interrupted body can run again.
134
+
135
+ ### Raw driver access with converted references
136
+
137
+ ```ts
138
+ import { marshalReference } from '@owlmeans/mongo-resource'
139
+
140
+ await projects.collection.updateOne(
141
+ { _id: marshalReference('id', projectId) as never, jobSequence },
142
+ { $inc: { jobSequence: 1 }, $set: { updatedAt: new Date() } }
143
+ )
144
+ ```
145
+
146
+ `resource.collection.*` bypasses the conversion layer. Marshal ids yourself, or stay on the
147
+ resource API.
148
+
56
149
  ## API
57
150
 
58
- ### `makeMongoResource<R, T>(alias, dbAlias?, serviceAlias?, maker?, collectionName?): T`
151
+ ### `makeMongoResource<R, T>(alias, dbAlias?, serviceAlias?, collectionName?): T`
59
152
 
60
- Creates a MongoDB resource. `dbAlias` defaults to `DEFAULT_DB_ALIAS` (`'mongo'`).
61
- `collectionName` overrides the physical collection name (otherwise `resourcePrefix + alias`).
62
- Pass the maker itself as the 4th argument so `schema`/`index()` survive context switches;
63
- `migration()`/`reference()` survive regardless (module-scope declarations keyed by alias).
153
+ Creates a MongoDB resource. `dbAlias` and `serviceAlias` default to `DEFAULT_DB_ALIAS` (`'mongo'`).
154
+ `collectionName` overrides the physical collection name (otherwise `resourcePrefix + alias`, limited
155
+ to `[a-zA-Z0-9_-]`). `migration()` and `reference()` are kept in module-scope declarations keyed by
156
+ alias, so a maker that runs more than once for the same alias re-declares the same entries and
157
+ loses nothing.
64
158
 
65
159
  ### `MongoResource<T>`
66
160
 
67
- Extends `Resource<T>` (and the shared `MigratableResource<MongoTx>` capability) with:
68
- - `collection: Collection` — MongoDB collection
161
+ Extends `Resource<T>` with the shared `MigratableResource<MongoTx, MongoResource<T>>` and
162
+ `LockableResource<T>` capabilities, plus:
163
+ - `collection: Collection`, the MongoDB collection
69
164
  - `db(): Promise<Db>` / `client(): Promise<MongoClient>`
70
- - `index(name, spec, options?): this` — define a collection index
71
- - `reference(field, targetAlias?): this` / `references()` — declare that a field stores another record's id (see below)
72
- - `migration(name, apply, stage?): this` / `migrations()` — register a code migration (see below)
73
- - `lock(record, fields?)` / `unlock(record, fields?)` — encrypt/decrypt secure fields
74
- - `getDefaults(): Partial<T>` — default values derived from schema
165
+ - `index(name, spec, options?): this`, which defines a collection index
166
+ - `reference(field, targetAlias?): this` / `references()`, which declare that a field stores another record's id (see below)
167
+ - `migration(name, apply, stage?)` / `migrations()`, which register a code migration (see below)
168
+ - `lock(record, fields?)` / `unlock(record, fields?)`, which encrypt/decrypt `secure: true` schema fields
169
+ - `getDefaults(): Partial<T>`, the default values derived from the schema
170
+ - `dbAlias` / `serviceAlias`, the aliases the resource was registered against
171
+
172
+ ### Criteria, sorting and paging
173
+
174
+ `Criteria<T>` is the portable query shape from [`@owlmeans/resource`](../resource): a bare value
175
+ is equality, a bare array is "any of these", `null` asks for the absence of a value and
176
+ `undefined` is skipped so an untouched filter never empties a list. Every operator in the shared
177
+ vocabulary is translated into the Mongo expression that answers the same question, so one criteria
178
+ object means the same thing here as it does against Postgres or an in-memory store. The vocabulary
179
+ is `$eq $ne $gt $gte $lt $lte $in $nin $exists $null $like $ilike $regex $startsWith $endsWith
180
+ $between $contains $contained $overlaps`, plus `$and`/`$or`/`$not`. `$exists` and `$null` both ask
181
+ whether the field *has a value*, not whether the key is present. An operator outside the
182
+ vocabulary raises `UnsupportedArgumentError`.
183
+
184
+ Paging is per call: `list(where, { page, size, sort })`. Mongo pages by default and returns
185
+ `DEFAULT_PAGE_SIZE` records when no `size` is given, because a caller should not get an unbounded
186
+ read of a collection by omission. `list(where, { size: 0 })` asks for the whole result set
187
+ explicitly, and a `page` without a `size` raises `UnsupportedArgumentError('page-without-size')`.
188
+ `ListResult.total` is always filled; `page` and `size` come back only when a limit was applied.
189
+ `sort` takes field names (ascending) or `{ field, order: 'desc' }`, and `id` addresses `_id`.
75
190
 
76
191
  ### ObjectId references
77
192
 
@@ -79,51 +194,97 @@ Extends `Resource<T>` (and the shared `MigratableResource<MongoTx>` capability)
79
194
  The resource then treats the field exactly like `_id`:
80
195
 
81
196
  - Records and criteria carry **strings**; the collection stores **`ObjectId`s**. Conversion is
82
- automatic on every read, write and lookup — including `$in`-style operator objects,
197
+ automatic on every read, write and lookup, including `$in`-style operator objects,
83
198
  `$and`/`$or`/`$nor` branches and arrays of ids. `id` criteria are mapped onto `_id`.
84
- - Writes are strict (a non-24-hex value throws `MisshapedRecord`); reads and criteria are
85
- tolerant (a non-id value simply matches nothing).
86
- - The field gets a mongo-level index (`ref_<field>`) automatically, unless an index with the
199
+ - Writes are strict: a non-24-hex value throws `MisshapedRecord`. Reads and criteria are tolerant:
200
+ a non-id value simply matches nothing.
201
+ - The field gets a Mongo-level index (`ref_<field>`) automatically, unless an index with the
87
202
  identical key pattern is already declared, and the collection validator declares it
88
203
  `objectId`.
89
- - Declaring a reference registers the system migration `$ref:<field>@1` (pre stage) that
90
- converts pre-existing string ids in place — idempotent and interrupt-safe. On every boot
91
- the collection is additionally probed for convertible strings and repaired if the ledger
92
- and the data disagree (the double check). Conversion bypasses document validation, which
93
- requires the `bypassDocumentValidation` privilege (`dbOwner`/`root` hold it).
94
- - Only declare fields whose values really are another record's `id`. Business keys, composite
95
- keys, external provider ids and slugs must stay strings — converting them corrupts data.
96
- - Raw `resource.collection.*` access bypasses the conversion: marshal filter values with
97
- `marshalReference(field, value)` and convert read-back ids to strings yourself.
204
+ - Declaring a reference registers the system migration `$ref:<field>@1` (pre stage), which
205
+ converts pre-existing string ids in place and is idempotent and interrupt-safe. On every boot
206
+ the collection is also probed for convertible strings and repaired if the ledger and the data
207
+ disagree (the double check). Conversion bypasses document validation, which requires the
208
+ `bypassDocumentValidation` privilege (`dbOwner`/`root` hold it).
98
209
 
99
210
  ### Migrations
100
211
 
101
- `migration(name, apply, stage?)` registers a code migration, applied once per database in
102
- declaration order and recorded in the `_owlmeans_migrations` collection (one ledger per
103
- database — an Entity-layer database tracks its own).
212
+ `migration(name, apply, stage?)` registers a code migration. It is applied once per database, in
213
+ declaration order, and recorded in the `_owlmeans_migrations` collection. The ledger is per
214
+ database, so each database tracks its own.
104
215
 
105
216
  - `MigrationStage.Pre` runs before the validator/index update, `Post` after. On a collection
106
217
  created by this very boot, registered migrations are **baselined** (recorded, not run).
107
- - Bodies receive a `MongoTx` (`db`, `collection`, `use(alias)`, `ref(alias)`) and must be
108
- **idempotent** — multi-document transactions are unavailable on a standalone `mongod`, so
109
- the ledger claims-then-completes and an interrupted body may re-run.
110
- - The checksum fingerprints the body's source text: keep bodies at module scope; an edited
111
- applied body raises `MigrationConflict` at boot.
218
+ - A replica that loses the race to claim a migration waits for the winner (up to
219
+ `DEF_MIGRATION_WAIT`), and a failed body withdraws its claim so the next boot retries.
112
220
 
113
221
  ### `Resource<T>` methods (all implemented)
114
222
 
115
- `get`, `load`, `create`, `update`, `save`, `delete`, `pick`, `list`
116
-
117
- ### Constants
118
-
119
- - `DEFAULT_DB_ALIAS` — `'mongo'`
120
- - `DEFAULT_PAGE_SIZE` — `10`
121
- - `DEF_MIGRATIONS_COLLECTION` — `'_owlmeans_migrations'`
122
-
123
- ## Related Packages
124
-
125
- - [`@owlmeans/resource`](../resource) — `Resource<T>`, `ResourceRecord`, `ResourceMaker` base
126
- - [`@owlmeans/mongo`](../mongo) — MongoDB connection service required by this package
223
+ `get`, `load`, `list`, `count`, `create`, `update`, `save`, `delete`, `take`, `purge`
224
+
225
+ - `get`/`load` take either an id or a `Criteria<T>` with an optional `{ sort }`; `get` throws
226
+ `UnknownRecordError` where `load` answers `null`. An id that is not a Mongo id finds nothing
227
+ rather than raising a driver error.
228
+ - `create` refuses a caller-supplied id (`RecordExists`). `update` replaces the whole record
229
+ addressed by its `id`; `save` creates when the record carries no id and replaces otherwise.
230
+ - `delete(id)` and `take(id)` remove atomically through `findOneAndDelete` and hand the record
231
+ back. `take` throws `UnknownRecordError` on absence where `delete` answers `null`.
232
+ - `purge(where)` is the bulk delete; it refuses an empty criteria object rather than emptying
233
+ the collection.
234
+ - `ttl` is refused with `UnsupportedArgumentError`, because a collection has no per-record expiry.
235
+
236
+ ### Exports
237
+
238
+ | Symbol | Kind | Purpose |
239
+ |---|---|---|
240
+ | `makeMongoResource` | function | The resource factory |
241
+ | `MongoResource<T>` | type | The resource interface described above |
242
+ | `MongoDbService` | type | Connection service contract implemented by `@owlmeans/mongo` |
243
+ | `MongoTx` | type | Façade handed to migration bodies: `db`, `collection`, `use(alias)`, `ref(alias)` |
244
+ | `MongoReference`, `MongoRefOptions` | type | A declared reference and the `reference()` options (`resource`, `noIndex`) |
245
+ | `criteriaToFilter(criteria, refs)` | function | `Criteria<T>` to a Mongo filter, with references converted |
246
+ | `sortToMongo(sort)` | function | `Sort<T>[]` to a Mongo sort document (`id` becomes `_id`) |
247
+ | `marshalReference(field, value)` | function | String id(s) to `ObjectId` for a write; throws `MisshapedRecord` on non-ids |
248
+ | `demarshalReference(value)`, `demarshalRefs(record, refs)` | function | `ObjectId` back to strings for one value or a whole document |
249
+ | `marshalCriteria(filter, refs)`, `identityCriteria(field, id, refs)` | function | Convert a Mongo filter's id-addressed values; build a single-record lookup |
250
+ | `isObjectIdHex(value)` | function | Strict 24-hex test used by the conversion layer |
251
+ | `convertReferenceField`, `makeRefMigration`, `reconcileReferences`, `refMigrationName` | function | The system reference migration and its boot-time probe |
252
+ | `makeMongoTx`, `makeMongoMigrationStore` | function | The migration façade and the ledger implementation |
253
+ | `getDeclaration(alias)`, `resetDeclarations(alias?)`, `MongoDeclaration` | function / type | Module-scope per-alias declarations; `resetDeclarations` is the testing seam |
254
+ | `getSchemaSecureFeilds(schema)` | function | The `secure: true` properties `lock`/`unlock` use when no fields are named |
255
+ | `DEFAULT_DB_ALIAS` | const | `'mongo'` |
256
+ | `DEFAULT_PAGE_SIZE` | const | `100` |
257
+ | `DEF_MIGRATIONS_COLLECTION` | const | `'_owlmeans_migrations'` |
258
+ | `DEF_MIGRATION_WAIT`, `DEF_MIGRATION_POLL` | const | How long (60 000 ms) and how often (250 ms) a replica waits for another's migration |
259
+ | `MONGO_DUPLICATE_KEY` | const | `11000`, the driver's duplicate-key code |
260
+
261
+ ## Common pitfalls
262
+
263
+ - **Declaring a non-id field as a reference.** `entityId`/`entitySlug` (the organization entity),
264
+ composite keys such as `profileId`, provider ids (Stripe, GitHub), slugs, aliases and codes must
265
+ stay strings. Converting them corrupts the collection and breaks unique indexes.
266
+ - **Two indexes over the same keys.** Mongo refuses them. Declare the index yourself under the key
267
+ pattern a reference would use and the automatic `ref_<field>` index is skipped.
268
+ - **Non-idempotent migration bodies.** There are no multi-document transactions on a standalone
269
+ `mongod`, so an interrupted body re-runs.
270
+ - **Editing an applied migration**, or defining its body inside a loop or closure. The checksum
271
+ fingerprints source text, so the boot fails with `MigrationConflict`.
272
+ - **A `Pre` body that `$unset`s a field under a unique index.** Indexes reconcile *after* `Pre`, so
273
+ the old index is still enforcing and the second document fails with E11000. Drop the stale index
274
+ in the body first.
275
+ - **Expecting `update` to merge.** It replaces the whole document. Pass every field that must survive.
276
+ - **Reading "everything" with `list(where)`.** It stops at 100. Ask with `{ size: 0 }`, or page.
277
+ - **Raw `resource.collection` calls with string ids.** They match nothing against `ObjectId`
278
+ fields. Use `marshalReference`.
279
+ - **Passing `{ ttl }`.** It is refused. Expiring records belong in Redis.
280
+
281
+ ## Related packages
282
+
283
+ - [`@owlmeans/mongo`](../mongo): the MongoDB connection service this package resolves through
284
+ - [`@owlmeans/resource`](../resource): `Resource<T>`, criteria, `ResourceMaker`, the migration framework, errors
285
+ - [`@owlmeans/postgres-resource`](../postgres-resource): the relational counterpart and the default choice
286
+ - [`@owlmeans/redis-resource`](../redis-resource): expiring, cached and pub/sub data
287
+ - [`@owlmeans/server-context`](../server-context): the server context resources register on
127
288
 
128
289
  <!-- owlmeans:agent-guidance:start -->
129
290
  ## Agent guidance
@@ -133,7 +294,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
133
294
  your project's skill store (`.agents/skills/`):
134
295
 
135
296
  ```sh
136
- npx @owlmeans/agent-skills
297
+ npx @owlmeans/agent-skills@^0.1.18-rc.31
137
298
  ```
138
299
 
139
300
  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/mongo-resource",
4
- "version": "0.1.18-rc.0",
5
- "generatedAt": "2026-08-16T22:20:50.501Z",
4
+ "version": "0.1.18-rc.30",
5
+ "generatedAt": "2026-09-21T21:54:22.953Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/mongo-resource
9
9
 
10
10
  **Layer:** Infra
11
- **Install:** `"@owlmeans/mongo-resource": "^0.1.18-rc.0"` in `dependencies` (peers `mongodb`, `ajv`)
11
+ **Install:** `"@owlmeans/mongo-resource": "^0.1.18-rc.30"` in `dependencies` (peers `mongodb`, `ajv`)
12
12
 
13
13
  The Mongo counterpart of [[postgres-resource]]. A collection has no structure of its own, so
14
14
  here the resource layer owns the *validator* (`$jsonSchema` from the AJV schema), the indexes,
@@ -19,16 +19,21 @@ reference**.
19
19
 
20
20
  | Export | Description |
21
21
  |--------|-------------|
22
- | `makeMongoResource<R, T>(alias, dbAlias?, serviceAlias?, maker?, collectionName?)` | The resource factory. Aliases default to `DEFAULT_DB_ALIAS` (`'mongo'`). `collectionName` overrides the collection (else `resourcePrefix + alias`). |
23
- | `MongoResource<T>` | `Resource<T>` + `collection`, `index`, `reference`/`references`, `migration`/`migrations` (the shared `MigratableResource` capability), `lock`/`unlock`, `getDefaults`. |
22
+ | `makeMongoResource<R, T>(alias, dbAlias?, serviceAlias?, collectionName?)` | The resource factory. Aliases default to `DEFAULT_DB_ALIAS` (`'mongo'`). `collectionName` overrides the collection (else `resourcePrefix + alias`). |
23
+ | `MongoResource<T>` | `Resource<T>` + `collection`, `db()`/`client()`, `index`/`indexes`, `reference`/`references`, `migration`/`migrations` (the shared `MigratableResource` capability), `lock`/`unlock`, `getDefaults`, and the `dbAlias`/`serviceAlias` it was registered against. Anything naming a collection on another resource's behalf reads *that* resource's `dbAlias`, since two resources in one database can carry different `resourcePrefix`es. |
24
24
  | `MongoDbService`, `MongoTx` | Service contract implemented by `@owlmeans/mongo`; the façade handed to migrations (`db`, `collection`, `use(alias)`, `ref(alias)`). |
25
25
  | `MongoReference`, `MongoRefOptions` | A declared ObjectId reference and the `reference()` options (`resource`, `noIndex`). |
26
- | `marshalReference`, `demarshalReference`, `marshalCriteria`, `identityCriteria`, `isObjectIdHex` | The conversion layer — reuse these wherever raw driver access bypasses the resource. |
27
- | `convertReferenceField`, `reconcileReferences`, `refMigrationName` | The system reference migration's machinery. |
26
+ | `marshalReference`, `demarshalReference`, `demarshalRefs`, `marshalCriteria`, `identityCriteria`, `isObjectIdHex` | The conversion layer — reuse these wherever raw driver access bypasses the resource. |
27
+ | `criteriaToFilter`, `sortToMongo` | `Criteria<T>` → a mongo filter (references converted, every shared operator rewritten into a mongo expression) and `Sort<T>` → a mongo sort spec. |
28
+ | `convertReferenceField`, `makeRefMigration`, `reconcileReferences`, `refMigrationName` | The system reference migration's machinery. |
28
29
  | `makeMongoTx`, `makeMongoMigrationStore` | The migration store (ledger) implementation. |
29
- | `getDeclaration`, `resetDeclarations` | Module-scope migration/reference declarations, keyed by alias. |
30
- | `schemaToMongoSchema`, `applyReferenceTypes`, `mongoCollectionName`, `updateIndexes` | Validator compilation and lifecycle helpers (deep import `utils/`). |
31
- | `DEFAULT_DB_ALIAS`, `DEFAULT_PAGE_SIZE`, `DEF_MIGRATIONS_COLLECTION` | Constants. |
30
+ | `getDeclaration`, `resetDeclarations`, `MongoDeclaration` | Module-scope migration/reference declarations, keyed by alias; `resetDeclarations(alias?)` is the testing seam. |
31
+ | `getSchemaSecureFeilds` | The `secure: true` schema properties `lock`/`unlock` operate on when the caller names no fields. |
32
+ | `DEFAULT_DB_ALIAS`, `DEFAULT_PAGE_SIZE`, `DEF_MIGRATIONS_COLLECTION`, `DEF_MIGRATION_WAIT`, `DEF_MIGRATION_POLL`, `MONGO_DUPLICATE_KEY` | Constants. |
33
+
34
+ Validator compilation and collection naming stay inside the package — the entry point exports
35
+ nothing to reach them with, so a consumer shapes a collection by assigning `resource.schema` and
36
+ declaring indexes, never by calling the compiler.
32
37
 
33
38
  ## Usage — the maker pattern
34
39
 
@@ -36,7 +41,7 @@ reference**.
36
41
  export const makeProjectStoryResource: ResourceMaker<ProjectStoryRecord, ProjectStoryResource> =
37
42
  (dbAlias, serviceAlias) => {
38
43
  const resource = makeMongoResource<ProjectStoryRecord, ProjectStoryResource>(
39
- RES_PROJECT_STORY, dbAlias, serviceAlias, makeProjectStoryResource
44
+ RES_PROJECT_STORY, dbAlias, serviceAlias
40
45
  )
41
46
  resource.schema = ProjectStorySchema
42
47
  resource.reference('projectId', RES_PROJECT)
@@ -48,10 +53,9 @@ export const makeProjectStoryResource: ResourceMaker<ProjectStoryRecord, Project
48
53
  context.registerResource(makeProjectStoryResource())
49
54
  ```
50
55
 
51
- Pass the maker itself as the 4th argument — `reinitializeContext` re-runs it, which is what
52
- carries `schema` and `index()` calls across context switches. `migration()` and `reference()`
53
- survive regardless: they live in module-scope declarations keyed by alias, because losing one
54
- silently loses a data transformation.
56
+ `migration()` and `reference()` live in module-scope declarations keyed by alias, so a maker
57
+ that runs more than once for the same alias (a custom maker, a test) re-declares the same
58
+ entries and loses nothing — losing one would silently lose a data transformation.
55
59
 
56
60
  ## ObjectId references
57
61
 
@@ -59,18 +63,18 @@ A field that stores **another record's id** is declared with `reference(field, t
59
63
  The resource then behaves for that field exactly as it does for `_id`:
60
64
 
61
65
  - **Records and criteria carry strings; the collection stores `ObjectId`s.** Conversion is
62
- automatic in `create`/`update`/`save` (write), `get`/`load`/`list`/`delete`/`pick` (read and
63
- lookup), and in `list` criteria — including `$in`/`$ne`-style operator objects, `$and`/`$or`/
64
- `$nor` branches, and arrays of ids (elementwise). `$regex`/`$type`-style operands are left
65
- alone.
66
+ automatic in `create`/`update`/`save` (write), `get`/`load`/`list`/`count`/`delete`/`take`/
67
+ `purge` (read and lookup), and in every criteria object — including operator objects,
68
+ `$and`/`$or`/`$not` branches, and arrays of ids (elementwise). `$regex`-style operands are
69
+ left alone.
66
70
  - **Writes are strict**: storing a non-24-hex value in a declared reference throws
67
71
  `MisshapedRecord('ref:<field>')` — a silent string would reintroduce the mixed-type state.
68
72
  Reads and criteria are tolerant: an unconverted legacy string comes back as-is; a non-id
69
73
  criteria value simply matches nothing (the auth `userId ?? profileId` fallback relies on
70
74
  this).
71
75
  - **`id` criteria address `_id`.** Documents never store an `id` field, so `list({ id })` and
72
- `load(x, 'id')` are mapped onto `_id` with conversion — before this mapping they silently
73
- matched nothing.
76
+ `load(id)` map onto `_id` with conversion; a criteria key naming a declared reference converts
77
+ the same way.
74
78
  - **The field is indexed** automatically (`ref_<field>`), unless `noIndex: true` or the
75
79
  resource already declares an index with the identical key pattern (mongo forbids two
76
80
  indexes over the same keys; a declared unique index wins).
@@ -96,7 +100,10 @@ version suffix** in `refMigrationName`, or every already-applied ledger raises
96
100
 
97
101
  Only fields assigned from another record's `.id` qualify. Known traps from the live codebase:
98
102
 
99
- - `entityId` / `entity` — IAM entity slug (also a Keycloak realm and a k8s namespace label)
103
+ - `entityId` / `entitySlug` — the **organization entity**, never a document in this database.
104
+ `entitySlug` is the renameable name that travels on tokens and URLs; `entityId` is the stable id
105
+ the organization registry minted for it, and a deployment with no resolver stores the slug in the
106
+ same field. Neither is an `ObjectId`, and the field carries both shapes across deployments
100
107
  - `profileId` — composite key `"{type}:{accountId}"`; `credentials.userId` — external
101
108
  provider key `"{type}:{service}:{sub}"` (while `profile.userId` **is** a reference)
102
109
  - Stripe ids (`externalId`, `productId`, `taxId`), Cloudflare `providerId`, GitHub numeric ids
@@ -115,10 +122,22 @@ with `marshalReference(field, value)` and convert read-back documents' reference
115
122
 
116
123
  `resource.migration(name, apply, stage?)` — the shared `MigratableResource` capability from
117
124
  [[resource]]. Applied once per database, in declaration order, ledgered in
118
- `_owlmeans_migrations` (one ledger per database, so an Entity-layer database tracks its own).
125
+ `_owlmeans_migrations` (one ledger per database, so each database tracks its own).
119
126
 
120
127
  - `Pre` runs **before** the validator is updated and indexes reconcile; `Post` after. A `Pre`
121
128
  body writes shapes the *old* validator allows; a `Post` body the *new* one.
129
+ - **A `Pre` body that removes an indexed field must drop that index itself.** Indexes reconcile
130
+ *after* `Pre`, so the old index is still live and still enforcing while the body writes, whatever
131
+ the declaration now says. `$unset`ing an indexed field collapses every document onto
132
+ `{ <field>: null, … }`, and on a `unique` index the second one dies with E11000 — mid-migration,
133
+ after earlier collections were already rewritten, leaving the database half-migrated and the boot
134
+ aborting on every restart. Drop the stale index by name in the body first (guard on the key spec
135
+ so the drop is idempotent) and let reconciliation recreate it from the declaration.
136
+ - **Index reconciliation matches by name and recreates on any difference.** A live index whose key
137
+ pattern or options differ from the declaration is dropped and created again, so a renamed field
138
+ does converge — after `Pre`, which is why the bullet above exists. Text indexes are the one
139
+ exception: a live index carrying `weights` is left as it is, and changing one means dropping it
140
+ yourself.
122
141
  - On a collection this boot just created, every registered migration is **baselined**
123
142
  (recorded, not run) — a fresh collection is born at head.
124
143
  - **No transactions**: a standalone `mongod` (the dev/CI target) rejects multi-document
@@ -140,25 +159,56 @@ with `marshalReference(field, value)` and convert read-back documents' reference
140
159
  2. absent → baseline all migrations; present → run `Pre` (system `$ref:` first if declared before app migrations)
141
160
  3. create collection (validator + indexes) or update validator + reconcile indexes
142
161
  4. present → run `Post`
143
- 5. reconcile declared references (probe + repair — the double check)
162
+ 5. present → reconcile declared references (probe + repair — the double check)
163
+
164
+ Steps 4 and 5 belong to the existing-collection path alone: a collection this boot created is
165
+ already at head and carries no legacy strings to repair.
144
166
 
145
167
  ## Method semantics worth remembering
146
168
 
147
169
  | Method | Semantics |
148
170
  |---|---|
149
- | `create` | refuses a caller-supplied id (`RecordExists`) |
150
- | `update` | **replaces** the whole record (no merge) |
151
- | `pick` | deletes the record it returns |
152
- | `load`/`get` | rejects `opts.ttl` (`UnsupportedArgumentError`); second arg selects the lookup field |
171
+ | `create` | refuses a caller-supplied id (`RecordExists`); rejects `opts.ttl` (`UnsupportedArgumentError`) |
172
+ | `update` | **replaces** the whole record (no merge), keeping the document's `_id` |
173
+ | `load(id)` / `get(id)` | a string that is not a 24-hex id matches nothing — `load` answers `null` and `get` throws, where handing it to `ObjectId` would raise a driver error at a call site that only asked whether the record exists |
174
+ | `load(where, { sort })` / `get(where, { sort })` | `findOne` with the sort applied, so "the newest matching record" is one round trip |
153
175
  | `list` | criteria go through reference conversion; documents never store `id` — use `id` criteria freely, they map to `_id` |
176
+ | `delete` / `take` | one `findOneAndDelete`: the record is handed back by the very operation that removed it, so two callers can never be given the same record. `take` **deletes** and throws `UnknownRecordError` on a miss |
177
+ | `purge` | `deleteMany`; refuses an empty criteria object (`UnsupportedArgumentError('purge:no-criteria')`) rather than emptying the collection |
154
178
  | `lock`/`unlock` | encrypt/decrypt `secure: true` schema fields via the db service |
155
179
 
180
+ ## Paging
181
+
182
+ Mongo is **PAGED**: `DEFAULT_PAGE_SIZE` is `100`, so `list(where)` with no `size` returns the first
183
+ 100 matches — a collection is unbounded and an unpaged read is an incident waiting for the document
184
+ count to grow. `ListResult.total` counts every match regardless of the window, and
185
+ `list(where, { size: 0 })` is the explicit, greppable ask for the whole result set. Asking for a
186
+ page while lifting the limit contradicts itself and raises
187
+ `UnsupportedArgumentError('page-without-size')` rather than quietly answering page 0.
188
+
189
+ ## Criteria against a collection
190
+
191
+ `criteriaToFilter` rewrites the shared vocabulary ([[resource]]) into mongo expressions so one
192
+ criteria object selects the same records here as it does in SQL and in memory. Two rewrites are
193
+ worth knowing:
194
+
195
+ - **`$exists` and `$null` compare against `null`**, not mongo's own `$exists`. The shared question
196
+ is whether a field *has a value*, which the other stores answer as `IS NULL` / `value == null`;
197
+ mongo's `$exists` answers whether the key is present, and a key present but null would part the
198
+ three stores over one object.
199
+ - **`$like`/`$ilike` become anchored regular expressions** with `%` as any run and `_` as one
200
+ character; `$between` becomes `$gte`/`$lte`; and `$contains`/`$contained`/`$overlaps` mean over
201
+ an array field exactly what the postgres operators `@>`, `<@` and `&&` mean. An operator mongo
202
+ cannot answer raises `UnsupportedArgumentError` rather than being dropped.
203
+
156
204
  ## Tests
157
205
 
158
- Unit specs (conversion layer): `bun test ./tests` in this package — ungated. Integration
159
- specs that build a real `ServerContext` live in `@owlmeans/mongo` (`migration.spec.ts`,
160
- `references.spec.ts`), gated on `MONGO_URL` (see [[testing-integration]]); a dev port-forward
161
- to the cluster mongo satisfies the checked-in `.env`.
206
+ `bun test ./tests` in this package runs the conversion-layer unit specs (`criteria.spec.ts`,
207
+ `refs.spec.ts`) ungated, plus `crud.spec.ts`, which needs a real collection and self-skips behind
208
+ the same `MONGO_URL` gate as the integration suites. Specs that build a real `ServerContext` live in
209
+ `@owlmeans/mongo` (`crud.spec.ts`, `migration.spec.ts`, `references.spec.ts`), also gated on
210
+ `MONGO_URL` — see [[testing-integration]]; a dev port-forward to the cluster mongo fills the
211
+ `MONGO_URL` the repo's `.env.example` describes.
162
212
 
163
213
  ## Depends On
164
214
 
package/build/consts.d.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  export declare const DEFAULT_DB_ALIAS = "mongo";
2
- export declare const DEFAULT_PAGE_SIZE = 10;
2
+ /**
3
+ * Page size a `list` call gets when it asks for none. Mongo cannot afford an unbounded read
4
+ * of a collection by omission, so paging is the default here — `list(where, { size: 0 })`
5
+ * asks for the whole result set, explicitly and greppably.
6
+ */
7
+ export declare const DEFAULT_PAGE_SIZE = 100;
3
8
  /**
4
9
  * Collection that records which code-registered migrations have already been applied.
5
10
  *
6
- * One ledger per database, which is the right boundary: `dbName()` already varies the
7
- * database per Entity/User layer, so a tenant's migrations are tracked with the tenant's
8
- * data and dropping the database drops the ledger with it.
11
+ * One ledger per database, which is the right boundary: it sits beside the data its
12
+ * migrations changed, so dropping the database drops the ledger with it.
9
13
  */
10
14
  export declare const DEF_MIGRATIONS_COLLECTION = "_owlmeans_migrations";
11
15
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,gBAAgB,UAAU,CAAA;AAEvC,eAAO,MAAM,iBAAiB,KAAK,CAAA;AAEnC;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,yBAAyB,CAAA;AAE/D;;;GAGG;AACH,eAAO,MAAM,kBAAkB,QAAQ,CAAA;AAEvC,eAAO,MAAM,kBAAkB,MAAM,CAAA;AAErC,yFAAyF;AACzF,eAAO,MAAM,mBAAmB,QAAQ,CAAA"}
1
+ {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,gBAAgB,UAAU,CAAA;AAEvC;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,MAAM,CAAA;AAEpC;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,yBAAyB,CAAA;AAE/D;;;GAGG;AACH,eAAO,MAAM,kBAAkB,QAAQ,CAAA;AAEvC,eAAO,MAAM,kBAAkB,MAAM,CAAA;AAErC,yFAAyF;AACzF,eAAO,MAAM,mBAAmB,QAAQ,CAAA"}
package/build/consts.js CHANGED
@@ -1,11 +1,15 @@
1
1
  export const DEFAULT_DB_ALIAS = 'mongo';
2
- export const DEFAULT_PAGE_SIZE = 10;
2
+ /**
3
+ * Page size a `list` call gets when it asks for none. Mongo cannot afford an unbounded read
4
+ * of a collection by omission, so paging is the default here — `list(where, { size: 0 })`
5
+ * asks for the whole result set, explicitly and greppably.
6
+ */
7
+ export const DEFAULT_PAGE_SIZE = 100;
3
8
  /**
4
9
  * Collection that records which code-registered migrations have already been applied.
5
10
  *
6
- * One ledger per database, which is the right boundary: `dbName()` already varies the
7
- * database per Entity/User layer, so a tenant's migrations are tracked with the tenant's
8
- * data and dropping the database drops the ledger with it.
11
+ * One ledger per database, which is the right boundary: it sits beside the data its
12
+ * migrations changed, so dropping the database drops the ledger with it.
9
13
  */
10
14
  export const DEF_MIGRATIONS_COLLECTION = '_owlmeans_migrations';
11
15
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAA;AAEvC,MAAM,CAAC,MAAM,iBAAiB,GAAG,EAAE,CAAA;AAEnC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,sBAAsB,CAAA;AAE/D;;;GAGG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,KAAK,CAAA;AAEvC,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAA;AAErC,yFAAyF;AACzF,MAAM,CAAC,MAAM,mBAAmB,GAAG,KAAK,CAAA"}
1
+ {"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAA;AAEvC;;;;GAIG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,GAAG,CAAA;AAEpC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,sBAAsB,CAAA;AAE/D;;;GAGG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,KAAK,CAAA;AAEvC,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAA;AAErC,yFAAyF;AACzF,MAAM,CAAC,MAAM,mBAAmB,GAAG,KAAK,CAAA"}