@owlmeans/mongo-resource 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.
- package/README.md +51 -6
- package/agent-meta/instructions/mongo-resource.instructions.md +44 -9
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/mongo-resource/SKILL.md +150 -17
- package/build/consts.d.ts +16 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +16 -0
- package/build/consts.js.map +1 -1
- package/build/declarations.d.ts +11 -0
- package/build/declarations.d.ts.map +1 -0
- package/build/declarations.js +29 -0
- package/build/declarations.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +58 -23
- package/build/resource.js.map +1 -1
- package/build/types.d.ts +53 -2
- package/build/types.d.ts.map +1 -1
- package/build/utils/index.d.ts +2 -0
- package/build/utils/index.d.ts.map +1 -1
- package/build/utils/index.js +2 -0
- package/build/utils/index.js.map +1 -1
- package/build/utils/life-cycle.d.ts +25 -1
- package/build/utils/life-cycle.d.ts.map +1 -1
- package/build/utils/life-cycle.js +89 -11
- package/build/utils/life-cycle.js.map +1 -1
- package/build/utils/migrations.d.ts +24 -0
- package/build/utils/migrations.d.ts.map +1 -0
- package/build/utils/migrations.js +129 -0
- package/build/utils/migrations.js.map +1 -0
- package/build/utils/refs.d.ts +74 -0
- package/build/utils/refs.d.ts.map +1 -0
- package/build/utils/refs.js +197 -0
- package/build/utils/refs.js.map +1 -0
- package/build/utils/schema.d.ts +8 -0
- package/build/utils/schema.d.ts.map +1 -1
- package/build/utils/schema.js +25 -0
- package/build/utils/schema.js.map +1 -1
- package/package.json +5 -5
- package/src/consts.ts +20 -0
- package/src/declarations.ts +42 -0
- package/src/index.ts +4 -1
- package/src/resource.ts +76 -28
- package/src/types.ts +58 -2
- package/src/utils/index.ts +2 -0
- package/src/utils/life-cycle.ts +117 -15
- package/src/utils/migrations.ts +171 -0
- package/src/utils/refs.ts +240 -0
- package/src/utils/schema.ts +32 -0
- package/tests/refs.spec.ts +95 -0
package/src/resource.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { appendContextual, assertContext } from '@owlmeans/context'
|
|
2
2
|
import type { BasicContext, Contextual } from '@owlmeans/context'
|
|
3
3
|
import { DEFAULT_DB_ALIAS, DEFAULT_PAGE_SIZE } from './consts.js'
|
|
4
|
+
import { MigrationStage } from '@owlmeans/resource'
|
|
4
5
|
import type { ListCriteria, ResourceMaker, ResourceRecord } from '@owlmeans/resource'
|
|
5
6
|
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
6
|
-
import type { MongoDbService, MongoResource } from './types.js'
|
|
7
|
+
import type { MongoDbService, MongoReference, MongoRefOptions, MongoResource, MongoTx } from './types.js'
|
|
7
8
|
import { initializeCollection } from './utils/life-cycle.js'
|
|
9
|
+
import { getDeclaration } from './declarations.js'
|
|
8
10
|
import { ObjectId } from 'mongodb'
|
|
9
11
|
import { MisshapedRecord, RecordExists, UnknownRecordError, UnsupportedArgumentError, RecordUpdateFailed, prepareListOptions } from '@owlmeans/resource'
|
|
10
12
|
import type { JSONSchemaType } from 'ajv'
|
|
11
13
|
import { getSchemaSecureFeilds } from './helper.js'
|
|
14
|
+
import {
|
|
15
|
+
demarshalRefs, identityCriteria, makeRefMigration, marshalCriteria, marshalReference,
|
|
16
|
+
refMigrationName
|
|
17
|
+
} from './utils/refs.js'
|
|
12
18
|
|
|
13
19
|
type Config = ServerConfig
|
|
14
20
|
type Context<C extends Config = Config> = ServerContext<C>
|
|
@@ -21,6 +27,18 @@ export const makeMongoResource = <
|
|
|
21
27
|
): T => {
|
|
22
28
|
const location = `mongo-resource:${alias}`
|
|
23
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Live view — references may be declared after the resource is built, and the
|
|
32
|
+
* declarations are module scoped so they survive `reinitializeContext`.
|
|
33
|
+
*/
|
|
34
|
+
const refs = (): Map<string, MongoReference> => getDeclaration(alias).references
|
|
35
|
+
|
|
36
|
+
const demarshal = <Type extends ResourceRecord>(record: Type & { _id?: unknown }): Type => {
|
|
37
|
+
record.id = record._id instanceof ObjectId ? record._id.toString() : record._id as string
|
|
38
|
+
delete record._id
|
|
39
|
+
return demarshalRefs(record, refs())
|
|
40
|
+
}
|
|
41
|
+
|
|
24
42
|
const resource: T = appendContextual<T>(alias, {
|
|
25
43
|
get: async (id, field, opts) => {
|
|
26
44
|
const record = await resource.load(id, field, opts)
|
|
@@ -37,15 +55,13 @@ export const makeMongoResource = <
|
|
|
37
55
|
field = field.field
|
|
38
56
|
}
|
|
39
57
|
field = field ?? '_id'
|
|
40
|
-
const criteria = '_id' === field ? new ObjectId(id) : id
|
|
41
58
|
if (opts?.ttl != null) {
|
|
42
59
|
throw new UnsupportedArgumentError('ttl')
|
|
43
60
|
}
|
|
44
61
|
|
|
45
|
-
const record = await resource.collection.findOne(
|
|
62
|
+
const record = await resource.collection.findOne(identityCriteria(field, id, refs()))
|
|
46
63
|
if (record != null) {
|
|
47
|
-
record
|
|
48
|
-
delete (record as any)._id
|
|
64
|
+
demarshal(record)
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
return record
|
|
@@ -70,16 +86,14 @@ export const makeMongoResource = <
|
|
|
70
86
|
|
|
71
87
|
const original = await resource.get(id, field)
|
|
72
88
|
|
|
73
|
-
const criteria = '_id' === field ? new ObjectId(id) : id
|
|
74
|
-
|
|
75
89
|
const replace = { ...record, _id: new ObjectId(original.id) }
|
|
76
90
|
if (replace.id != null) {
|
|
77
91
|
delete replace.id
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
const result = await resource.collection.replaceOne(
|
|
81
|
-
|
|
82
|
-
_prepareValues(replace, resource.schema as JSONSchemaType<any
|
|
95
|
+
identityCriteria(field, id, refs()),
|
|
96
|
+
_prepareValues(replace, resource.schema as JSONSchemaType<any>, refs())
|
|
83
97
|
)
|
|
84
98
|
if (!result.acknowledged) {
|
|
85
99
|
throw new RecordUpdateFailed(`${field}:${id}`)
|
|
@@ -128,7 +142,7 @@ export const makeMongoResource = <
|
|
|
128
142
|
}
|
|
129
143
|
const result = await resource.collection.insertOne({
|
|
130
144
|
...resource.getDefaults(),
|
|
131
|
-
..._prepareValues(record, resource.schema as JSONSchemaType<any
|
|
145
|
+
..._prepareValues(record, resource.schema as JSONSchemaType<any>, refs())
|
|
132
146
|
})
|
|
133
147
|
|
|
134
148
|
if (!result.acknowledged) {
|
|
@@ -168,9 +182,7 @@ export const makeMongoResource = <
|
|
|
168
182
|
throw new MisshapedRecord('id')
|
|
169
183
|
}
|
|
170
184
|
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
const result = await resource.collection.deleteOne({ [field]: criteria })
|
|
185
|
+
const result = await resource.collection.deleteOne(identityCriteria(field, _id as string, refs()))
|
|
174
186
|
if (!result.acknowledged || result.deletedCount === 0) {
|
|
175
187
|
return null
|
|
176
188
|
}
|
|
@@ -190,7 +202,7 @@ export const makeMongoResource = <
|
|
|
190
202
|
list: async (criteria, opts) => {
|
|
191
203
|
const options = prepareListOptions(DEFAULT_PAGE_SIZE, criteria, opts)
|
|
192
204
|
|
|
193
|
-
criteria = options.criteria
|
|
205
|
+
criteria = marshalCriteria(options.criteria, refs()) ?? {}
|
|
194
206
|
const pager = options.pager ?? {}
|
|
195
207
|
|
|
196
208
|
const size = pager?.size ?? DEFAULT_PAGE_SIZE
|
|
@@ -218,13 +230,7 @@ export const makeMongoResource = <
|
|
|
218
230
|
const items = await cursor.toArray()
|
|
219
231
|
|
|
220
232
|
return {
|
|
221
|
-
pager, items: items.map(item => {
|
|
222
|
-
const _item: R = { ...item } as any
|
|
223
|
-
_item.id = item._id.toString()
|
|
224
|
-
delete (_item as any)._id
|
|
225
|
-
|
|
226
|
-
return _item
|
|
227
|
-
})
|
|
233
|
+
pager, items: items.map(item => demarshal({ ...item } as unknown as R))
|
|
228
234
|
}
|
|
229
235
|
},
|
|
230
236
|
|
|
@@ -270,7 +276,35 @@ export const makeMongoResource = <
|
|
|
270
276
|
resource.indexes = resource.indexes ?? []
|
|
271
277
|
resource.indexes.push({ name, index, options })
|
|
272
278
|
return resource
|
|
273
|
-
}
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* `migration` and `reference` are `this`-returning in the interface, which an object
|
|
283
|
+
* literal can't express — hence the member level casts: the implementations return
|
|
284
|
+
* the closed over `resource`, which is that very object.
|
|
285
|
+
*/
|
|
286
|
+
migration: ((name: string, apply: (tx: MongoTx) => Promise<void>, stage?: MigrationStage) => {
|
|
287
|
+
getDeclaration(alias).migrations.register(name, apply, stage)
|
|
288
|
+
return resource
|
|
289
|
+
}) as T['migration'],
|
|
290
|
+
|
|
291
|
+
migrations: () => getDeclaration(alias).migrations,
|
|
292
|
+
|
|
293
|
+
reference: ((field: string, opts?: string | MongoRefOptions) => {
|
|
294
|
+
const declaration = getDeclaration(alias)
|
|
295
|
+
const options = typeof opts === 'string' ? { resource: opts } : opts ?? {}
|
|
296
|
+
declaration.references.set(field, { field, resource: options.resource, noIndex: options.noIndex })
|
|
297
|
+
/**
|
|
298
|
+
* The system migration that converts the field's pre-existing string ids. Registered
|
|
299
|
+
* here rather than at init so it precedes migrations the app declares after its
|
|
300
|
+
* `reference()` calls — the field's type contract is the foundation those build on.
|
|
301
|
+
*/
|
|
302
|
+
declaration.migrations.register(refMigrationName(field), makeRefMigration(field), MigrationStage.Pre)
|
|
303
|
+
|
|
304
|
+
return resource
|
|
305
|
+
}) as T['reference'],
|
|
306
|
+
|
|
307
|
+
references: () => [...getDeclaration(alias).references.values()]
|
|
274
308
|
} as Partial<T>)
|
|
275
309
|
|
|
276
310
|
// Explicit collection name override (decoupled from the registration alias, which may
|
|
@@ -286,7 +320,9 @@ export const makeMongoResource = <
|
|
|
286
320
|
await mongo.ready()
|
|
287
321
|
const db = await mongo.db(dbAlias)
|
|
288
322
|
const config = mongo.config(dbAlias)
|
|
289
|
-
resource.collection = await initializeCollection(
|
|
323
|
+
resource.collection = await initializeCollection(
|
|
324
|
+
db, config, resource as unknown as MongoResource<ResourceRecord>, context
|
|
325
|
+
)
|
|
290
326
|
}
|
|
291
327
|
|
|
292
328
|
resource.reinitializeContext = <Type extends Contextual>(context: BasicContext<Config>) => {
|
|
@@ -301,15 +337,27 @@ export const makeMongoResource = <
|
|
|
301
337
|
return resource
|
|
302
338
|
}
|
|
303
339
|
|
|
304
|
-
const _prepareValues = <T extends ResourceRecord>(
|
|
340
|
+
const _prepareValues = <T extends ResourceRecord>(
|
|
341
|
+
obj: T, schema?: JSONSchemaType<T>, refs?: Map<string, MongoReference>
|
|
342
|
+
): T => {
|
|
343
|
+
/**
|
|
344
|
+
* Declared references convert independently of the schema — the schema is optional,
|
|
345
|
+
* and where it exists it declares these fields as strings, whose coercion below would
|
|
346
|
+
* undo the conversion.
|
|
347
|
+
*/
|
|
348
|
+
if (refs != null && refs.size > 0) {
|
|
349
|
+
obj = Object.fromEntries(Object.entries(obj).map(([key, value]) =>
|
|
350
|
+
refs.has(key) ? [key, marshalReference(key, value)] : [key, value]
|
|
351
|
+
)) as T
|
|
352
|
+
}
|
|
305
353
|
// @TODO Validate keys from additional properties in the root
|
|
306
354
|
return schema != null ? Object.fromEntries(Object.entries(obj).map(([key, value]) => {
|
|
307
|
-
|
|
308
|
-
// How to properly transform them?
|
|
309
|
-
// What if _id isn't an Object Id?
|
|
310
|
-
if (key === '_id') {
|
|
355
|
+
if (key === '_id' && !(value instanceof ObjectId)) {
|
|
311
356
|
return [key, new ObjectId(value as string)]
|
|
312
357
|
}
|
|
358
|
+
if (key === '_id' || refs?.has(key)) {
|
|
359
|
+
return [key, value]
|
|
360
|
+
}
|
|
313
361
|
// A null/undefined value has nothing to coerce — pass it through for any declared
|
|
314
362
|
// type. Without this guard the object-map and array branches below call
|
|
315
363
|
// `Object.entries`/`.map` on `undefined` and throw (and `new Date(null)` would
|
package/src/types.ts
CHANGED
|
@@ -1,8 +1,50 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Resource, ResourceRecord, ResourceDbService, DbLocker, ResourceLocker, MigratableResource
|
|
3
|
+
} from '@owlmeans/resource'
|
|
2
4
|
import type { Collection, CreateIndexesOptions, Db, IndexSpecification, MongoClient } from 'mongodb'
|
|
3
5
|
import type { AnySchema } from 'ajv'
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
/**
|
|
8
|
+
* What a mongo migration is handed.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately not a session or transaction: multi-document transactions require a replica
|
|
11
|
+
* set, and a standalone `mongod` — the usual development and CI target — rejects them
|
|
12
|
+
* outright. A migration therefore has to be written to tolerate being interrupted partway,
|
|
13
|
+
* which in practice means idempotent updates rather than read-then-write.
|
|
14
|
+
*/
|
|
15
|
+
export interface MongoTx {
|
|
16
|
+
db: Db
|
|
17
|
+
/** The owning resource's collection. */
|
|
18
|
+
collection: Collection
|
|
19
|
+
/** Another registered mongo resource's collection; omit the alias for the owning one. */
|
|
20
|
+
use: (alias?: string) => Collection
|
|
21
|
+
/** A collection *name*, for `$lookup.from` and other stages that take one rather than a handle. */
|
|
22
|
+
ref: (alias?: string) => string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A declared ObjectId reference — a record field that stores another record's id.
|
|
27
|
+
*
|
|
28
|
+
* The resource converts the field between the string ids records carry and the `ObjectId`
|
|
29
|
+
* the collection stores, exactly the way it already does for `_id`: strings in records and
|
|
30
|
+
* criteria, `ObjectId` on the wire. Declaring a reference also gives the field a mongo
|
|
31
|
+
* level index and registers the system migration that converts pre-existing string values.
|
|
32
|
+
*/
|
|
33
|
+
export interface MongoReference {
|
|
34
|
+
/** Top level record property holding the reference (a single id or an array of ids). */
|
|
35
|
+
field: string
|
|
36
|
+
/** Alias of the referenced resource. Informational — conversion never resolves it. */
|
|
37
|
+
resource?: string
|
|
38
|
+
/** Skip the automatic `{ [field]: 1 }` index. */
|
|
39
|
+
noIndex?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MongoRefOptions {
|
|
43
|
+
resource?: string
|
|
44
|
+
noIndex?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MongoResource<T extends ResourceRecord> extends Resource<T>, ResourceLocker<T>, MigratableResource<MongoTx> {
|
|
6
48
|
name?: string
|
|
7
49
|
schema?: AnySchema
|
|
8
50
|
indexes?: Array<{ name: string, index: IndexSpecification, options?: CreateIndexesOptions }>
|
|
@@ -10,6 +52,20 @@ export interface MongoResource<T extends ResourceRecord> extends Resource<T>, Re
|
|
|
10
52
|
db: () => Promise<Db>
|
|
11
53
|
client: () => Promise<MongoClient>
|
|
12
54
|
index: <Type extends MongoResource<T>>(name: string, index: IndexSpecification, options?: CreateIndexesOptions) => Type
|
|
55
|
+
/**
|
|
56
|
+
* Declare that a field stores another record's id.
|
|
57
|
+
*
|
|
58
|
+
* Chainable and idempotent like {@link MigratableResource.migration}, and stored the same
|
|
59
|
+
* way — per alias at module scope — because losing the declaration to a context rebuild
|
|
60
|
+
* would silently stop the string/ObjectId conversion for the field.
|
|
61
|
+
*
|
|
62
|
+
* Declare only fields whose values really are mongo ids (assigned from another record's
|
|
63
|
+
* `id`). Composite keys, external provider ids, DIDs and business slugs must stay
|
|
64
|
+
* strings — converting them corrupts the collection.
|
|
65
|
+
*/
|
|
66
|
+
reference: (field: string, opts?: string | MongoRefOptions) => this
|
|
67
|
+
/** The declared references of this alias. */
|
|
68
|
+
references: () => MongoReference[]
|
|
13
69
|
getDefaults: () => Partial<T>
|
|
14
70
|
}
|
|
15
71
|
|
package/src/utils/index.ts
CHANGED
package/src/utils/life-cycle.ts
CHANGED
|
@@ -1,31 +1,131 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type {
|
|
3
|
-
import type {
|
|
1
|
+
import { MigrationStage, runMigrations } from '@owlmeans/resource'
|
|
2
|
+
import type { DbConfig, MigrationReport, ResourceRecord } from '@owlmeans/resource'
|
|
3
|
+
import type { BasicContext } from '@owlmeans/context'
|
|
4
|
+
import type { MongoReference, MongoResource } from '../types.js'
|
|
5
|
+
import type { Db, Collection, Document, IndexSpecification } from 'mongodb'
|
|
6
|
+
import { DEF_MIGRATIONS_COLLECTION } from '../consts.js'
|
|
7
|
+
import { getDeclaration } from '../declarations.js'
|
|
4
8
|
import { mongoCollectionName } from './name.js'
|
|
5
|
-
import { schemaToMongoSchema } from './schema.js'
|
|
9
|
+
import { applyReferenceTypes, schemaToMongoSchema } from './schema.js'
|
|
6
10
|
import { updateIndexes } from './indexes.js'
|
|
11
|
+
import { makeMongoMigrationStore, makeMongoTx } from './migrations.js'
|
|
12
|
+
import { reconcileReferences } from './refs.js'
|
|
7
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Bring a resource's collection to the shape its schema declares.
|
|
16
|
+
*
|
|
17
|
+
* Order is deliberate, and matches the Postgres counterpart:
|
|
18
|
+
*
|
|
19
|
+
* 1. probe for the collection
|
|
20
|
+
* 2. absent → *baseline* every registered migration; present → run the `pre` ones
|
|
21
|
+
* (including the system `$ref:` migrations that convert declared references)
|
|
22
|
+
* 3. create the collection, or update its validator and indexes
|
|
23
|
+
* 4. present → run the `post` migrations
|
|
24
|
+
* 5. reconcile declared references — the second half of their double check: the ledger
|
|
25
|
+
* said whether the `$ref:` migration ran, this probes the collection itself and
|
|
26
|
+
* converts whatever strings still slipped through
|
|
27
|
+
*
|
|
28
|
+
* Step 2 is what stops the two mechanisms colliding. A `pre` migration runs before the
|
|
29
|
+
* validator is tightened, so it can reshape documents the new validator would reject. On a
|
|
30
|
+
* collection this call just created there is nothing to reshape — replaying a historical
|
|
31
|
+
* migration against a collection born in its final shape would at best scan for nothing —
|
|
32
|
+
* so the migrations are recorded as satisfied instead of run.
|
|
33
|
+
*
|
|
34
|
+
* `context` is optional so the existing three-argument call keeps working; without it a
|
|
35
|
+
* migration's `use`/`ref` can only address the owning resource.
|
|
36
|
+
*/
|
|
8
37
|
export const initializeCollection = async (
|
|
9
|
-
db: Db, config: DbConfig, resource: MongoResource<ResourceRecord
|
|
38
|
+
db: Db, config: DbConfig, resource: MongoResource<ResourceRecord>,
|
|
39
|
+
context?: BasicContext<any>
|
|
10
40
|
): Promise<Collection> => {
|
|
11
|
-
let _collection: Collection
|
|
12
|
-
|
|
13
41
|
const name = mongoCollectionName(config, resource)
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
42
|
+
const fresh = !await db.listCollections({ name }).hasNext()
|
|
43
|
+
|
|
44
|
+
appendReferenceIndexes(resource)
|
|
45
|
+
|
|
46
|
+
const migrate = prepareMigrations(db, config, resource, name, context)
|
|
47
|
+
await migrate(fresh ? { baseline: true } : { stage: MigrationStage.Pre })
|
|
48
|
+
|
|
49
|
+
const collection = fresh
|
|
50
|
+
? await createCollection(db, name, resource)
|
|
51
|
+
: await updateCollection(db, name, resource)
|
|
52
|
+
|
|
53
|
+
if (!fresh) {
|
|
54
|
+
await migrate({ stage: MigrationStage.Post })
|
|
55
|
+
await reconcileReferences(collection, referencesOf(resource), resource.alias)
|
|
19
56
|
}
|
|
20
57
|
|
|
21
|
-
return
|
|
58
|
+
return collection
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Tolerates hand-built resource objects that predate the reference capability. */
|
|
62
|
+
const referencesOf = (resource: MongoResource<ResourceRecord>): MongoReference[] =>
|
|
63
|
+
resource.references?.() ?? []
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A declared reference is indexed at the mongo level — that's part of its contract. The
|
|
67
|
+
* index is appended unless the resource already declares one with the same key pattern
|
|
68
|
+
* (mongo refuses two indexes over identical keys, and the existing one — possibly
|
|
69
|
+
* unique — wins).
|
|
70
|
+
*/
|
|
71
|
+
const appendReferenceIndexes = (resource: MongoResource<ResourceRecord>): void => {
|
|
72
|
+
for (const ref of referencesOf(resource)) {
|
|
73
|
+
if (ref.noIndex === true) {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
resource.indexes = resource.indexes ?? []
|
|
77
|
+
const spec = JSON.stringify({ [ref.field]: 1 })
|
|
78
|
+
const present = resource.indexes.some(index =>
|
|
79
|
+
JSON.stringify(index.index as IndexSpecification) === spec || index.name === `ref_${ref.field}`
|
|
80
|
+
)
|
|
81
|
+
if (!present) {
|
|
82
|
+
resource.indexes.push({ name: `ref_${ref.field}`, index: { [ref.field]: 1 } })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Bind the migration runner to this resource's database, or hand back a no-op when nothing
|
|
89
|
+
* is registered — the overwhelmingly common case, and one that shouldn't pay for a ledger.
|
|
90
|
+
*/
|
|
91
|
+
const prepareMigrations = (
|
|
92
|
+
db: Db, config: DbConfig, resource: MongoResource<ResourceRecord>, name: string,
|
|
93
|
+
context?: BasicContext<any>
|
|
94
|
+
): (opts: { stage?: MigrationStage, baseline?: boolean }) => Promise<MigrationReport | null> => {
|
|
95
|
+
const registry = getDeclaration(resource.alias).migrations
|
|
96
|
+
if (registry.list().length < 1) {
|
|
97
|
+
return async () => null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ledgerName = (config.meta as { migrationsCollection?: string } | undefined)?.migrationsCollection
|
|
101
|
+
?? DEF_MIGRATIONS_COLLECTION
|
|
102
|
+
/**
|
|
103
|
+
* `db.collection()` never round-trips, so taking the handle before the collection exists
|
|
104
|
+
* is safe — and on the fresh path the transaction is only ever baselined, never used.
|
|
105
|
+
*/
|
|
106
|
+
const tx = makeMongoTx(db, db.collection(name), config, context as BasicContext<any>, resource.alias)
|
|
107
|
+
const store = makeMongoMigrationStore(db, tx, ledgerName)
|
|
108
|
+
|
|
109
|
+
return async opts => {
|
|
110
|
+
const report = await runMigrations(resource.alias, registry, store, opts)
|
|
111
|
+
if (report.applied.length > 0) {
|
|
112
|
+
console.log(
|
|
113
|
+
`@owlmeans/mongo-resource: ${name} applied ${report.stage} migrations —`
|
|
114
|
+
+ ` ${report.applied.join(', ')}`
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return report
|
|
119
|
+
}
|
|
22
120
|
}
|
|
23
121
|
|
|
24
122
|
export const createCollection = async (db: Db, name: string, resource: MongoResource<ResourceRecord>): Promise<Collection> => {
|
|
25
123
|
const collection = await db.createCollection(name, {
|
|
26
124
|
...(resource.schema != null ? {
|
|
27
125
|
validator: {
|
|
28
|
-
$jsonSchema: patchJsonSchema(
|
|
126
|
+
$jsonSchema: patchJsonSchema(applyReferenceTypes(
|
|
127
|
+
schemaToMongoSchema(resource.schema), resource.schema, resource.references()
|
|
128
|
+
))
|
|
29
129
|
}
|
|
30
130
|
} : {})
|
|
31
131
|
})
|
|
@@ -43,7 +143,9 @@ export const createCollection = async (db: Db, name: string, resource: MongoReso
|
|
|
43
143
|
|
|
44
144
|
export const updateCollection = async (db: Db, name: string, resource: MongoResource<ResourceRecord>): Promise<Collection> => {
|
|
45
145
|
if (resource.schema != null) {
|
|
46
|
-
const $jsonSchema = patchJsonSchema(
|
|
146
|
+
const $jsonSchema = patchJsonSchema(applyReferenceTypes(
|
|
147
|
+
schemaToMongoSchema(resource.schema), resource.schema, resource.references()
|
|
148
|
+
))
|
|
47
149
|
await db.command({ collMod: name, validator: { $jsonSchema } })
|
|
48
150
|
}
|
|
49
151
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { BasicContext } from '@owlmeans/context'
|
|
2
|
+
import type { DbConfig, Migration, MigrationStage, MigrationStore, ResourceRecord } from '@owlmeans/resource'
|
|
3
|
+
import type { Collection, Db } from 'mongodb'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEF_MIGRATION_POLL, DEF_MIGRATION_WAIT, DEF_MIGRATIONS_COLLECTION, MONGO_DUPLICATE_KEY
|
|
7
|
+
} from '../consts.js'
|
|
8
|
+
import type { MongoResource, MongoTx } from '../types.js'
|
|
9
|
+
import { mongoCollectionName } from './name.js'
|
|
10
|
+
|
|
11
|
+
interface LedgerRecord {
|
|
12
|
+
alias: string
|
|
13
|
+
name: string
|
|
14
|
+
stage: MigrationStage
|
|
15
|
+
checksum: string | null
|
|
16
|
+
baseline: boolean
|
|
17
|
+
startedAt: Date
|
|
18
|
+
/** `null` while a replica is running it — the claim, not the completion. */
|
|
19
|
+
completedAt: Date | null
|
|
20
|
+
durationMs: number | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const isDuplicateKey = (error: unknown): boolean =>
|
|
24
|
+
(error as { code?: number } | null)?.code === MONGO_DUPLICATE_KEY
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A transaction façade over the database. Alias resolution is injected so migrations can
|
|
28
|
+
* address other resources without importing the context.
|
|
29
|
+
*
|
|
30
|
+
* Names are resolved through {@link mongoCollectionName}, not through the other resource's
|
|
31
|
+
* live `collection` handle, because at migration time that resource may not have
|
|
32
|
+
* initialized yet — registration order is not dependency order. The name is a pure
|
|
33
|
+
* function of the config and the alias, so it's knowable either way.
|
|
34
|
+
*/
|
|
35
|
+
export const makeMongoTx = (
|
|
36
|
+
db: Db, collection: Collection, config: DbConfig, context: BasicContext<any>, self: string
|
|
37
|
+
): MongoTx => {
|
|
38
|
+
const ref = (alias?: string): string => {
|
|
39
|
+
if (alias == null || alias === self) {
|
|
40
|
+
return collection.collectionName
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return mongoCollectionName(config, context.resource<MongoResource<ResourceRecord>>(alias))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
db,
|
|
48
|
+
collection,
|
|
49
|
+
ref,
|
|
50
|
+
use: alias => alias == null || alias === self ? collection : db.collection(ref(alias))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const waitForClaim = async (
|
|
55
|
+
ledger: Collection<LedgerRecord>, alias: string, name: string
|
|
56
|
+
): Promise<void> => {
|
|
57
|
+
const deadline = Date.now() + DEF_MIGRATION_WAIT
|
|
58
|
+
while (Date.now() < deadline) {
|
|
59
|
+
const row = await ledger.findOne({ alias, name })
|
|
60
|
+
if (row == null) {
|
|
61
|
+
/** The claim was withdrawn — the other replica's migration failed, and so must this boot. */
|
|
62
|
+
throw new Error(`${alias}/${name}: abandoned by the replica that claimed it`)
|
|
63
|
+
}
|
|
64
|
+
if (row.completedAt != null) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
await new Promise(resolve => setTimeout(resolve, DEF_MIGRATION_POLL))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
throw new Error(`${alias}/${name}: still running elsewhere after ${DEF_MIGRATION_WAIT}ms`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Migration ledger, one collection per database.
|
|
75
|
+
*
|
|
76
|
+
* Where the Postgres store commits the ledger row inside the migration's own transaction,
|
|
77
|
+
* this one can't — see {@link MongoTx}. It uses claim-then-complete instead: the unique
|
|
78
|
+
* index on `(alias, name)` is the mutual exclusion primitive, so a replica that loses the
|
|
79
|
+
* race to insert waits for the winner rather than running the same migration twice.
|
|
80
|
+
*/
|
|
81
|
+
export const makeMongoMigrationStore = (
|
|
82
|
+
db: Db, tx: MongoTx, name: string = DEF_MIGRATIONS_COLLECTION
|
|
83
|
+
): MigrationStore<MongoTx> => {
|
|
84
|
+
const ledger = (): Collection<LedgerRecord> => db.collection<LedgerRecord>(name)
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
ensure: async () => {
|
|
88
|
+
await ledger().createIndex({ alias: 1, name: 1 }, { name: 'alias_name_unique', unique: true })
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
applied: async alias => {
|
|
92
|
+
/**
|
|
93
|
+
* Completed rows only. A claim still holding a null `completedAt` belongs to a replica
|
|
94
|
+
* running right now, and treating it as applied would let this process carry on against
|
|
95
|
+
* a half-migrated database.
|
|
96
|
+
*/
|
|
97
|
+
const rows = await ledger().find({ alias, completedAt: { $ne: null } }).toArray()
|
|
98
|
+
|
|
99
|
+
return rows.reduce<Record<string, string | null>>((applied, row) => {
|
|
100
|
+
applied[row.name] = row.checksum
|
|
101
|
+
|
|
102
|
+
return applied
|
|
103
|
+
}, {})
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
baseline: async (alias, migrations) => {
|
|
107
|
+
const now = new Date()
|
|
108
|
+
/**
|
|
109
|
+
* `$setOnInsert` upserts rather than plain inserts: two replicas creating the same
|
|
110
|
+
* collection at once both baseline, and the second must be a no-op, not a crash.
|
|
111
|
+
*/
|
|
112
|
+
await ledger().bulkWrite(migrations.map(migration => ({
|
|
113
|
+
updateOne: {
|
|
114
|
+
filter: { alias, name: migration.name },
|
|
115
|
+
update: {
|
|
116
|
+
$setOnInsert: {
|
|
117
|
+
alias,
|
|
118
|
+
name: migration.name,
|
|
119
|
+
stage: migration.stage,
|
|
120
|
+
checksum: migration.checksum,
|
|
121
|
+
baseline: true,
|
|
122
|
+
startedAt: now,
|
|
123
|
+
completedAt: now,
|
|
124
|
+
durationMs: 0
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
upsert: true
|
|
128
|
+
}
|
|
129
|
+
})), { ordered: false })
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
run: async (alias: string, migration: Migration<MongoTx>) => {
|
|
133
|
+
const started = Date.now()
|
|
134
|
+
try {
|
|
135
|
+
await ledger().insertOne({
|
|
136
|
+
alias,
|
|
137
|
+
name: migration.name,
|
|
138
|
+
stage: migration.stage,
|
|
139
|
+
checksum: migration.checksum,
|
|
140
|
+
baseline: false,
|
|
141
|
+
startedAt: new Date(started),
|
|
142
|
+
completedAt: null,
|
|
143
|
+
durationMs: null
|
|
144
|
+
})
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (!isDuplicateKey(error)) {
|
|
147
|
+
throw error
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return await waitForClaim(ledger(), alias, migration.name)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await migration.apply(tx)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
/**
|
|
157
|
+
* Withdraw the claim so the next boot retries. Postgres rolls its ledger row back
|
|
158
|
+
* with the migration itself; without a transaction the claim has to be released by
|
|
159
|
+
* hand, and leaving it would wedge every future boot on a migration that never ran.
|
|
160
|
+
*/
|
|
161
|
+
await ledger().deleteOne({ alias, name: migration.name }).catch(() => undefined)
|
|
162
|
+
throw error
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await ledger().updateOne(
|
|
166
|
+
{ alias, name: migration.name },
|
|
167
|
+
{ $set: { completedAt: new Date(), durationMs: Date.now() - started } }
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|