@owlmeans/mongo 0.1.14 → 0.1.16-rc.0

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.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: "How to use @owlmeans/mongo — MongoDB client service factory (makeMongoService) registered on a server context."
2
+ description: "How to use @owlmeans/mongo — MongoDB connection service (makeMongoDbService / appendMongo) registered on a server context; cluster setup, layer sensitivity, field encryption."
3
3
  applyTo: "**/context.ts, **/config.ts, **/*.ts, **/*.tsx"
4
4
  ---
5
5
  <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
@@ -7,23 +7,37 @@ applyTo: "**/context.ts, **/config.ts, **/*.ts, **/*.tsx"
7
7
  # @owlmeans/mongo
8
8
 
9
9
  **Layer:** Infra
10
- **Install:** `"@owlmeans/mongo": "^0.1.14"` in `dependencies`
10
+ **Install:** `"@owlmeans/mongo": "^0.1.16-rc.0"` in `dependencies` (peer `mongodb`)
11
11
 
12
12
  ## Key Exports
13
13
 
14
14
  | Export | Description |
15
15
  |--------|-------------|
16
- | `makeMongoService()` | MongoDB connection service factory |
17
- | `Mongo` types | Service interface, db handle |
18
- | Constants | `DEFAULT_ALIAS` |
16
+ | `makeMongoDbService(alias?)` | MongoDB connection service factory (implements `MongoDbService`) |
17
+ | `appendMongo(context, alias?)` | Register the service on a server context |
18
+ | `DEFAULT_ALIAS` | `'mongo'` |
19
19
 
20
20
  ## Usage
21
21
 
22
22
  ```typescript
23
- import { makeMongoService } from '@owlmeans/mongo'
24
- context.registerService(makeMongoService())
23
+ import { appendMongo } from '@owlmeans/mongo'
24
+ appendMongo(context)
25
+
26
+ cfg.dbs = [{
27
+ service: 'mongo', alias: 'mongo',
28
+ host: '127.0.0.1', port: 27017, // string[] host → replica set bootstrap
29
+ user: 'admin', secret: '...',
30
+ schema: 'my-app', // DATABASE name (layer-suffixed by dbName())
31
+ encryptionKey: '...', // enables lock()/unlock() field encryption
32
+ entitySensitive: true, // per-Entity-layer databases (each with its own migration ledger)
33
+ }]
25
34
  ```
26
35
 
36
+ ## Tests
37
+
38
+ Integration suites for the whole Mongo pair live here (`tests/migration.spec.ts`,
39
+ `tests/references.spec.ts`), gated on `MONGO_URL`.
40
+
27
41
  ## Depends On
28
42
 
29
- - `@owlmeans/server-context`, `@owlmeans/resource`, `mongodb`
43
+ - `@owlmeans/resource`, `@owlmeans/mongo-resource` (service contract), `@owlmeans/basic-keys`, peer `mongodb`
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "@owlmeans/mongo",
4
- "version": "0.1.14",
5
- "generatedAt": "2026-08-05T16:56:53.382Z",
4
+ "version": "0.1.16-rc.0",
5
+ "generatedAt": "2026-08-11T14:02:34.987Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: mongo
3
- description: How to use @owlmeans/mongo — MongoDB client service factory (makeMongoService) registered on a server context. Auto-invoked when wiring MongoDB into a server app.
3
+ description: How to use @owlmeans/mongo — MongoDB connection service (makeMongoDbService / appendMongo) registered on a server context; cluster setup, layer sensitivity, field encryption backend. Auto-invoked when wiring MongoDB into a server app.
4
4
  user-invocable: false
5
5
  ---
6
6
  <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
@@ -8,31 +8,57 @@ user-invocable: false
8
8
  # @owlmeans/mongo
9
9
 
10
10
  **Layer:** Infra
11
- **Install:** `"@owlmeans/mongo": "^0.1.14"` in `dependencies`
11
+ **Install:** `"@owlmeans/mongo": "^0.1.16-rc.0"` in `dependencies` (peer `mongodb`)
12
12
 
13
13
  ## Key Exports
14
14
 
15
15
  | Export | Description |
16
16
  |--------|-------------|
17
- | `makeMongoService()` | Factory for the MongoDB connection service |
18
- | `Mongo` types | Service interface, db handle |
19
- | Constants | `DEFAULT_ALIAS` for the mongo service |
17
+ | `makeMongoDbService(alias?)` | Factory for the MongoDB connection service (implements `MongoDbService` from [[mongo-resource]]). |
18
+ | `appendMongo(context, alias?)` | Register the service on a server context. Default alias `'mongo'` (`DEFAULT_ALIAS`). |
19
+ | `DEFAULT_ALIAS` | `'mongo'`. |
20
20
 
21
21
  ## Usage
22
22
 
23
23
  ```typescript
24
- import { makeMongoService } from '@owlmeans/mongo'
25
- context.registerService(makeMongoService())
24
+ import { appendMongo } from '@owlmeans/mongo'
25
+ appendMongo(context)
26
26
 
27
- // Connection settings come from cfg.services / cfg.dbs
27
+ // Connection settings come from cfg.dbs
28
28
  cfg.dbs = [{
29
29
  service: 'mongo',
30
- host: 'mongodb://localhost',
31
- database: 'my-app',
30
+ alias: 'mongo',
31
+ host: '127.0.0.1', // or string[] for a cluster — triggers replica set setup
32
+ port: 27017,
33
+ user: 'admin', secret: '...',
34
+ schema: 'my-app', // the DATABASE name (layer-suffixed by dbName())
35
+ encryptionKey: '...', // enables lock()/unlock() field encryption
36
+ entitySensitive: true, // per-Entity-layer databases
32
37
  }]
33
38
  ```
34
39
 
40
+ - The service lazily creates one `MongoClient` per config alias; an array `host` runs the
41
+ replica-set bootstrap (`setUpCluster`) first.
42
+ - `lock`/`unlock` encrypt/decrypt record fields with `encryptionKey` via
43
+ `@owlmeans/basic-keys` — the backend behind `MongoResource.lock()`.
44
+ - Layer sensitivity: `serviceSensitive`/`entitySensitive` add `Layer.Service`/`Layer.Entity`
45
+ to the service's layers, which makes `dbName()` derive per-layer database names — each
46
+ such database carries its own data **and its own migration ledger**.
47
+
48
+ ## Tests
49
+
50
+ This package hosts the integration suites for the whole Mongo pair (a devDependency in the
51
+ other direction would be a cycle): `tests/migration.spec.ts` (ledger end to end),
52
+ `tests/references.spec.ts` (ObjectId reference conversion, system `$ref:` migration, drift
53
+ repair). Gated on `MONGO_URL` — see [[testing-integration]]; the repo `.env` expects a
54
+ port-forward to the cluster mongo.
55
+
35
56
  ## Depends On
36
57
 
37
- - `@owlmeans/server-context`, `@owlmeans/resource`
38
- - `mongodb` (runtime)
58
+ - `@owlmeans/resource` (`createDbService`) · `@owlmeans/mongo-resource` (service contract)
59
+ - `@owlmeans/basic-keys` — field encryption
60
+ - peer `mongodb`
61
+
62
+ ## Related
63
+
64
+ - [[mongo-resource]] — the resource implementation resolved through this service
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@owlmeans/mongo",
3
- "version": "0.1.14",
3
+ "version": "0.1.16-rc.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "build": "tsc -b",
8
8
  "dev": "sleep 192 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
9
- "watch": "tsc -b -w --preserveWatchOutput --pretty"
9
+ "watch": "tsc -b -w --preserveWatchOutput --pretty",
10
+ "test": "bun test ./tests"
10
11
  },
11
12
  "main": "build/index.js",
12
13
  "module": "build/index.js",
@@ -22,16 +23,19 @@
22
23
  },
23
24
  "devDependencies": {
24
25
  "@owlmeans/dep-config": "workspace:*",
26
+ "@owlmeans/test-integration": "^0.1.16-rc.0",
27
+ "@types/bun": "^1.3.14",
25
28
  "@types/node": "^26.1.0",
26
29
  "nodemon": "^3.1.14",
27
30
  "typescript": "^6.0.3"
28
31
  },
29
32
  "dependencies": {
30
33
  "@noble/hashes": "^1.5.0",
31
- "@owlmeans/basic-keys": "^0.1.14",
32
- "@owlmeans/context": "^0.1.14",
33
- "@owlmeans/mongo-resource": "^0.1.14",
34
- "@owlmeans/server-context": "^0.1.14",
34
+ "@owlmeans/basic-keys": "^0.1.16-rc.0",
35
+ "@owlmeans/context": "^0.1.16-rc.0",
36
+ "@owlmeans/mongo-resource": "^0.1.16-rc.0",
37
+ "@owlmeans/resource": "^0.1.16-rc.0",
38
+ "@owlmeans/server-context": "^0.1.16-rc.0",
35
39
  "@scure/base": "^1.1.9",
36
40
  "mongodb": "^6.9.0"
37
41
  },
@@ -0,0 +1,149 @@
1
+ import { mongoGate, randomNamespace } from '@owlmeans/test-integration'
2
+ import type { IntegrationGate, MongoEnv } from '@owlmeans/test-integration'
3
+ import type { MongoResource } from '@owlmeans/mongo-resource'
4
+ import type { ResourceRecord } from '@owlmeans/resource'
5
+ import { config, makeServerContext } from '@owlmeans/server-context'
6
+ import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
7
+ import { MongoClient } from 'mongodb'
8
+ import type { Db } from 'mongodb'
9
+
10
+ import { appendMongo } from '@owlmeans/mongo'
11
+ import type { MongoDbService } from '@owlmeans/mongo-resource'
12
+
13
+ /**
14
+ * Integration harness for the Mongo pair.
15
+ *
16
+ * The `mongo-resource` suite is a pilot that drives the raw driver and proves only the
17
+ * gate/namespace/cleanup pattern. This one builds a real `ServerContext`, registers
18
+ * resources through `makeMongoResource` and exercises the migration ledger end to end —
19
+ * which is why it lives here rather than there (the reverse would make
20
+ * `@owlmeans/mongo-resource` dev-depend on its own dependent).
21
+ */
22
+ export const gate: IntegrationGate<MongoEnv> = mongoGate()
23
+
24
+ const url = (): string => gate.env.MONGO_URL as string
25
+
26
+ /** Short lived client for setup and assertions outside any context. */
27
+ export const raw = async <R>(fn: (client: MongoClient) => Promise<R>): Promise<R> => {
28
+ const client = new MongoClient(url())
29
+ try {
30
+ await client.connect()
31
+
32
+ return await fn(client)
33
+ } finally {
34
+ await client.close().catch(() => undefined)
35
+ }
36
+ }
37
+
38
+ export interface BootOptions {
39
+ /** Registered in the order given — which is *not* dependency order, deliberately. */
40
+ resources?: Array<MongoResource<any>>
41
+ }
42
+
43
+ export type { MongoDbService, MongoResource }
44
+
45
+ export interface Booted {
46
+ context: ServerContext<ServerConfig>
47
+ mongo: MongoDbService
48
+ }
49
+
50
+ export interface MongoSuite {
51
+ /** Mongo DATABASE this suite owns end to end. Dropped by {@link teardown}. */
52
+ database: string
53
+ boot: (opts?: BootOptions) => Promise<Booted>
54
+ teardown: () => Promise<void>
55
+ }
56
+
57
+ /**
58
+ * One database per suite, torn down by that suite's own `afterAll`.
59
+ *
60
+ * The shared `registerCleanup`/`runCleanups` queue is process global and Bun runs every
61
+ * spec file of a package in one process, so the first file to finish would drop the
62
+ * databases of the files still to come.
63
+ */
64
+ export const makeSuite = (label: string): MongoSuite => {
65
+ const prefix = process.env.MONGO_TEST_DB_PREFIX ?? 'omt'
66
+ const database = randomNamespace(`${prefix}_${label}`)
67
+ const clients: MongoClient[] = []
68
+
69
+ const boot = async (opts: BootOptions = {}): Promise<Booted> => {
70
+ /**
71
+ * `prepareConfig` builds the connection string from `host`/`port`/`user`/`secret` rather
72
+ * than taking a URL, so the gate's URL is decomposed here. `schema` is the database name —
73
+ * that is what `dbName()` reads.
74
+ */
75
+ const parsed = new URL(url())
76
+ const cfg: ServerConfig = config('mongo-test', {
77
+ dbs: [{
78
+ service: 'mongo',
79
+ alias: 'mongo',
80
+ host: decodeURIComponent(parsed.hostname).replace(/^\[|\]$/g, ''),
81
+ port: parsed.port !== '' ? parseInt(parsed.port, 10) : 27017,
82
+ schema: database,
83
+ ...(parsed.username !== ''
84
+ ? { user: decodeURIComponent(parsed.username), secret: decodeURIComponent(parsed.password) }
85
+ : {})
86
+ }]
87
+ } as Partial<ServerConfig>)
88
+
89
+ const context = makeServerContext(cfg) as ServerContext<ServerConfig>
90
+ appendMongo(context)
91
+
92
+ for (const resource of opts.resources ?? []) {
93
+ context.registerResource(resource as never)
94
+ }
95
+
96
+ context.configure()
97
+ const mongo = context.service<MongoDbService>('mongo')
98
+ try {
99
+ await context.init()
100
+ } finally {
101
+ /**
102
+ * Captured in `finally` because the suites that assert a *failed* boot — a throwing
103
+ * migration — open a client before the failure and would otherwise leak it.
104
+ */
105
+ for (const client of Object.values(mongo.clients ?? {})) {
106
+ if (!clients.includes(client)) {
107
+ clients.push(client)
108
+ }
109
+ }
110
+ }
111
+
112
+ return { context, mongo }
113
+ }
114
+
115
+ const teardown = async (): Promise<void> => {
116
+ if (gate.skip) {
117
+ return
118
+ }
119
+ await raw(async client => { await client.db(database).dropDatabase() })
120
+ .catch(error => console.error(`mongo test teardown (${database}):`, error))
121
+ while (clients.length > 0) {
122
+ await clients.pop()?.close().catch(() => undefined)
123
+ }
124
+ }
125
+
126
+ return { database, boot, teardown }
127
+ }
128
+
129
+ export interface LedgerRow {
130
+ alias: string
131
+ name: string
132
+ stage: string
133
+ baseline: boolean
134
+ checksum: string | null
135
+ completedAt: Date | null
136
+ }
137
+
138
+ export const ledgerOf = async (mongo: MongoDbService, alias: string): Promise<LedgerRow[]> => {
139
+ const db: Db = await mongo.db()
140
+
141
+ return await db.collection<LedgerRow>('_owlmeans_migrations')
142
+ .find({ alias }).sort({ name: 1 }).toArray() as LedgerRow[]
143
+ }
144
+
145
+ export interface Note extends ResourceRecord {
146
+ id?: string
147
+ title: string
148
+ slug?: string
149
+ }
@@ -0,0 +1,267 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test'
2
+ import { makeMongoResource, resetDeclarations } from '@owlmeans/mongo-resource'
3
+ import type { MongoTx } from '@owlmeans/mongo-resource'
4
+ import { MigrationConflict, MigrationError, MigrationStage } from '@owlmeans/resource'
5
+
6
+ import { gate, ledgerOf, makeSuite, raw } from './context.js'
7
+ import type { LedgerRow, MongoDbService, MongoResource, Note } from './context.js'
8
+
9
+ /**
10
+ * `legacy` exists, `slug` does not. The validator carries `additionalProperties: false`, so
11
+ * this is not merely documentation — writing a `slug` under this schema is refused.
12
+ */
13
+ const v1 = {
14
+ type: 'object',
15
+ properties: {
16
+ title: { type: 'string' },
17
+ legacy: { type: 'string', nullable: true }
18
+ },
19
+ required: ['title']
20
+ }
21
+
22
+ /** The mirror image: `slug` is writable now and `legacy` is not. */
23
+ const v2 = {
24
+ type: 'object',
25
+ properties: {
26
+ title: { type: 'string' },
27
+ slug: { type: 'string', nullable: true }
28
+ },
29
+ required: ['title']
30
+ }
31
+
32
+ /**
33
+ * Bodies live at module scope so their source text — and therefore their checksum — is
34
+ * identical across the boots of one spec file. A body closed over a loop variable would
35
+ * fingerprint the wrapper instead, which is the trap `createMigrationRegistry` documents.
36
+ */
37
+ const ran: Record<string, number> = {}
38
+ const tick = (name: string): void => { ran[name] = (ran[name] ?? 0) + 1 }
39
+
40
+ /** Throws outright: if a baselined migration ever ran, the boot would fail rather than lie. */
41
+ const ghostPreBody = async (): Promise<void> => {
42
+ tick('ghost-pre')
43
+ throw new Error('a baselined migration must never run')
44
+ }
45
+ const ghostPostBody = async (): Promise<void> => {
46
+ tick('ghost-post')
47
+ throw new Error('a baselined migration must never run')
48
+ }
49
+
50
+ const seedBody = async (tx: MongoTx): Promise<void> => {
51
+ tick('seed')
52
+ await tx.collection.insertOne({ title: 'seeded' })
53
+ }
54
+
55
+ /** Valid under {@link v1} only — `legacy` is undeclared once the validator is tightened. */
56
+ const legacyBody = async (tx: MongoTx): Promise<void> => {
57
+ tick('legacy')
58
+ await tx.collection.updateMany({}, { $set: { legacy: 'touched' } })
59
+ }
60
+
61
+ /** Valid under {@link v2} only — and carrying `legacy` across proves the pre stage ran first. */
62
+ const slugBody = async (tx: MongoTx): Promise<void> => {
63
+ tick('slug')
64
+ await tx.collection.updateMany({}, [{ $set: { slug: '$legacy' } }, { $unset: 'legacy' }])
65
+ }
66
+
67
+ const failingBody = async (): Promise<void> => {
68
+ tick('failing')
69
+ throw new Error('deliberate')
70
+ }
71
+
72
+ const driftBody = async (tx: MongoTx): Promise<void> => { await tx.collection.countDocuments() }
73
+ const driftEdited = async (tx: MongoTx): Promise<void> => { await tx.collection.estimatedDocumentCount() }
74
+
75
+ const copyBody = async (tx: MongoTx): Promise<void> => {
76
+ tick('copy')
77
+ const source = await tx.use('mig-src').find({}, { projection: { _id: 0 } }).toArray()
78
+ if (source.length > 0) {
79
+ await tx.collection.insertMany(source)
80
+ }
81
+ }
82
+
83
+ interface Built {
84
+ alias: string
85
+ schema?: unknown
86
+ declare?: (resource: MongoResource<Note>) => void
87
+ }
88
+
89
+ const suite = makeSuite('migration')
90
+ const it = gate.skip ? test.skip : test
91
+
92
+ const build = (spec: Built): MongoResource<Note> => {
93
+ const resource = makeMongoResource<Note, MongoResource<Note>>(spec.alias)
94
+ resource.schema = (spec.schema ?? v1) as never
95
+ spec.declare?.(resource)
96
+
97
+ return resource
98
+ }
99
+
100
+ const boot = async (...specs: Built[]): Promise<MongoDbService> =>
101
+ (await suite.boot({ resources: specs.map(build) })).mongo
102
+
103
+ const ledger = async (mongo: MongoDbService, alias: string): Promise<LedgerRow[]> =>
104
+ await ledgerOf(mongo, alias)
105
+
106
+ describe('@owlmeans/mongo — code registered migrations', () => {
107
+ if (gate.skip) {
108
+ test.skip(gate.reason ?? 'mongo gate closed', () => {})
109
+
110
+ return
111
+ }
112
+
113
+ afterAll(async () => {
114
+ await suite.teardown()
115
+ })
116
+
117
+ it('baselines every migration on a collection it just created', async () => {
118
+ const mongo = await boot({
119
+ alias: 'mig-a',
120
+ declare: resource => {
121
+ resource.migration('0001-ghost', ghostPreBody)
122
+ resource.migration('0002-ghost', ghostPostBody, MigrationStage.Post)
123
+ }
124
+ })
125
+
126
+ const rows = await ledger(mongo, 'mig-a')
127
+ expect(rows).toHaveLength(2)
128
+ expect(rows.every(row => row.baseline)).toBe(true)
129
+ /** A baselined row is complete on arrival — nothing is left holding a claim. */
130
+ expect(rows.every(row => row.completedAt != null)).toBe(true)
131
+ expect(rows.map(row => row.stage)).toEqual([MigrationStage.Pre, MigrationStage.Post])
132
+ expect(ran['ghost-pre']).toBeUndefined()
133
+ expect(ran['ghost-post']).toBeUndefined()
134
+ })
135
+
136
+ it('skips them again on the next boot', async () => {
137
+ const mongo = await boot({
138
+ alias: 'mig-a',
139
+ declare: resource => {
140
+ resource.migration('0001-ghost', ghostPreBody)
141
+ resource.migration('0002-ghost', ghostPostBody, MigrationStage.Post)
142
+ }
143
+ })
144
+
145
+ expect(await ledger(mongo, 'mig-a')).toHaveLength(2)
146
+ expect(ran['ghost-pre']).toBeUndefined()
147
+ })
148
+
149
+ it('applies a migration added after the collection exists exactly once', async () => {
150
+ await boot({ alias: 'mig-b' })
151
+
152
+ const seed = (): Built => ({
153
+ alias: 'mig-b',
154
+ declare: resource => { resource.migration('0001-seed', seedBody, MigrationStage.Post) }
155
+ })
156
+
157
+ const mongo = await boot(seed())
158
+ expect(ran.seed).toBe(1)
159
+
160
+ const [row] = await ledger(mongo, 'mig-b')
161
+ expect(row.baseline).toBe(false)
162
+ expect(row.completedAt).not.toBeNull()
163
+
164
+ /** Re-registering an identical body is a no-op, and the ledger row makes it a no-op twice. */
165
+ await boot(seed())
166
+ expect(ran.seed).toBe(1)
167
+ expect(await ledger(mongo, 'mig-b')).toHaveLength(1)
168
+ })
169
+
170
+ /**
171
+ * The ordering is observable rather than asserted indirectly: `legacyBody` writes a field
172
+ * only the old validator allows and `slugBody` one only the new validator allows, so
173
+ * either stage running on the wrong side of the validator update fails the boot outright.
174
+ */
175
+ it('runs pre before the validator is tightened and post after', async () => {
176
+ const mongo = await boot({ alias: 'mig-c' })
177
+ const db = await mongo.db()
178
+ await db.collection('mig-c').insertOne({ title: 'existing', legacy: 'old' })
179
+
180
+ await boot({
181
+ alias: 'mig-c',
182
+ schema: v2,
183
+ declare: resource => {
184
+ resource.migration('0001-legacy', legacyBody)
185
+ resource.migration('0002-slug', slugBody, MigrationStage.Post)
186
+ }
187
+ })
188
+
189
+ const doc = await db.collection('mig-c').findOne({}, { projection: { _id: 0 } })
190
+ /** `touched`, not `old` — the value could only have been carried over by the pre stage. */
191
+ expect(doc).toEqual({ title: 'existing', slug: 'touched' })
192
+ expect(await ledger(mongo, 'mig-c')).toHaveLength(2)
193
+ })
194
+
195
+ it('aborts the boot on a throwing migration and withdraws its claim', async () => {
196
+ await boot({ alias: 'mig-d' })
197
+
198
+ await expect(boot({
199
+ alias: 'mig-d',
200
+ declare: resource => { resource.migration('0001-fail', failingBody, MigrationStage.Post) }
201
+ })).rejects.toThrow(MigrationError)
202
+ expect(ran.failing).toBe(1)
203
+
204
+ /**
205
+ * Read outside the context: it never came up. Without a transaction the claim has to be
206
+ * released by hand, and a claim left behind would wedge every later boot on a migration
207
+ * that never actually ran.
208
+ */
209
+ const rows = await raw(async client =>
210
+ await client.db(suite.database).collection('_owlmeans_migrations').find({ alias: 'mig-d' }).toArray()
211
+ )
212
+ expect(rows).toHaveLength(0)
213
+ })
214
+
215
+ it('refuses an applied migration whose body has been edited', async () => {
216
+ await boot({
217
+ alias: 'mig-e',
218
+ declare: resource => { resource.migration('0001-drift', driftBody, MigrationStage.Post) }
219
+ })
220
+
221
+ /** A restarted process: the registry is rebuilt from source, the ledger is not. */
222
+ resetDeclarations('mig-e')
223
+
224
+ await expect(boot({
225
+ alias: 'mig-e',
226
+ declare: resource => { resource.migration('0001-drift', driftEdited, MigrationStage.Post) }
227
+ })).rejects.toThrow(MigrationConflict)
228
+ })
229
+
230
+ it('refuses an in-process redeclaration and accepts an identical one', () => {
231
+ const resource = makeMongoResource<Note, MongoResource<Note>>('mig-x')
232
+ resource.migration('0001-drift', driftBody)
233
+
234
+ expect(() => resource.migration('0001-drift', driftBody)).not.toThrow()
235
+ expect(() => resource.migration('0001-drift', driftEdited)).toThrow(MigrationConflict)
236
+ resetDeclarations('mig-x')
237
+ })
238
+
239
+ it('records nothing for a resource that registers no migrations', async () => {
240
+ const mongo = await boot({ alias: 'mig-none' })
241
+
242
+ /** The overwhelmingly common case, and the one that short-circuits before the ledger. */
243
+ expect(await ledger(mongo, 'mig-none')).toHaveLength(0)
244
+ })
245
+
246
+ /**
247
+ * Registration order is irrelevant here, where in Postgres it is not: `ref` resolves a
248
+ * collection *name* from the config and the alias — a pure function — rather than reaching
249
+ * into another resource's initialized handle.
250
+ */
251
+ it('resolves another resource by alias inside a migration', async () => {
252
+ const first = await boot({ alias: 'mig-src' }, { alias: 'mig-g' })
253
+ await (await first.db()).collection('mig-src').insertOne({ title: 'from-source' })
254
+
255
+ const mongo = await boot(
256
+ {
257
+ alias: 'mig-g',
258
+ declare: resource => { resource.migration('0001-copy', copyBody, MigrationStage.Post) }
259
+ },
260
+ { alias: 'mig-src' }
261
+ )
262
+ expect(ran.copy).toBe(1)
263
+
264
+ const copied = await (await mongo.db()).collection('mig-g').find({}, { projection: { _id: 0 } }).toArray()
265
+ expect(copied).toEqual([{ title: 'from-source' }])
266
+ })
267
+ })
@@ -0,0 +1,309 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test'
2
+ import { makeMongoResource, resetDeclarations } from '@owlmeans/mongo-resource'
3
+ import type { MongoResource } from '@owlmeans/mongo-resource'
4
+ import { MisshapedRecord } from '@owlmeans/resource'
5
+ import type { ResourceRecord } from '@owlmeans/resource'
6
+ import { ObjectId } from 'mongodb'
7
+
8
+ import { gate, ledgerOf, makeSuite, raw } from './context.js'
9
+ import type { MongoDbService } from './context.js'
10
+
11
+ interface Owner extends ResourceRecord {
12
+ id?: string
13
+ title: string
14
+ }
15
+
16
+ interface Item extends ResourceRecord {
17
+ id?: string
18
+ title: string
19
+ ownerId?: string | null
20
+ ownerIds?: string[] | null
21
+ key?: string
22
+ }
23
+
24
+ /**
25
+ * Mirrors the real consumers: `profile.userId` lives on a schema-less resource,
26
+ * viable's `projectId` fields live under AJV schemas. Both shapes are exercised.
27
+ */
28
+ const itemSchema = {
29
+ type: 'object',
30
+ properties: {
31
+ title: { type: 'string' },
32
+ ownerId: { type: 'string', nullable: true },
33
+ ownerIds: { type: 'array', items: { type: 'string' }, nullable: true },
34
+ key: { type: 'string', nullable: true }
35
+ },
36
+ required: ['title']
37
+ }
38
+
39
+ interface Built {
40
+ alias: string
41
+ schema?: unknown
42
+ declare?: (resource: MongoResource<Item>) => void
43
+ }
44
+
45
+ const suite = makeSuite('refs')
46
+ const it = gate.skip ? test.skip : test
47
+
48
+ const build = (spec: Built): MongoResource<Item> => {
49
+ const resource = makeMongoResource<Item, MongoResource<Item>>(spec.alias)
50
+ if (spec.schema != null) {
51
+ resource.schema = spec.schema as never
52
+ }
53
+ spec.declare?.(resource)
54
+
55
+ return resource
56
+ }
57
+
58
+ const boot = async (...specs: Built[]) => await suite.boot({ resources: specs.map(build) })
59
+
60
+ describe('@owlmeans/mongo — ObjectId references', () => {
61
+ if (gate.skip) {
62
+ test.skip(gate.reason ?? 'mongo gate closed', () => {})
63
+
64
+ return
65
+ }
66
+
67
+ afterAll(async () => {
68
+ await suite.teardown()
69
+ })
70
+
71
+ it('stores a declared reference as ObjectId and hands back strings', async () => {
72
+ const { context } = await boot(
73
+ { alias: 'ref-owner' },
74
+ { alias: 'ref-item', schema: itemSchema, declare: r => r.reference('ownerId', 'ref-owner') }
75
+ )
76
+
77
+ const owners = context.resource<MongoResource<Owner>>('ref-owner')
78
+ const items = context.resource<MongoResource<Item>>('ref-item')
79
+
80
+ const owner = await owners.create({ title: 'owner' })
81
+ const item = await items.create({ title: 'item', ownerId: owner.id })
82
+
83
+ /** The record round-trips as strings — the API contract is untouched. */
84
+ expect(item.ownerId).toBe(owner.id!)
85
+ expect(typeof item.ownerId).toBe('string')
86
+
87
+ /** The document stores an ObjectId — that's the whole point. */
88
+ const stored = await raw(async client =>
89
+ await client.db(suite.database).collection('ref-item').findOne({ _id: new ObjectId(item.id!) }))
90
+ expect(stored?.ownerId).toBeInstanceOf(ObjectId)
91
+ expect((stored?.ownerId as ObjectId).toString()).toBe(owner.id!)
92
+ })
93
+
94
+ it('finds records through string criteria against the converted field', async () => {
95
+ const { context } = await boot(
96
+ { alias: 'ref-owner' },
97
+ { alias: 'ref-item', schema: itemSchema, declare: r => r.reference('ownerId', 'ref-owner') }
98
+ )
99
+ const owners = context.resource<MongoResource<Owner>>('ref-owner')
100
+ const items = context.resource<MongoResource<Item>>('ref-item')
101
+
102
+ const owner = await owners.create({ title: 'queried' })
103
+ const other = await owners.create({ title: 'other' })
104
+ await items.create({ title: 'a', ownerId: owner.id })
105
+ await items.create({ title: 'b', ownerId: other.id })
106
+
107
+ const listed = await items.list({ ownerId: owner.id! })
108
+ expect(listed.items.map(i => i.title)).toEqual(['a'])
109
+ expect(listed.items[0].ownerId).toBe(owner.id!)
110
+
111
+ const viaIn = await items.list({ ownerId: { $in: [owner.id!, other.id!] } } as never)
112
+ expect(viaIn.items).toHaveLength(2)
113
+
114
+ /** `id` criteria address `_id` — documents never store an `id` field. */
115
+ const byId = await items.list({ id: listed.items[0].id! })
116
+ expect(byId.items).toHaveLength(1)
117
+
118
+ /** A non-id value probes tolerantly: matches nothing rather than throwing. */
119
+ const none = await items.list({ ownerId: 'ext:not-an-id' })
120
+ expect(none.items).toHaveLength(0)
121
+
122
+ const loaded = await items.load(owner.id!, 'ownerId')
123
+ expect(loaded?.title).toBe('a')
124
+ })
125
+
126
+ it('refuses to store a non-id in a declared reference', async () => {
127
+ const { context } = await boot(
128
+ { alias: 'ref-item', schema: itemSchema, declare: r => r.reference('ownerId', 'ref-owner') }
129
+ )
130
+ const items = context.resource<MongoResource<Item>>('ref-item')
131
+
132
+ await expect(items.create({ title: 'bad', ownerId: 'business-key' })).rejects.toThrow(MisshapedRecord)
133
+ })
134
+
135
+ it('converts arrays of ids elementwise', async () => {
136
+ const { context } = await boot(
137
+ { alias: 'ref-owner' },
138
+ { alias: 'ref-multi', schema: itemSchema, declare: r => r.reference('ownerIds', 'ref-owner') }
139
+ )
140
+ const owners = context.resource<MongoResource<Owner>>('ref-owner')
141
+ const items = context.resource<MongoResource<Item>>('ref-multi')
142
+
143
+ const first = await owners.create({ title: 'first' })
144
+ const second = await owners.create({ title: 'second' })
145
+ const item = await items.create({ title: 'multi', ownerIds: [first.id!, second.id!] })
146
+ expect(item.ownerIds).toEqual([first.id!, second.id!])
147
+
148
+ const stored = await raw(async client =>
149
+ await client.db(suite.database).collection('ref-multi').findOne({ _id: new ObjectId(item.id!) }))
150
+ expect((stored?.ownerIds as unknown[]).every(v => v instanceof ObjectId)).toBe(true)
151
+
152
+ /** Multikey criteria hit the converted elements. */
153
+ const listed = await items.list({ ownerIds: second.id! })
154
+ expect(listed.items.map(i => i.title)).toEqual(['multi'])
155
+ })
156
+
157
+ it('indexes a declared reference automatically, unless the resource already does', async () => {
158
+ const { mongo } = await boot(
159
+ { alias: 'ref-item', schema: itemSchema, declare: r => r.reference('ownerId', 'ref-owner') },
160
+ {
161
+ alias: 'ref-owned', schema: itemSchema,
162
+ declare: r => {
163
+ r.index('ownerId', { ownerId: 1 })
164
+ r.reference('ownerId', 'ref-owner')
165
+ }
166
+ }
167
+ )
168
+ const db = await mongo.db()
169
+
170
+ const auto = await db.collection('ref-item').indexes()
171
+ expect(auto.some(index => index.name === 'ref_ownerId')).toBe(true)
172
+
173
+ /** Same key pattern already declared — the automatic one steps aside. */
174
+ const manual = await db.collection('ref-owned').indexes()
175
+ expect(manual.some(index => index.name === 'ownerId')).toBe(true)
176
+ expect(manual.some(index => index.name === 'ref_ownerId')).toBe(false)
177
+ })
178
+
179
+ it('migrates pre-existing string ids and records it in the ledger', async () => {
180
+ const legacyOwner = new ObjectId().toString()
181
+ const { mongo } = await boot({ alias: 'ref-legacy', schema: itemSchema })
182
+ const db = await mongo.db()
183
+ await db.collection('ref-legacy').insertMany([
184
+ { title: 'legacy-a', ownerId: legacyOwner },
185
+ { title: 'legacy-b', ownerId: 'ext:key' },
186
+ { title: 'legacy-c', ownerId: null }
187
+ ])
188
+
189
+ const { context } = await boot(
190
+ { alias: 'ref-legacy', schema: itemSchema, declare: r => r.reference('ownerId', 'ref-owner') }
191
+ )
192
+
193
+ const rows = await ledgerOf(mongo, 'ref-legacy')
194
+ expect(rows.map(row => row.name)).toEqual(['$ref:ownerId@1'])
195
+ expect(rows[0].baseline).toBe(false)
196
+ expect(rows[0].completedAt).not.toBeNull()
197
+
198
+ const docs = await db.collection('ref-legacy').find({}).sort({ title: 1 }).toArray()
199
+ expect(docs[0].ownerId).toBeInstanceOf(ObjectId)
200
+ /** Not a mongo id — left exactly as it was, loudly convertible by hand if ever needed. */
201
+ expect(docs[1].ownerId).toBe('ext:key')
202
+ expect(docs[2].ownerId).toBeNull()
203
+
204
+ /** And the migrated value flows back out as the same string. */
205
+ const items = context.resource<MongoResource<Item>>('ref-legacy')
206
+ const migrated = await items.list({ ownerId: legacyOwner })
207
+ expect(migrated.items.map(i => i.title)).toEqual(['legacy-a'])
208
+ })
209
+
210
+ it('baselines the reference migration on a fresh collection', async () => {
211
+ const { mongo } = await boot(
212
+ { alias: 'ref-fresh', schema: itemSchema, declare: r => r.reference('ownerId', 'ref-owner') }
213
+ )
214
+
215
+ const rows = await ledgerOf(mongo, 'ref-fresh')
216
+ expect(rows.map(row => row.name)).toEqual(['$ref:ownerId@1'])
217
+ expect(rows[0].baseline).toBe(true)
218
+ })
219
+
220
+ it('repairs strings the ledger never saw — the double check', async () => {
221
+ const spec: Built = {
222
+ alias: 'ref-drift', schema: itemSchema, declare: r => r.reference('ownerId', 'ref-owner')
223
+ }
224
+ const { mongo } = await boot(spec)
225
+ const db = await mongo.db()
226
+
227
+ /** A legacy writer sneaks a string in after the migration was recorded as applied. */
228
+ const sneaked = new ObjectId().toString()
229
+ await db.collection('ref-drift').insertOne(
230
+ { title: 'sneaked', ownerId: sneaked } as never,
231
+ { bypassDocumentValidation: true }
232
+ )
233
+
234
+ await boot(spec)
235
+
236
+ const doc = await db.collection('ref-drift').findOne({ title: 'sneaked' })
237
+ expect(doc?.ownerId).toBeInstanceOf(ObjectId)
238
+ expect((doc?.ownerId as ObjectId).toString()).toBe(sneaked)
239
+
240
+ /** Still exactly one ledger row — the repair is the probe's, not a re-run migration. */
241
+ expect(await ledgerOf(mongo, 'ref-drift')).toHaveLength(1)
242
+ })
243
+
244
+ it('keeps working schema-less, the auth identity shape', async () => {
245
+ const { context } = await boot(
246
+ { alias: 'ref-account' },
247
+ { alias: 'ref-profile', declare: r => r.reference('userId' as never, 'ref-account') as never }
248
+ )
249
+ const accounts = context.resource<MongoResource<Owner>>('ref-account')
250
+ const profiles = context.resource<MongoResource<Owner & { userId?: string }>>('ref-profile')
251
+
252
+ const account = await accounts.create({ title: 'account' })
253
+ const profile = await profiles.create({ title: 'profile', userId: account.id })
254
+ expect(profile.userId).toBe(account.id!)
255
+
256
+ const stored = await raw(async client =>
257
+ await client.db(suite.database).collection('ref-profile').findOne({ _id: new ObjectId(profile.id!) }))
258
+ expect(stored?.userId).toBeInstanceOf(ObjectId)
259
+
260
+ const listed = await profiles.list({ userId: account.id! })
261
+ expect(listed.items.map(i => i.title)).toEqual(['profile'])
262
+
263
+ /** The auth fallback shape: a composite key probing the reference matches nothing. */
264
+ const none = await profiles.list({ userId: 'one-time-token:abc' })
265
+ expect(none.items).toHaveLength(0)
266
+ })
267
+
268
+ it('updates through the reference and by the reference field', async () => {
269
+ const { context } = await boot(
270
+ { alias: 'ref-owner' },
271
+ { alias: 'ref-upd', schema: itemSchema, declare: r => r.reference('ownerId', 'ref-owner') }
272
+ )
273
+ const owners = context.resource<MongoResource<Owner>>('ref-owner')
274
+ const items = context.resource<MongoResource<Item>>('ref-upd')
275
+
276
+ const owner = await owners.create({ title: 'owner' })
277
+ const next = await owners.create({ title: 'next' })
278
+ const item = await items.create({ title: 'up', ownerId: owner.id, key: 'k1' })
279
+
280
+ const updated = await items.update({ ...item, ownerId: next.id })
281
+ expect(updated.ownerId).toBe(next.id!)
282
+
283
+ /** Addressing the record BY its reference field converts the lookup value too. */
284
+ const byRef = await items.update({ ...updated, title: 'by-ref' }, 'ownerId')
285
+ expect(byRef.title).toBe('by-ref')
286
+
287
+ const stored = await raw(async client =>
288
+ await client.db(suite.database).collection('ref-upd').findOne({ _id: new ObjectId(item.id!) }))
289
+ expect(stored?.ownerId).toBeInstanceOf(ObjectId)
290
+
291
+ const gone = await items.delete(next.id!, 'ownerId')
292
+ expect(gone?.title).toBe('by-ref')
293
+ })
294
+
295
+ it('survives redeclaration and reboot without conflicts', async () => {
296
+ const spec: Built = {
297
+ alias: 'ref-stable', schema: itemSchema, declare: r => r.reference('ownerId', 'ref-owner')
298
+ }
299
+ await boot(spec)
300
+ const { mongo } = await boot(spec)
301
+
302
+ expect(await ledgerOf(mongo, 'ref-stable')).toHaveLength(1)
303
+
304
+ /** A fresh process: declarations rebuilt from source, ledger untouched. */
305
+ resetDeclarations('ref-stable')
306
+ const { mongo: rebooted } = await boot(spec)
307
+ expect(await ledgerOf(rebooted, 'ref-stable')).toHaveLength(1)
308
+ })
309
+ })
package/tsconfig.json CHANGED
@@ -10,6 +10,7 @@
10
10
  "exclude": [
11
11
  "./dist/**/*",
12
12
  "./build/**/*",
13
+ "./tests/**/*",
13
14
  "./*.ts"
14
15
  ]
15
16
  }