@owlmeans/mongo-resource 0.1.15 → 0.1.16

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 (53) hide show
  1. package/README.md +54 -10
  2. package/agent-meta/manifest.json +4 -11
  3. package/agent-meta/skills/mongo-resource/SKILL.md +150 -17
  4. package/build/consts.d.ts +16 -0
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +16 -0
  7. package/build/consts.js.map +1 -1
  8. package/build/declarations.d.ts +11 -0
  9. package/build/declarations.d.ts.map +1 -0
  10. package/build/declarations.js +29 -0
  11. package/build/declarations.js.map +1 -0
  12. package/build/index.d.ts +3 -0
  13. package/build/index.d.ts.map +1 -1
  14. package/build/index.js +3 -0
  15. package/build/index.js.map +1 -1
  16. package/build/resource.d.ts.map +1 -1
  17. package/build/resource.js +58 -23
  18. package/build/resource.js.map +1 -1
  19. package/build/types.d.ts +53 -2
  20. package/build/types.d.ts.map +1 -1
  21. package/build/utils/index.d.ts +2 -0
  22. package/build/utils/index.d.ts.map +1 -1
  23. package/build/utils/index.js +2 -0
  24. package/build/utils/index.js.map +1 -1
  25. package/build/utils/life-cycle.d.ts +25 -1
  26. package/build/utils/life-cycle.d.ts.map +1 -1
  27. package/build/utils/life-cycle.js +89 -11
  28. package/build/utils/life-cycle.js.map +1 -1
  29. package/build/utils/migrations.d.ts +24 -0
  30. package/build/utils/migrations.d.ts.map +1 -0
  31. package/build/utils/migrations.js +129 -0
  32. package/build/utils/migrations.js.map +1 -0
  33. package/build/utils/refs.d.ts +74 -0
  34. package/build/utils/refs.d.ts.map +1 -0
  35. package/build/utils/refs.js +197 -0
  36. package/build/utils/refs.js.map +1 -0
  37. package/build/utils/schema.d.ts +8 -0
  38. package/build/utils/schema.d.ts.map +1 -1
  39. package/build/utils/schema.js +25 -0
  40. package/build/utils/schema.js.map +1 -1
  41. package/package.json +5 -5
  42. package/src/consts.ts +20 -0
  43. package/src/declarations.ts +42 -0
  44. package/src/index.ts +4 -1
  45. package/src/resource.ts +76 -28
  46. package/src/types.ts +58 -2
  47. package/src/utils/index.ts +2 -0
  48. package/src/utils/life-cycle.ts +117 -15
  49. package/src/utils/migrations.ts +171 -0
  50. package/src/utils/refs.ts +240 -0
  51. package/src/utils/schema.ts +32 -0
  52. package/tests/refs.spec.ts +95 -0
  53. package/agent-meta/instructions/mongo-resource.instructions.md +0 -30
package/README.md CHANGED
@@ -4,9 +4,11 @@ MongoDB-backed `Resource<T>` implementation — the primary database resource fo
4
4
 
5
5
  ## Overview
6
6
 
7
- - `makeMongoResource<R, T>(alias, dbAlias?, serviceAlias?, maker?)` — factory for MongoDB resources
8
- - `MongoResource<T>` — extends `Resource<T>` with MongoDB collection, indexing, and field encryption
9
- - Supports CRUD, list/pagination, AJV schema validation, and field-level locking (encryption)
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
10
12
  - Used for all persistent data models in server applications
11
13
 
12
14
  ## Installation
@@ -53,19 +55,61 @@ const list = await projects.list({ criteria: { entityId } })
53
55
 
54
56
  ## API
55
57
 
56
- ### `makeMongoResource<R, T>(alias, dbAlias?, serviceAlias?, maker?): T`
58
+ ### `makeMongoResource<R, T>(alias, dbAlias?, serviceAlias?, maker?, collectionName?): T`
57
59
 
58
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).
59
64
 
60
65
  ### `MongoResource<T>`
61
66
 
62
- Extends `Resource<T>` with:
67
+ Extends `Resource<T>` (and the shared `MigratableResource<MongoTx>` capability) with:
63
68
  - `collection: Collection` — MongoDB collection
64
- - `db(): Promise<Db>` get the MongoDB database
69
+ - `db(): Promise<Db>` / `client(): Promise<MongoClient>`
65
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)
66
73
  - `lock(record, fields?)` / `unlock(record, fields?)` — encrypt/decrypt secure fields
67
74
  - `getDefaults(): Partial<T>` — default values derived from schema
68
75
 
76
+ ### ObjectId references
77
+
78
+ `reference(field, targetAlias?)` declares that a record field references another record's id.
79
+ The resource then treats the field exactly like `_id`:
80
+
81
+ - 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,
83
+ `$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
87
+ identical key pattern is already declared, and the collection validator declares it
88
+ `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.
98
+
99
+ ### Migrations
100
+
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).
104
+
105
+ - `MigrationStage.Pre` runs before the validator/index update, `Post` after. On a collection
106
+ 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.
112
+
69
113
  ### `Resource<T>` methods (all implemented)
70
114
 
71
115
  `get`, `load`, `create`, `update`, `save`, `delete`, `pick`, `list`
@@ -74,6 +118,7 @@ Extends `Resource<T>` with:
74
118
 
75
119
  - `DEFAULT_DB_ALIAS` — `'mongo'`
76
120
  - `DEFAULT_PAGE_SIZE` — `10`
121
+ - `DEF_MIGRATIONS_COLLECTION` — `'_owlmeans_migrations'`
77
122
 
78
123
  ## Related Packages
79
124
 
@@ -83,10 +128,9 @@ Extends `Resource<T>` with:
83
128
  <!-- owlmeans:agent-guidance:start -->
84
129
  ## Agent guidance
85
130
 
86
- This package ships embedded Claude Code skills and GitHub Copilot instructions under
87
- `agent-meta/`. After installing your `@owlmeans/*` packages, run the OwlMeans
88
- agent-skills installer to place them into your project's native locations
89
- (`.claude/skills/` and `.github/instructions/`):
131
+ This package ships embedded agent skills under `agent-meta/`. After installing your
132
+ `@owlmeans/*` packages, run the OwlMeans agent-skills installer to place them into
133
+ your project's skill store (`.agents/skills/`):
90
134
 
91
135
  ```sh
92
136
  npx @owlmeans/agent-skills
@@ -1,8 +1,8 @@
1
1
  {
2
- "schemaVersion": 1,
2
+ "schemaVersion": 2,
3
3
  "package": "@owlmeans/mongo-resource",
4
- "version": "0.1.15",
5
- "generatedAt": "2026-08-07T17:06:32.896Z",
4
+ "version": "0.1.16",
5
+ "generatedAt": "2026-08-14T10:14:48.849Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -10,14 +10,7 @@
10
10
  "name": "mongo-resource",
11
11
  "category": "package-specific",
12
12
  "file": "skills/mongo-resource/SKILL.md",
13
- "canonicalPath": ".claude/skills/mongo-resource/SKILL.md"
14
- },
15
- {
16
- "kind": "instruction",
17
- "name": "mongo-resource",
18
- "category": "package-specific",
19
- "file": "instructions/mongo-resource.instructions.md",
20
- "canonicalPath": ".github/instructions/mongo-resource.instructions.md"
13
+ "canonicalPath": ".agents/skills/mongo-resource/SKILL.md"
21
14
  }
22
15
  ]
23
16
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: mongo-resource
3
- description: How to use @owlmeans/mongo-resource — MongoDB-backed Resource implementation. Builds a typed CRUD resource on top of @owlmeans/mongo. Auto-invoked when defining a resource backed by MongoDB.
3
+ description: How to use @owlmeans/mongo-resource — MongoDB-backed Resource implementation with AJV-schema validators, code migrations and ObjectId reference conversion. Auto-invoked when defining a resource backed by MongoDB, declaring record references, or writing mongo migrations.
4
4
  user-invocable: false
5
5
  ---
6
6
  <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
@@ -8,32 +8,165 @@ user-invocable: false
8
8
  # @owlmeans/mongo-resource
9
9
 
10
10
  **Layer:** Infra
11
- **Install:** `"@owlmeans/mongo-resource": "^0.1.15"` in `dependencies`
11
+ **Install:** `"@owlmeans/mongo-resource": "^0.1.16"` in `dependencies` (peers `mongodb`, `ajv`)
12
+
13
+ The Mongo counterpart of [[postgres-resource]]. A collection has no structure of its own, so
14
+ here the resource layer owns the *validator* (`$jsonSchema` from the AJV schema), the indexes,
15
+ the code migrations, and the string↔`ObjectId` conversion for `_id` **and every declared
16
+ reference**.
12
17
 
13
18
  ## Key Exports
14
19
 
15
20
  | Export | Description |
16
21
  |--------|-------------|
17
- | `makeMongoResource<T>(options)` | Factory for a MongoDB-backed Resource<T> |
18
- | `MongoResource<T>` types | Resource interface |
19
- | Constants | Default collection prefix |
20
- | Helpers | Index creation, query helpers |
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`. |
24
+ | `MongoDbService`, `MongoTx` | Service contract implemented by `@owlmeans/mongo`; the façade handed to migrations (`db`, `collection`, `use(alias)`, `ref(alias)`). |
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. |
28
+ | `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. |
21
32
 
22
- ## Usage
33
+ ## Usage — the maker pattern
23
34
 
24
35
  ```typescript
25
- import { makeMongoResource } from '@owlmeans/mongo-resource'
26
-
27
- context.registerResource(makeMongoResource<Project>({
28
- alias: 'projects',
29
- collection: 'projects',
30
- }))
36
+ export const makeProjectStoryResource: ResourceMaker<ProjectStoryRecord, ProjectStoryResource> =
37
+ (dbAlias, serviceAlias) => {
38
+ const resource = makeMongoResource<ProjectStoryRecord, ProjectStoryResource>(
39
+ RES_PROJECT_STORY, dbAlias, serviceAlias, makeProjectStoryResource
40
+ )
41
+ resource.schema = ProjectStorySchema
42
+ resource.reference('projectId', RES_PROJECT)
43
+ resource.index('code', { projectId: 1, code: 1 }, { sparse: true })
44
+ resource.migration('0001-backfill-code', async tx => { /* ... */ })
31
45
 
32
- // In a handler:
33
- const projects = ctx.getResource<Resource<Project>>('projects')
34
- await projects.create({ name: 'New', entityId })
46
+ return resource
47
+ }
48
+ context.registerResource(makeProjectStoryResource())
35
49
  ```
36
50
 
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.
55
+
56
+ ## ObjectId references
57
+
58
+ A field that stores **another record's id** is declared with `reference(field, targetAlias?)`.
59
+ The resource then behaves for that field exactly as it does for `_id`:
60
+
61
+ - **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
+ - **Writes are strict**: storing a non-24-hex value in a declared reference throws
67
+ `MisshapedRecord('ref:<field>')` — a silent string would reintroduce the mixed-type state.
68
+ Reads and criteria are tolerant: an unconverted legacy string comes back as-is; a non-id
69
+ criteria value simply matches nothing (the auth `userId ?? profileId` fallback relies on
70
+ this).
71
+ - **`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.
74
+ - **The field is indexed** automatically (`ref_<field>`), unless `noIndex: true` or the
75
+ resource already declares an index with the identical key pattern (mongo forbids two
76
+ indexes over the same keys; a declared unique index wins).
77
+ - **The validator declares the field `objectId`** (nullable/array shapes carry over from the
78
+ AJV property) — after the switch a raw string write is rejected at the collection level.
79
+
80
+ ### The system migration and its double check
81
+
82
+ Declaring a reference registers `$ref:<field>@1` at `Pre` stage: an idempotent, interrupt-safe
83
+ `updateMany` that converts stored 24-hex strings (scalar or array elements) to `ObjectId`s and
84
+ leaves everything else untouched. On restart the ledger says whether it ran; **independently**,
85
+ after structure update the boot probes the collection for convertible strings and repairs any
86
+ drift (restored backup, legacy writer), logging a warning. Both paths run with
87
+ `bypassDocumentValidation` — the connection's user must hold that privilege (`dbOwner`/`root`
88
+ do).
89
+
90
+ The `@1` in the name is the body's version. The body is shared by every field, so its checksum
91
+ never distinguishes them — **any semantic edit to `convertReferenceField` must bump the
92
+ version suffix** in `refMigrationName`, or every already-applied ledger raises
93
+ `MigrationConflict` at boot.
94
+
95
+ ### What is NOT a reference — do not declare these
96
+
97
+ Only fields assigned from another record's `.id` qualify. Known traps from the live codebase:
98
+
99
+ - `entityId` / `entity` — IAM entity slug (also a Keycloak realm and a k8s namespace label)
100
+ - `profileId` — composite key `"{type}:{accountId}"`; `credentials.userId` — external
101
+ provider key `"{type}:{service}:{sub}"` (while `profile.userId` **is** a reference)
102
+ - Stripe ids (`externalId`, `productId`, `taxId`), Cloudflare `providerId`, GitHub numeric ids
103
+ - locally minted slugs/tokens (`linkId`, `alias`, `slug`, `code`, `credential`)
104
+
105
+ Converting one of these corrupts the collection and breaks unique indexes. When in doubt,
106
+ trace what the writer actually assigns.
107
+
108
+ ### Raw driver access bypasses all of this
109
+
110
+ `resource.collection.find/aggregate/findOneAndUpdate` see `ObjectId`s. Marshal filter values
111
+ with `marshalReference(field, value)` and convert read-back documents' reference fields (and
112
+ `_id`) to strings by hand — or better, stay on the resource API.
113
+
114
+ ## Migrations
115
+
116
+ `resource.migration(name, apply, stage?)` — the shared `MigratableResource` capability from
117
+ [[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).
119
+
120
+ - `Pre` runs **before** the validator is updated and indexes reconcile; `Post` after. A `Pre`
121
+ body writes shapes the *old* validator allows; a `Post` body the *new* one.
122
+ - On a collection this boot just created, every registered migration is **baselined**
123
+ (recorded, not run) — a fresh collection is born at head.
124
+ - **No transactions**: a standalone `mongod` (the dev/CI target) rejects multi-document
125
+ transactions, so the ledger claims-then-completes — the unique `(alias, name)` index is the
126
+ mutual exclusion; a replica losing the race waits for the winner; a failed body withdraws
127
+ the claim so the next boot retries. Consequence: **write migration bodies idempotent** —
128
+ they may be interrupted and re-run.
129
+ - The checksum fingerprints the body's **source text**. Keep bodies at module scope; an edited
130
+ applied body raises `MigrationConflict`, a throwing one `MigrationError` and the boot
131
+ aborts. A body that closes over a loop variable fingerprints the wrapper — the trap
132
+ `createMigrationRegistry` documents.
133
+ - Inside a body, `tx.use(alias)` / `tx.ref(alias)` address other registered resources'
134
+ collections by alias — resolved from config, so registration order does not matter (unlike
135
+ Postgres `{{alias}}`).
136
+
137
+ ## Lifecycle order at `init()`
138
+
139
+ 1. probe for the collection
140
+ 2. absent → baseline all migrations; present → run `Pre` (system `$ref:` first if declared before app migrations)
141
+ 3. create collection (validator + indexes) or update validator + reconcile indexes
142
+ 4. present → run `Post`
143
+ 5. reconcile declared references (probe + repair — the double check)
144
+
145
+ ## Method semantics worth remembering
146
+
147
+ | Method | Semantics |
148
+ |---|---|
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 |
153
+ | `list` | criteria go through reference conversion; documents never store `id` — use `id` criteria freely, they map to `_id` |
154
+ | `lock`/`unlock` | encrypt/decrypt `secure: true` schema fields via the db service |
155
+
156
+ ## Tests
157
+
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`.
162
+
37
163
  ## Depends On
38
164
 
39
- - `@owlmeans/mongo`, `@owlmeans/resource`, `@owlmeans/server-context`
165
+ - `@owlmeans/resource` · `@owlmeans/context` · `@owlmeans/server-context`
166
+ - peer `mongodb`, `ajv`
167
+
168
+ ## Related
169
+
170
+ - [[mongo]] — the connection service this resolves through
171
+ - [[resource]] — `Resource<T>`, `MigratableResource`, the migration framework, errors
172
+ - [[postgres-resource]] — the Postgres counterpart (structure reconciliation instead of validators)
package/build/consts.d.ts CHANGED
@@ -1,3 +1,19 @@
1
1
  export declare const DEFAULT_DB_ALIAS = "mongo";
2
2
  export declare const DEFAULT_PAGE_SIZE = 10;
3
+ /**
4
+ * Collection that records which code-registered migrations have already been applied.
5
+ *
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.
9
+ */
10
+ export declare const DEF_MIGRATIONS_COLLECTION = "_owlmeans_migrations";
11
+ /**
12
+ * How long a replica waits for another replica's in-flight migration before giving up.
13
+ * Bounded because the alternative is a pod that hangs on boot with no diagnostic.
14
+ */
15
+ export declare const DEF_MIGRATION_WAIT = 60000;
16
+ export declare const DEF_MIGRATION_POLL = 250;
17
+ /** `E11000` — the unique index on `(alias, name)` rejecting a second replica's claim. */
18
+ export declare const MONGO_DUPLICATE_KEY = 11000;
3
19
  //# sourceMappingURL=consts.d.ts.map
@@ -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"}
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"}
package/build/consts.js CHANGED
@@ -1,3 +1,19 @@
1
1
  export const DEFAULT_DB_ALIAS = 'mongo';
2
2
  export const DEFAULT_PAGE_SIZE = 10;
3
+ /**
4
+ * Collection that records which code-registered migrations have already been applied.
5
+ *
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.
9
+ */
10
+ export const DEF_MIGRATIONS_COLLECTION = '_owlmeans_migrations';
11
+ /**
12
+ * How long a replica waits for another replica's in-flight migration before giving up.
13
+ * Bounded because the alternative is a pod that hangs on boot with no diagnostic.
14
+ */
15
+ export const DEF_MIGRATION_WAIT = 60000;
16
+ export const DEF_MIGRATION_POLL = 250;
17
+ /** `E11000` — the unique index on `(alias, name)` rejecting a second replica's claim. */
18
+ export const MONGO_DUPLICATE_KEY = 11000;
3
19
  //# sourceMappingURL=consts.js.map
@@ -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"}
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"}
@@ -0,0 +1,11 @@
1
+ import type { MigrationRegistry } from '@owlmeans/resource';
2
+ import type { MongoReference, MongoTx } from './types.js';
3
+ export interface MongoDeclaration {
4
+ migrations: MigrationRegistry<MongoTx>;
5
+ /** Declared ObjectId references, keyed by field. Registered via `resource.reference()`. */
6
+ references: Map<string, MongoReference>;
7
+ }
8
+ export declare const getDeclaration: (alias: string) => MongoDeclaration;
9
+ /** Testing seam — drops every declaration so a spec can redeclare a resource from scratch. */
10
+ export declare const resetDeclarations: (alias?: string) => void;
11
+ //# sourceMappingURL=declarations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"declarations.d.ts","sourceRoot":"","sources":["../src/declarations.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAE3D,OAAO,KAAK,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AAEzD,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,iBAAiB,CAAC,OAAO,CAAC,CAAA;IACtC,2FAA2F;IAC3F,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAA;CACxC;AAcD,eAAO,MAAM,cAAc,GAAI,OAAO,MAAM,KAAG,gBAQ9C,CAAA;AAED,8FAA8F;AAC9F,eAAO,MAAM,iBAAiB,GAAI,QAAQ,MAAM,KAAG,IAOlD,CAAA"}
@@ -0,0 +1,29 @@
1
+ import { createMigrationRegistry } from '@owlmeans/resource';
2
+ /**
3
+ * Per-alias migration store, held at module scope rather than on the resource object.
4
+ *
5
+ * `reinitializeContext` rebuilds every resource from the maker, which drops anything a
6
+ * caller attached by chaining afterwards. For indexes that is survivable — they already
7
+ * exist in the database and `updateIndexes` only ever adds. Migrations are not: a layer
8
+ * switch points the resource at a *different* database, and a registry emptied by the
9
+ * rebuild would mean the entity database silently never gets the transformation. Keying
10
+ * by alias makes the declarations outlive any number of context switches.
11
+ */
12
+ const declarations = new Map();
13
+ export const getDeclaration = (alias) => {
14
+ let declaration = declarations.get(alias);
15
+ if (declaration == null) {
16
+ declaration = { migrations: createMigrationRegistry(), references: new Map() };
17
+ declarations.set(alias, declaration);
18
+ }
19
+ return declaration;
20
+ };
21
+ /** Testing seam — drops every declaration so a spec can redeclare a resource from scratch. */
22
+ export const resetDeclarations = (alias) => {
23
+ if (alias == null) {
24
+ declarations.clear();
25
+ return;
26
+ }
27
+ declarations.delete(alias);
28
+ };
29
+ //# sourceMappingURL=declarations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"declarations.js","sourceRoot":"","sources":["../src/declarations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,oBAAoB,CAAA;AAW5D;;;;;;;;;GASG;AACH,MAAM,YAAY,GAAkC,IAAI,GAAG,EAAE,CAAA;AAE7D,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,KAAa,EAAoB,EAAE;IAChE,IAAI,WAAW,GAAG,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;IACzC,IAAI,WAAW,IAAI,IAAI,EAAE,CAAC;QACxB,WAAW,GAAG,EAAE,UAAU,EAAE,uBAAuB,EAAW,EAAE,UAAU,EAAE,IAAI,GAAG,EAAE,EAAE,CAAA;QACvF,YAAY,CAAC,GAAG,CAAC,KAAK,EAAE,WAAW,CAAC,CAAA;IACtC,CAAC;IAED,OAAO,WAAW,CAAA;AACpB,CAAC,CAAA;AAED,8FAA8F;AAC9F,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,KAAc,EAAQ,EAAE;IACxD,IAAI,KAAK,IAAI,IAAI,EAAE,CAAC;QAClB,YAAY,CAAC,KAAK,EAAE,CAAA;QAEpB,OAAM;IACR,CAAC;IACD,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AAC5B,CAAC,CAAA"}
package/build/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export type * from './types.js';
2
2
  export * from './consts.js';
3
+ export * from './declarations.js';
4
+ export * from './utils/migrations.js';
5
+ export * from './utils/refs.js';
3
6
  export * from './resource.js';
4
7
  export * from './helper.js';
5
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,mBAAmB,CAAA;AACjC,cAAc,uBAAuB,CAAA;AACrC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,aAAa,CAAA"}
package/build/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  export * from './consts.js';
2
+ export * from './declarations.js';
3
+ export * from './utils/migrations.js';
4
+ export * from './utils/refs.js';
2
5
  export * from './resource.js';
3
6
  export * from './helper.js';
4
7
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,aAAa,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,cAAc,aAAa,CAAA;AAC3B,cAAc,mBAAmB,CAAA;AACjC,cAAc,uBAAuB,CAAA;AACrC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,aAAa,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAgB,aAAa,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAErF,OAAO,KAAK,EAAkB,aAAa,EAAE,MAAM,YAAY,CAAA;AAU/D,eAAO,MAAM,iBAAiB,GAC5B,CAAC,SAAS,cAAc,EAAE,CAAC,SAAS,aAAa,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,EAEvE,OAAO,MAAM,EAAE,UAAS,MAAyB,EAAE,eAAc,MAAyB,EAC1F,qBAAqB,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,iBAAiB,MAAM,KAChE,CAyRF,CAAA"}
1
+ {"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAgB,aAAa,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAErF,OAAO,KAAK,EAAmD,aAAa,EAAW,MAAM,YAAY,CAAA;AAezG,eAAO,MAAM,iBAAiB,GAC5B,CAAC,SAAS,cAAc,EAAE,CAAC,SAAS,aAAa,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,EAEvE,OAAO,MAAM,EAAE,UAAS,MAAyB,EAAE,eAAc,MAAyB,EAC1F,qBAAqB,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,iBAAiB,MAAM,KAChE,CAuTF,CAAA"}
package/build/resource.js CHANGED
@@ -1,11 +1,24 @@
1
1
  import { appendContextual, assertContext } from '@owlmeans/context';
2
2
  import { DEFAULT_DB_ALIAS, DEFAULT_PAGE_SIZE } from './consts.js';
3
+ import { MigrationStage } from '@owlmeans/resource';
3
4
  import { initializeCollection } from './utils/life-cycle.js';
5
+ import { getDeclaration } from './declarations.js';
4
6
  import { ObjectId } from 'mongodb';
5
7
  import { MisshapedRecord, RecordExists, UnknownRecordError, UnsupportedArgumentError, RecordUpdateFailed, prepareListOptions } from '@owlmeans/resource';
6
8
  import { getSchemaSecureFeilds } from './helper.js';
9
+ import { demarshalRefs, identityCriteria, makeRefMigration, marshalCriteria, marshalReference, refMigrationName } from './utils/refs.js';
7
10
  export const makeMongoResource = (alias, dbAlias = DEFAULT_DB_ALIAS, serviceAlias = DEFAULT_DB_ALIAS, makeCustomResource, collectionName) => {
8
11
  const location = `mongo-resource:${alias}`;
12
+ /**
13
+ * Live view — references may be declared after the resource is built, and the
14
+ * declarations are module scoped so they survive `reinitializeContext`.
15
+ */
16
+ const refs = () => getDeclaration(alias).references;
17
+ const demarshal = (record) => {
18
+ record.id = record._id instanceof ObjectId ? record._id.toString() : record._id;
19
+ delete record._id;
20
+ return demarshalRefs(record, refs());
21
+ };
9
22
  const resource = appendContextual(alias, {
10
23
  get: async (id, field, opts) => {
11
24
  const record = await resource.load(id, field, opts);
@@ -20,14 +33,12 @@ export const makeMongoResource = (alias, dbAlias = DEFAULT_DB_ALIAS, serviceAlia
20
33
  field = field.field;
21
34
  }
22
35
  field = field ?? '_id';
23
- const criteria = '_id' === field ? new ObjectId(id) : id;
24
36
  if (opts?.ttl != null) {
25
37
  throw new UnsupportedArgumentError('ttl');
26
38
  }
27
- const record = await resource.collection.findOne({ [field]: criteria });
39
+ const record = await resource.collection.findOne(identityCriteria(field, id, refs()));
28
40
  if (record != null) {
29
- record.id = record._id instanceof ObjectId ? record._id.toString() : record._id;
30
- delete record._id;
41
+ demarshal(record);
31
42
  }
32
43
  return record;
33
44
  },
@@ -47,12 +58,11 @@ export const makeMongoResource = (alias, dbAlias = DEFAULT_DB_ALIAS, serviceAlia
47
58
  throw new MisshapedRecord('id');
48
59
  }
49
60
  const original = await resource.get(id, field);
50
- const criteria = '_id' === field ? new ObjectId(id) : id;
51
61
  const replace = { ...record, _id: new ObjectId(original.id) };
52
62
  if (replace.id != null) {
53
63
  delete replace.id;
54
64
  }
55
- const result = await resource.collection.replaceOne({ [field]: criteria }, _prepareValues(replace, resource.schema));
65
+ const result = await resource.collection.replaceOne(identityCriteria(field, id, refs()), _prepareValues(replace, resource.schema, refs()));
56
66
  if (!result.acknowledged) {
57
67
  throw new RecordUpdateFailed(`${field}:${id}`);
58
68
  }
@@ -90,7 +100,7 @@ export const makeMongoResource = (alias, dbAlias = DEFAULT_DB_ALIAS, serviceAlia
90
100
  }
91
101
  const result = await resource.collection.insertOne({
92
102
  ...resource.getDefaults(),
93
- ..._prepareValues(record, resource.schema)
103
+ ..._prepareValues(record, resource.schema, refs())
94
104
  });
95
105
  if (!result.acknowledged) {
96
106
  throw new RecordUpdateFailed(`creation`);
@@ -124,8 +134,7 @@ export const makeMongoResource = (alias, dbAlias = DEFAULT_DB_ALIAS, serviceAlia
124
134
  if (id == null) {
125
135
  throw new MisshapedRecord('id');
126
136
  }
127
- const criteria = '_id' === field ? new ObjectId(_id) : _id;
128
- const result = await resource.collection.deleteOne({ [field]: criteria });
137
+ const result = await resource.collection.deleteOne(identityCriteria(field, _id, refs()));
129
138
  if (!result.acknowledged || result.deletedCount === 0) {
130
139
  return null;
131
140
  }
@@ -140,7 +149,7 @@ export const makeMongoResource = (alias, dbAlias = DEFAULT_DB_ALIAS, serviceAlia
140
149
  },
141
150
  list: async (criteria, opts) => {
142
151
  const options = prepareListOptions(DEFAULT_PAGE_SIZE, criteria, opts);
143
- criteria = options.criteria;
152
+ criteria = marshalCriteria(options.criteria, refs()) ?? {};
144
153
  const pager = options.pager ?? {};
145
154
  const size = pager?.size ?? DEFAULT_PAGE_SIZE;
146
155
  const total = await resource.collection.countDocuments(criteria);
@@ -158,12 +167,7 @@ export const makeMongoResource = (alias, dbAlias = DEFAULT_DB_ALIAS, serviceAlia
158
167
  }
159
168
  const items = await cursor.toArray();
160
169
  return {
161
- pager, items: items.map(item => {
162
- const _item = { ...item };
163
- _item.id = item._id.toString();
164
- delete _item._id;
165
- return _item;
166
- })
170
+ pager, items: items.map(item => demarshal({ ...item }))
167
171
  };
168
172
  },
169
173
  lock: async (record, fields) => {
@@ -200,7 +204,30 @@ export const makeMongoResource = (alias, dbAlias = DEFAULT_DB_ALIAS, serviceAlia
200
204
  resource.indexes = resource.indexes ?? [];
201
205
  resource.indexes.push({ name, index, options });
202
206
  return resource;
203
- }
207
+ },
208
+ /**
209
+ * `migration` and `reference` are `this`-returning in the interface, which an object
210
+ * literal can't express — hence the member level casts: the implementations return
211
+ * the closed over `resource`, which is that very object.
212
+ */
213
+ migration: ((name, apply, stage) => {
214
+ getDeclaration(alias).migrations.register(name, apply, stage);
215
+ return resource;
216
+ }),
217
+ migrations: () => getDeclaration(alias).migrations,
218
+ reference: ((field, opts) => {
219
+ const declaration = getDeclaration(alias);
220
+ const options = typeof opts === 'string' ? { resource: opts } : opts ?? {};
221
+ declaration.references.set(field, { field, resource: options.resource, noIndex: options.noIndex });
222
+ /**
223
+ * The system migration that converts the field's pre-existing string ids. Registered
224
+ * here rather than at init so it precedes migrations the app declares after its
225
+ * `reference()` calls — the field's type contract is the foundation those build on.
226
+ */
227
+ declaration.migrations.register(refMigrationName(field), makeRefMigration(field), MigrationStage.Pre);
228
+ return resource;
229
+ }),
230
+ references: () => [...getDeclaration(alias).references.values()]
204
231
  });
205
232
  // Explicit collection name override (decoupled from the registration alias, which may
206
233
  // contain characters that aren't valid in a collection name). Survives reinitializeContext
@@ -214,7 +241,7 @@ export const makeMongoResource = (alias, dbAlias = DEFAULT_DB_ALIAS, serviceAlia
214
241
  await mongo.ready();
215
242
  const db = await mongo.db(dbAlias);
216
243
  const config = mongo.config(dbAlias);
217
- resource.collection = await initializeCollection(db, config, resource);
244
+ resource.collection = await initializeCollection(db, config, resource, context);
218
245
  };
219
246
  resource.reinitializeContext = (context) => {
220
247
  const resource = (makeCustomResource?.(dbAlias, serviceAlias)
@@ -224,15 +251,23 @@ export const makeMongoResource = (alias, dbAlias = DEFAULT_DB_ALIAS, serviceAlia
224
251
  };
225
252
  return resource;
226
253
  };
227
- const _prepareValues = (obj, schema) => {
254
+ const _prepareValues = (obj, schema, refs) => {
255
+ /**
256
+ * Declared references convert independently of the schema — the schema is optional,
257
+ * and where it exists it declares these fields as strings, whose coercion below would
258
+ * undo the conversion.
259
+ */
260
+ if (refs != null && refs.size > 0) {
261
+ obj = Object.fromEntries(Object.entries(obj).map(([key, value]) => refs.has(key) ? [key, marshalReference(key, value)] : [key, value]));
262
+ }
228
263
  // @TODO Validate keys from additional properties in the root
229
264
  return schema != null ? Object.fromEntries(Object.entries(obj).map(([key, value]) => {
230
- // @TODO What if we reference another table by object id in another field?
231
- // How to properly transform them?
232
- // What if _id isn't an Object Id?
233
- if (key === '_id') {
265
+ if (key === '_id' && !(value instanceof ObjectId)) {
234
266
  return [key, new ObjectId(value)];
235
267
  }
268
+ if (key === '_id' || refs?.has(key)) {
269
+ return [key, value];
270
+ }
236
271
  // A null/undefined value has nothing to coerce — pass it through for any declared
237
272
  // type. Without this guard the object-map and array branches below call
238
273
  // `Object.entries`/`.map` on `undefined` and throw (and `new Date(null)` would