@owlmeans/mongo-resource 0.1.15 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -10
- package/agent-meta/manifest.json +4 -11
- 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/agent-meta/instructions/mongo-resource.instructions.md +0 -30
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
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { MisshapedRecord } from '@owlmeans/resource'
|
|
2
|
+
import type { ListCriteria } from '@owlmeans/resource'
|
|
3
|
+
import { ObjectId } from 'mongodb'
|
|
4
|
+
import type { Collection, Document } from 'mongodb'
|
|
5
|
+
|
|
6
|
+
import type { MongoReference, MongoTx } from '../types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The only shape a stored reference is converted from. Deliberately stricter than
|
|
10
|
+
* `ObjectId.isValid`, which also accepts any 12 character string and would silently
|
|
11
|
+
* swallow short business keys.
|
|
12
|
+
*/
|
|
13
|
+
const HEX24 = /^[0-9a-fA-F]{24}$/
|
|
14
|
+
|
|
15
|
+
export const isObjectIdHex = (value: unknown): value is string =>
|
|
16
|
+
typeof value === 'string' && HEX24.test(value)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Write side of a declared reference: the string id a record carries becomes the
|
|
20
|
+
* `ObjectId` the collection stores. Arrays convert elementwise.
|
|
21
|
+
*
|
|
22
|
+
* Strict on purpose — a declared reference holding something that is not a mongo id is
|
|
23
|
+
* either a mis-declared field (should never have been a reference) or a bug at the call
|
|
24
|
+
* site, and storing it as a string would silently reintroduce the mixed type state this
|
|
25
|
+
* mechanism exists to remove.
|
|
26
|
+
*
|
|
27
|
+
* @throws {MisshapedRecord}
|
|
28
|
+
*/
|
|
29
|
+
export const marshalReference = (field: string, value: unknown): unknown => {
|
|
30
|
+
if (value == null) {
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
if (value instanceof ObjectId) {
|
|
34
|
+
return value
|
|
35
|
+
}
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
return value.map(item => marshalReference(field, item))
|
|
38
|
+
}
|
|
39
|
+
if (isObjectIdHex(value)) {
|
|
40
|
+
return new ObjectId(value)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
throw new MisshapedRecord(`ref:${field}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read side: `ObjectId` back to the string records carry. Tolerates not yet migrated strings. */
|
|
47
|
+
export const demarshalReference = (value: unknown): unknown => {
|
|
48
|
+
if (value instanceof ObjectId) {
|
|
49
|
+
return value.toString()
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(value)) {
|
|
52
|
+
return value.map(demarshalReference)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return value
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Convert every declared reference of a fetched document back to string ids, in place. */
|
|
59
|
+
export const demarshalRefs = <T extends {}>(record: T, refs: Map<string, MongoReference>): T => {
|
|
60
|
+
if (refs.size < 1) {
|
|
61
|
+
return record
|
|
62
|
+
}
|
|
63
|
+
for (const field of refs.keys()) {
|
|
64
|
+
const value = (record as Document)[field]
|
|
65
|
+
if (value != null) {
|
|
66
|
+
(record as Document)[field] = demarshalReference(value)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return record
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Operators whose operand is never an id — a 24 hex string under `$regex` is a pattern,
|
|
75
|
+
* not a reference.
|
|
76
|
+
*/
|
|
77
|
+
const OPAQUE_OPERATORS = ['$regex', '$options', '$type', '$size', '$mod', '$exists', '$where']
|
|
78
|
+
|
|
79
|
+
const LOGICAL_OPERATORS = ['$and', '$or', '$nor']
|
|
80
|
+
|
|
81
|
+
const marshalCriteriaValue = (value: unknown): unknown => {
|
|
82
|
+
if (typeof value === 'string') {
|
|
83
|
+
return isObjectIdHex(value) ? new ObjectId(value) : value
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
return value.map(marshalCriteriaValue)
|
|
87
|
+
}
|
|
88
|
+
if (value != null && typeof value === 'object' && !(value instanceof ObjectId) && !(value instanceof Date)) {
|
|
89
|
+
return Object.fromEntries(Object.entries(value).map(([operator, operand]) =>
|
|
90
|
+
OPAQUE_OPERATORS.includes(operator)
|
|
91
|
+
? [operator, operand]
|
|
92
|
+
: [operator, marshalCriteriaValue(operand)]
|
|
93
|
+
))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return value
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Convert list criteria the way records are converted: values addressed at `_id` or at a
|
|
101
|
+
* declared reference become `ObjectId`s, and the `id` alias records actually carry is
|
|
102
|
+
* mapped onto `_id` — documents never store `id`, so before this mapping such criteria
|
|
103
|
+
* silently matched nothing.
|
|
104
|
+
*
|
|
105
|
+
* Tolerant by design: a value that is not 24 hex passes through unconverted. Criteria are
|
|
106
|
+
* matched against the collection, and against an `ObjectId` typed field a stray string
|
|
107
|
+
* matches nothing — which is exactly what it matched before the field was converted.
|
|
108
|
+
*/
|
|
109
|
+
export const marshalCriteria = (
|
|
110
|
+
criteria: ListCriteria | undefined, refs: Map<string, MongoReference>
|
|
111
|
+
): ListCriteria | undefined => {
|
|
112
|
+
if (criteria == null) {
|
|
113
|
+
return criteria
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return Object.fromEntries(Object.entries(criteria).map(([key, value]) => {
|
|
117
|
+
if (LOGICAL_OPERATORS.includes(key) && Array.isArray(value)) {
|
|
118
|
+
return [key, value.map(sub => marshalCriteria(sub as ListCriteria, refs))]
|
|
119
|
+
}
|
|
120
|
+
if (key === 'id' || key === '_id') {
|
|
121
|
+
return ['_id', marshalCriteriaValue(value)]
|
|
122
|
+
}
|
|
123
|
+
if (refs.has(key)) {
|
|
124
|
+
return [key, marshalCriteriaValue(value)]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return [key, value]
|
|
128
|
+
})) as ListCriteria
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Criteria for addressing a single record by a field — `get`/`load`/`update`/`delete`.
|
|
133
|
+
*
|
|
134
|
+
* `_id` keeps its historical strictness (an invalid id throws through the driver). The
|
|
135
|
+
* `id` alias and declared references convert tolerantly, so a caller probing a reference
|
|
136
|
+
* with a value that is not a mongo id gets "not found" rather than a throw.
|
|
137
|
+
*/
|
|
138
|
+
export const identityCriteria = (
|
|
139
|
+
field: string, id: string, refs: Map<string, MongoReference>
|
|
140
|
+
): Document => {
|
|
141
|
+
if (field === '_id') {
|
|
142
|
+
return { _id: new ObjectId(id) }
|
|
143
|
+
}
|
|
144
|
+
if (field === 'id') {
|
|
145
|
+
return { _id: isObjectIdHex(id) ? new ObjectId(id) : id }
|
|
146
|
+
}
|
|
147
|
+
if (refs.has(field)) {
|
|
148
|
+
return { [field]: isObjectIdHex(id) ? new ObjectId(id) : id }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { [field]: id }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Name of the system migration that converts a reference field's stored strings.
|
|
156
|
+
*
|
|
157
|
+
* The `@1` is the body's version: the shared body below fingerprints identically for
|
|
158
|
+
* every field, so an edit to it would raise `MigrationConflict` against every ledger on
|
|
159
|
+
* the next boot. Any semantic change to {@link convertReferenceField} must bump this
|
|
160
|
+
* suffix instead — the old name stays applied, the new one runs (idempotently) once.
|
|
161
|
+
*/
|
|
162
|
+
export const refMigrationName = (field: string): string => `$ref:${field}@1`
|
|
163
|
+
|
|
164
|
+
/** Ledger registered body of the system reference migration. */
|
|
165
|
+
export const makeRefMigration = (field: string) => async (tx: MongoTx): Promise<void> => {
|
|
166
|
+
await convertReferenceField(tx.collection, field)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const convertScalar = (path: string): Document => ({
|
|
170
|
+
$cond: [
|
|
171
|
+
{
|
|
172
|
+
$and: [
|
|
173
|
+
{ $eq: [{ $type: path }, 'string'] },
|
|
174
|
+
{ $regexMatch: { input: path, regex: HEX24 } }
|
|
175
|
+
]
|
|
176
|
+
},
|
|
177
|
+
{ $toObjectId: path },
|
|
178
|
+
path
|
|
179
|
+
]
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Convert one reference field's stored string ids to `ObjectId`s — the body of the
|
|
184
|
+
* system migration and of the boot time reconciliation probe alike.
|
|
185
|
+
*
|
|
186
|
+
* Idempotent and interrupt safe: it matches only documents where the field (or one of
|
|
187
|
+
* its elements) is still a string, converts only values that are actually 24 hex, and
|
|
188
|
+
* leaves everything else exactly as it was. Safe to run concurrently from several
|
|
189
|
+
* replicas — a document converts once, the loser's filter no longer matches it.
|
|
190
|
+
*
|
|
191
|
+
* Validation is bypassed deliberately: at `Pre` stage the collection still carries the
|
|
192
|
+
* validator that declares the field a *string*, and after the switch a legacy document
|
|
193
|
+
* may violate the schema in unrelated ways — either would wedge the boot on a write
|
|
194
|
+
* that only makes the data more correct. (Bypassing requires the connection's user to
|
|
195
|
+
* hold the `bypassDocumentValidation` privilege — `dbOwner`/`root` do.)
|
|
196
|
+
*/
|
|
197
|
+
export const convertReferenceField = async (collection: Collection, field: string): Promise<number> => {
|
|
198
|
+
const result = await collection.updateMany(
|
|
199
|
+
/** An array valued field matches `$type: 'string'` when any element is a string. */
|
|
200
|
+
{ [field]: { $type: 'string' } },
|
|
201
|
+
[{
|
|
202
|
+
$set: {
|
|
203
|
+
[field]: {
|
|
204
|
+
$cond: [
|
|
205
|
+
{ $eq: [{ $type: `$${field}` }, 'array'] },
|
|
206
|
+
{ $map: { input: `$${field}`, as: 'ref', in: convertScalar('$$ref') } },
|
|
207
|
+
convertScalar(`$${field}`)
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}],
|
|
212
|
+
{ bypassDocumentValidation: true }
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return result.modifiedCount
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The second half of the double check the reference migration promises: the ledger says
|
|
220
|
+
* whether the migration ran; this probes whether the collection actually holds no
|
|
221
|
+
* convertible strings — and repairs it when the two disagree (a restored backup, a
|
|
222
|
+
* write from a legacy process, a ledger created by hand).
|
|
223
|
+
*/
|
|
224
|
+
export const reconcileReferences = async (
|
|
225
|
+
collection: Collection, refs: MongoReference[], alias: string
|
|
226
|
+
): Promise<void> => {
|
|
227
|
+
for (const ref of refs) {
|
|
228
|
+
const remnant = await collection.findOne(
|
|
229
|
+
{ [ref.field]: { $type: 'string', $regex: HEX24 } },
|
|
230
|
+
{ projection: { _id: 1 } }
|
|
231
|
+
)
|
|
232
|
+
if (remnant != null) {
|
|
233
|
+
const converted = await convertReferenceField(collection, ref.field)
|
|
234
|
+
console.warn(
|
|
235
|
+
`@owlmeans/mongo-resource: ${alias}.${ref.field} held string ids outside the migration`
|
|
236
|
+
+ ` ledger — converted ${converted} document(s)`
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/utils/schema.ts
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
import type { AnySchema, JSONSchemaType } from 'ajv'
|
|
2
2
|
import type { Document } from 'mongodb'
|
|
3
3
|
|
|
4
|
+
import type { MongoReference } from '../types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Declared references are stored as `ObjectId`s while the AJV schema — which describes
|
|
8
|
+
* the records the app exchanges — keeps calling them strings. The collection validator
|
|
9
|
+
* describes what's stored, so the reference fields are overridden here after the plain
|
|
10
|
+
* conversion. Nullability and array shape carry over from the declared property.
|
|
11
|
+
*/
|
|
12
|
+
export const applyReferenceTypes = (
|
|
13
|
+
mongoSchema: Document, schema: AnySchema, refs: MongoReference[]
|
|
14
|
+
): Document => {
|
|
15
|
+
if (mongoSchema.properties == null || refs.length < 1) {
|
|
16
|
+
return mongoSchema
|
|
17
|
+
}
|
|
18
|
+
const properties: Record<string, JSONSchemaType<unknown>> =
|
|
19
|
+
(schema as JSONSchemaType<unknown>).properties ?? {}
|
|
20
|
+
for (const ref of refs) {
|
|
21
|
+
const declared = properties[ref.field]
|
|
22
|
+
if (declared == null || mongoSchema.properties[ref.field] == null) {
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
mongoSchema.properties[ref.field] = declared.type === 'array'
|
|
26
|
+
? {
|
|
27
|
+
bsonType: declared.nullable ? ['array', 'null'] : 'array',
|
|
28
|
+
items: { bsonType: 'objectId' }
|
|
29
|
+
}
|
|
30
|
+
: { bsonType: declared.nullable ? ['objectId', 'null'] : 'objectId' }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return mongoSchema
|
|
34
|
+
}
|
|
35
|
+
|
|
4
36
|
export const schemaToMongoSchema = (schema: AnySchema): Document => {
|
|
5
37
|
const _schema = schema as JSONSchemaType<unknown>
|
|
6
38
|
|