@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.
Files changed (53) hide show
  1. package/README.md +51 -6
  2. package/agent-meta/instructions/mongo-resource.instructions.md +44 -9
  3. package/agent-meta/manifest.json +2 -2
  4. package/agent-meta/skills/mongo-resource/SKILL.md +150 -17
  5. package/build/consts.d.ts +16 -0
  6. package/build/consts.d.ts.map +1 -1
  7. package/build/consts.js +16 -0
  8. package/build/consts.js.map +1 -1
  9. package/build/declarations.d.ts +11 -0
  10. package/build/declarations.d.ts.map +1 -0
  11. package/build/declarations.js +29 -0
  12. package/build/declarations.js.map +1 -0
  13. package/build/index.d.ts +3 -0
  14. package/build/index.d.ts.map +1 -1
  15. package/build/index.js +3 -0
  16. package/build/index.js.map +1 -1
  17. package/build/resource.d.ts.map +1 -1
  18. package/build/resource.js +58 -23
  19. package/build/resource.js.map +1 -1
  20. package/build/types.d.ts +53 -2
  21. package/build/types.d.ts.map +1 -1
  22. package/build/utils/index.d.ts +2 -0
  23. package/build/utils/index.d.ts.map +1 -1
  24. package/build/utils/index.js +2 -0
  25. package/build/utils/index.js.map +1 -1
  26. package/build/utils/life-cycle.d.ts +25 -1
  27. package/build/utils/life-cycle.d.ts.map +1 -1
  28. package/build/utils/life-cycle.js +89 -11
  29. package/build/utils/life-cycle.js.map +1 -1
  30. package/build/utils/migrations.d.ts +24 -0
  31. package/build/utils/migrations.d.ts.map +1 -0
  32. package/build/utils/migrations.js +129 -0
  33. package/build/utils/migrations.js.map +1 -0
  34. package/build/utils/refs.d.ts +74 -0
  35. package/build/utils/refs.d.ts.map +1 -0
  36. package/build/utils/refs.js +197 -0
  37. package/build/utils/refs.js.map +1 -0
  38. package/build/utils/schema.d.ts +8 -0
  39. package/build/utils/schema.d.ts.map +1 -1
  40. package/build/utils/schema.js +25 -0
  41. package/build/utils/schema.js.map +1 -1
  42. package/package.json +5 -5
  43. package/src/consts.ts +20 -0
  44. package/src/declarations.ts +42 -0
  45. package/src/index.ts +4 -1
  46. package/src/resource.ts +76 -28
  47. package/src/types.ts +58 -2
  48. package/src/utils/index.ts +2 -0
  49. package/src/utils/life-cycle.ts +117 -15
  50. package/src/utils/migrations.ts +171 -0
  51. package/src/utils/refs.ts +240 -0
  52. package/src/utils/schema.ts +32 -0
  53. 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({ [field]: criteria })
62
+ const record = await resource.collection.findOne(identityCriteria(field, id, refs()))
46
63
  if (record != null) {
47
- record.id = record._id instanceof ObjectId ? record._id.toString() : record._id
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
- { [field]: criteria },
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 criteria = '_id' === field ? new ObjectId(_id) : _id
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(db, config, resource as unknown as MongoResource<ResourceRecord>)
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>(obj: T, schema?: JSONSchemaType<T>): T => {
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
- // @TODO What if we reference another table by object id in another field?
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 { Resource, ResourceRecord, ResourceDbService, DbLocker, ResourceLocker } from '@owlmeans/resource'
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
- export interface MongoResource<T extends ResourceRecord> extends Resource<T>, ResourceLocker<T> {
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
 
@@ -3,3 +3,5 @@ export * from './indexes.js'
3
3
  export * from './schema.js'
4
4
  export * from './name.js'
5
5
  export * from './life-cycle.js'
6
+ export * from './migrations.js'
7
+ export * from './refs.js'
@@ -1,31 +1,131 @@
1
- import type { DbConfig, ResourceRecord } from '@owlmeans/resource'
2
- import type { MongoResource } from '../types.js'
3
- import type { Db, Collection, Document } from 'mongodb'
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 cursor = db.listCollections({ name })
15
- if (!await cursor.hasNext()) {
16
- _collection = await createCollection(db, name, resource)
17
- } else {
18
- _collection = await updateCollection(db, name, resource)
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 _collection
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(schemaToMongoSchema(resource.schema))
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(schemaToMongoSchema(resource.schema))
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
+ }