@owlmeans/mongo 0.1.15 → 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.
- package/agent-meta/instructions/mongo.instructions.md +22 -8
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/mongo/SKILL.md +38 -12
- package/package.json +10 -6
- package/tests/context.ts +149 -0
- package/tests/migration.spec.ts +267 -0
- package/tests/references.spec.ts +309 -0
- package/tsconfig.json +1 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "How to use @owlmeans/mongo — MongoDB
|
|
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.
|
|
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
|
-
| `
|
|
17
|
-
| `
|
|
18
|
-
|
|
|
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 {
|
|
24
|
-
context
|
|
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/
|
|
43
|
+
- `@owlmeans/resource`, `@owlmeans/mongo-resource` (service contract), `@owlmeans/basic-keys`, peer `mongodb`
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"package": "@owlmeans/mongo",
|
|
4
|
-
"version": "0.1.
|
|
5
|
-
"generatedAt": "2026-08-
|
|
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
|
|
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.
|
|
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
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
|
|
|
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 {
|
|
25
|
-
context
|
|
24
|
+
import { appendMongo } from '@owlmeans/mongo'
|
|
25
|
+
appendMongo(context)
|
|
26
26
|
|
|
27
|
-
// Connection settings come from cfg.
|
|
27
|
+
// Connection settings come from cfg.dbs
|
|
28
28
|
cfg.dbs = [{
|
|
29
29
|
service: 'mongo',
|
|
30
|
-
|
|
31
|
-
|
|
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/
|
|
38
|
-
- `
|
|
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.
|
|
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.
|
|
32
|
-
"@owlmeans/context": "^0.1.
|
|
33
|
-
"@owlmeans/mongo-resource": "^0.1.
|
|
34
|
-
"@owlmeans/
|
|
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
|
},
|
package/tests/context.ts
ADDED
|
@@ -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
|
+
})
|