@owlmeans/mongo-resource 0.1.18-rc.8 → 0.1.18
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 +232 -71
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/mongo-resource/SKILL.md +89 -44
- package/build/consts.d.ts +8 -4
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +8 -4
- package/build/consts.js.map +1 -1
- package/build/declarations/types.d.ts +12 -0
- package/build/declarations/types.d.ts.map +1 -0
- package/build/declarations/types.js +2 -0
- package/build/declarations/types.js.map +1 -0
- package/build/declarations.d.ts +4 -9
- package/build/declarations.d.ts.map +1 -1
- package/build/declarations.js +28 -21
- package/build/declarations.js.map +1 -1
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/resource.d.ts +2 -2
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +100 -130
- package/build/resource.js.map +1 -1
- package/build/types.d.ts +11 -5
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +4 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/build/utils/consts.local.d.ts +25 -0
- package/build/utils/consts.local.d.ts.map +1 -0
- package/build/utils/consts.local.js +31 -0
- package/build/utils/consts.local.js.map +1 -0
- package/build/utils/criteria/types.d.ts +27 -0
- package/build/utils/criteria/types.d.ts.map +1 -0
- package/build/utils/criteria/types.js +2 -0
- package/build/utils/criteria/types.js.map +1 -0
- package/build/utils/criteria.d.ts +9 -0
- package/build/utils/criteria.d.ts.map +1 -0
- package/build/utils/criteria.js +204 -0
- package/build/utils/criteria.js.map +1 -0
- package/build/utils/index.d.ts +1 -0
- package/build/utils/index.d.ts.map +1 -1
- package/build/utils/index.js +1 -0
- package/build/utils/index.js.map +1 -1
- package/build/utils/indexes/types.d.ts +19 -0
- package/build/utils/indexes/types.d.ts.map +1 -0
- package/build/utils/indexes/types.js +2 -0
- package/build/utils/indexes/types.js.map +1 -0
- package/build/utils/indexes.d.ts +3 -4
- package/build/utils/indexes.d.ts.map +1 -1
- package/build/utils/indexes.js +44 -28
- package/build/utils/indexes.js.map +1 -1
- package/build/utils/life-cycle/types.d.ts +36 -0
- package/build/utils/life-cycle/types.d.ts.map +1 -0
- package/build/utils/life-cycle/types.js +2 -0
- package/build/utils/life-cycle/types.js.map +1 -0
- package/build/utils/life-cycle.d.ts +3 -30
- package/build/utils/life-cycle.d.ts.map +1 -1
- package/build/utils/life-cycle.js +96 -112
- package/build/utils/life-cycle.js.map +1 -1
- package/build/utils/migrations.d.ts.map +1 -1
- package/build/utils/migrations.js +1 -2
- package/build/utils/migrations.js.map +1 -1
- package/build/utils/name.d.ts.map +1 -1
- package/build/utils/name.js +1 -5
- package/build/utils/name.js.map +1 -1
- package/build/utils/refs/types.d.ts +78 -0
- package/build/utils/refs/types.d.ts.map +1 -0
- package/build/utils/refs/types.js +2 -0
- package/build/utils/refs/types.js.map +1 -0
- package/build/utils/refs.d.ts +13 -67
- package/build/utils/refs.d.ts.map +1 -1
- package/build/utils/refs.js +115 -158
- package/build/utils/refs.js.map +1 -1
- package/build/utils/schema/types.d.ts +16 -0
- package/build/utils/schema/types.d.ts.map +1 -0
- package/build/utils/schema/types.js +2 -0
- package/build/utils/schema/types.js.map +1 -0
- package/build/utils/schema.d.ts +3 -11
- package/build/utils/schema.d.ts.map +1 -1
- package/build/utils/schema.js +86 -88
- package/build/utils/schema.js.map +1 -1
- package/build/utils/types.local.d.ts +13 -0
- package/build/utils/types.local.d.ts.map +1 -0
- package/build/utils/types.local.js +2 -0
- package/build/utils/types.local.js.map +1 -0
- package/package.json +10 -9
- package/src/consts.ts +8 -4
- package/src/declarations/types.ts +12 -0
- package/src/declarations.ts +32 -28
- package/src/index.ts +4 -0
- package/src/resource.ts +121 -163
- package/src/types.local.ts +5 -0
- package/src/types.ts +13 -7
- package/src/utils/consts.local.ts +35 -0
- package/src/utils/criteria/types.ts +28 -0
- package/src/utils/criteria.ts +235 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/indexes/types.ts +20 -0
- package/src/utils/indexes.ts +48 -28
- package/src/utils/life-cycle/types.ts +39 -0
- package/src/utils/life-cycle.ts +120 -134
- package/src/utils/migrations.ts +3 -17
- package/src/utils/name.ts +1 -6
- package/src/utils/refs/types.ts +79 -0
- package/src/utils/refs.ts +147 -189
- package/src/utils/schema/types.ts +17 -0
- package/src/utils/schema.ts +92 -91
- package/src/utils/types.local.ts +13 -0
- package/tests/context.ts +3 -3
- package/tests/criteria.spec.ts +116 -0
- package/tests/crud.spec.ts +2 -2
- package/tests/indexes.spec.ts +42 -0
- package/tests/refs.spec.ts +5 -3
- package/build/.gitkeep +0 -0
package/src/utils/refs.ts
CHANGED
|
@@ -1,167 +1,21 @@
|
|
|
1
1
|
import { MisshapedRecord } from '@owlmeans/resource'
|
|
2
|
-
import type
|
|
3
|
-
import {
|
|
4
|
-
import type { Collection, Document } from 'mongodb'
|
|
2
|
+
import { ObjectId, type Collection, type Document } from 'mongodb'
|
|
3
|
+
import { logger } from '@owlmeans/log'
|
|
5
4
|
|
|
6
5
|
import type { MongoReference, MongoTx } from '../types.js'
|
|
6
|
+
import { HEX24, LOGICAL_OPERATORS, OPAQUE_OPERATORS } from './consts.local.js'
|
|
7
|
+
import type { MongoRefHelper } from './refs/types.js'
|
|
7
8
|
|
|
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)
|
|
9
|
+
const log = logger('mongo-resource')
|
|
17
10
|
|
|
18
11
|
/**
|
|
19
|
-
*
|
|
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.
|
|
12
|
+
* Ledger registered body of the system reference migration.
|
|
26
13
|
*
|
|
27
|
-
*
|
|
14
|
+
* Stays a module level factory, textually unchanged: the ledger fingerprints a migration by the
|
|
15
|
+
* source text of the function it registers (`apply.toString()`), so moving the returned closure —
|
|
16
|
+
* or renaming what it calls — changes the checksum and raises `MigrationConflict` against every
|
|
17
|
+
* ledger that already applied it.
|
|
28
18
|
*/
|
|
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
19
|
export const makeRefMigration = (field: string) => async (tx: MongoTx): Promise<void> => {
|
|
166
20
|
await convertReferenceField(tx.collection, field)
|
|
167
21
|
}
|
|
@@ -180,21 +34,10 @@ const convertScalar = (path: string): Document => ({
|
|
|
180
34
|
})
|
|
181
35
|
|
|
182
36
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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.)
|
|
37
|
+
* The body {@link makeRefMigration} runs, kept at module level for the same reason it is; the
|
|
38
|
+
* helper exposes the very same function as `convertReferenceField`.
|
|
196
39
|
*/
|
|
197
|
-
|
|
40
|
+
const convertReferenceField = async (collection: Collection, field: string): Promise<number> => {
|
|
198
41
|
const result = await collection.updateMany(
|
|
199
42
|
/** An array valued field matches `$type: 'string'` when any element is a string. */
|
|
200
43
|
{ [field]: { $type: 'string' } },
|
|
@@ -215,26 +58,141 @@ export const convertReferenceField = async (collection: Collection, field: strin
|
|
|
215
58
|
return result.modifiedCount
|
|
216
59
|
}
|
|
217
60
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
61
|
+
export const createMongoRefHelper = (): MongoRefHelper => {
|
|
62
|
+
const isObjectIdHex = (value: unknown): value is string =>
|
|
63
|
+
typeof value === 'string' && HEX24.test(value)
|
|
64
|
+
|
|
65
|
+
const marshalReference = (field: string, value: unknown): unknown => {
|
|
66
|
+
if (value == null) {
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
if (value instanceof ObjectId) {
|
|
70
|
+
return value
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(value)) {
|
|
73
|
+
return value.map(item => marshalReference(field, item))
|
|
74
|
+
}
|
|
75
|
+
if (isObjectIdHex(value)) {
|
|
76
|
+
return new ObjectId(value)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
throw new MisshapedRecord(`ref:${field}`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const demarshalReference = (value: unknown): unknown => {
|
|
83
|
+
if (value instanceof ObjectId) {
|
|
84
|
+
return value.toString()
|
|
85
|
+
}
|
|
86
|
+
if (Array.isArray(value)) {
|
|
87
|
+
return value.map(demarshalReference)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return value
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const demarshalRefs = <T extends {}>(record: T, refs: Map<string, MongoReference>): T => {
|
|
94
|
+
if (refs.size < 1) {
|
|
95
|
+
return record
|
|
96
|
+
}
|
|
97
|
+
for (const field of refs.keys()) {
|
|
98
|
+
const value = (record as Document)[field]
|
|
99
|
+
if (value != null) {
|
|
100
|
+
(record as Document)[field] = demarshalReference(value)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return record
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const marshalCriteriaValue = (value: unknown): unknown => {
|
|
108
|
+
if (typeof value === 'string') {
|
|
109
|
+
return isObjectIdHex(value) ? new ObjectId(value) : value
|
|
110
|
+
}
|
|
111
|
+
if (Array.isArray(value)) {
|
|
112
|
+
return value.map(marshalCriteriaValue)
|
|
113
|
+
}
|
|
114
|
+
if (value != null && typeof value === 'object' && !(value instanceof ObjectId) && !(value instanceof Date)) {
|
|
115
|
+
return Object.fromEntries(Object.entries(value).map(([operator, operand]) =>
|
|
116
|
+
OPAQUE_OPERATORS.includes(operator)
|
|
117
|
+
? [operator, operand]
|
|
118
|
+
: [operator, marshalCriteriaValue(operand)]
|
|
119
|
+
))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return value
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const marshalCriteria = (
|
|
126
|
+
criteria: Document | undefined, refs: Map<string, MongoReference>
|
|
127
|
+
): Document | undefined => {
|
|
128
|
+
if (criteria == null) {
|
|
129
|
+
return criteria
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return Object.fromEntries(Object.entries(criteria).map(([key, value]) => {
|
|
133
|
+
if (LOGICAL_OPERATORS.includes(key) && Array.isArray(value)) {
|
|
134
|
+
return [key, value.map(sub => marshalCriteria(sub as Document, refs))]
|
|
135
|
+
}
|
|
136
|
+
if (key === 'id' || key === '_id') {
|
|
137
|
+
return ['_id', marshalCriteriaValue(value)]
|
|
138
|
+
}
|
|
139
|
+
if (refs.has(key)) {
|
|
140
|
+
return [key, marshalCriteriaValue(value)]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return [key, value]
|
|
144
|
+
})) as Document
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const identityCriteria = (
|
|
148
|
+
field: string, id: string, refs: Map<string, MongoReference>
|
|
149
|
+
): Document => {
|
|
150
|
+
if (field === '_id') {
|
|
151
|
+
return { _id: new ObjectId(id) }
|
|
152
|
+
}
|
|
153
|
+
if (field === 'id') {
|
|
154
|
+
return { _id: isObjectIdHex(id) ? new ObjectId(id) : id }
|
|
155
|
+
}
|
|
156
|
+
if (refs.has(field)) {
|
|
157
|
+
return { [field]: isObjectIdHex(id) ? new ObjectId(id) : id }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { [field]: id }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const refMigrationName = (field: string): string => `$ref:${field}@1`
|
|
164
|
+
|
|
165
|
+
const reconcileReferences = async (
|
|
166
|
+
collection: Collection, refs: MongoReference[], alias: string
|
|
167
|
+
): Promise<void> => {
|
|
168
|
+
for (const ref of refs) {
|
|
169
|
+
const remnant = await collection.findOne(
|
|
170
|
+
{ [ref.field]: { $type: 'string', $regex: HEX24 } },
|
|
171
|
+
{ projection: { _id: 1 } }
|
|
237
172
|
)
|
|
173
|
+
if (remnant != null) {
|
|
174
|
+
const converted = await convertReferenceField(collection, ref.field)
|
|
175
|
+
log.warn('Reference field held string ids outside the migration ledger; converted', {
|
|
176
|
+
resource: alias, field: ref.field, converted,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
238
179
|
}
|
|
239
180
|
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
isObjectIdHex, marshalReference, demarshalReference, demarshalRefs, marshalCriteria, identityCriteria,
|
|
184
|
+
refMigrationName, convertReferenceField, reconcileReferences
|
|
185
|
+
}
|
|
240
186
|
}
|
|
187
|
+
|
|
188
|
+
export const mongoRefHelper = createMongoRefHelper()
|
|
189
|
+
|
|
190
|
+
/** @deprecated compat:factory-refactor — use `mongoRefHelper.isObjectIdHex(…)` */
|
|
191
|
+
export const isObjectIdHex = (value: unknown): value is string => mongoRefHelper.isObjectIdHex(value)
|
|
192
|
+
|
|
193
|
+
/** @deprecated compat:factory-refactor — use `mongoRefHelper.marshalReference(…)` */
|
|
194
|
+
export const marshalReference = (field: string, value: unknown): unknown => mongoRefHelper.marshalReference(field, value)
|
|
195
|
+
|
|
196
|
+
/** @deprecated compat:factory-refactor — use `mongoRefHelper.demarshalRefs(…)` */
|
|
197
|
+
export const demarshalRefs = <T extends {}>(record: T, refs: Map<string, MongoReference>): T =>
|
|
198
|
+
mongoRefHelper.demarshalRefs(record, refs)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AnySchema } from 'ajv'
|
|
2
|
+
import type { Document } from 'mongodb'
|
|
3
|
+
|
|
4
|
+
import type { MongoReference } from '../../types.js'
|
|
5
|
+
|
|
6
|
+
/** An AJV schema as the `$jsonSchema` validator of a collection. */
|
|
7
|
+
export interface MongoSchemaUtils {
|
|
8
|
+
/**
|
|
9
|
+
* Declared references are stored as `ObjectId`s while the AJV schema — which describes
|
|
10
|
+
* the records the app exchanges — keeps calling them strings. The collection validator
|
|
11
|
+
* describes what's stored, so the reference fields are overridden here after the plain
|
|
12
|
+
* conversion. Nullability and array shape carry over from the declared property.
|
|
13
|
+
*/
|
|
14
|
+
applyReferenceTypes: (mongoSchema: Document, schema: AnySchema, refs: MongoReference[]) => Document
|
|
15
|
+
/** The plain conversion of an object schema into a `$jsonSchema` document. */
|
|
16
|
+
schemaToMongoSchema: (schema: AnySchema) => Document
|
|
17
|
+
}
|
package/src/utils/schema.ts
CHANGED
|
@@ -2,120 +2,121 @@ import type { AnySchema, JSONSchemaType } from 'ajv'
|
|
|
2
2
|
import type { Document } from 'mongodb'
|
|
3
3
|
|
|
4
4
|
import type { MongoReference } from '../types.js'
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
5
|
+
import type { MongoSchemaUtils } from './schema/types.js'
|
|
6
|
+
|
|
7
|
+
export const createMongoSchemaUtils = (): MongoSchemaUtils => {
|
|
8
|
+
const applyReferenceTypes = (
|
|
9
|
+
mongoSchema: Document, schema: AnySchema, refs: MongoReference[]
|
|
10
|
+
): Document => {
|
|
11
|
+
if (mongoSchema.properties == null || refs.length < 1) {
|
|
12
|
+
return mongoSchema
|
|
24
13
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
14
|
+
const properties: Record<string, JSONSchemaType<unknown>> =
|
|
15
|
+
(schema as JSONSchemaType<unknown>).properties ?? {}
|
|
16
|
+
for (const ref of refs) {
|
|
17
|
+
const declared = properties[ref.field]
|
|
18
|
+
if (declared == null || mongoSchema.properties[ref.field] == null) {
|
|
19
|
+
continue
|
|
29
20
|
}
|
|
30
|
-
|
|
21
|
+
mongoSchema.properties[ref.field] = declared.type === 'array'
|
|
22
|
+
? {
|
|
23
|
+
bsonType: declared.nullable ? ['array', 'null'] : 'array',
|
|
24
|
+
items: { bsonType: 'objectId' }
|
|
25
|
+
}
|
|
26
|
+
: { bsonType: declared.nullable ? ['objectId', 'null'] : 'objectId' }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return mongoSchema
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
const schemaToMongoSchema = (schema: AnySchema): Document => {
|
|
33
|
+
const _schema = schema as JSONSchemaType<unknown>
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
if (_schema.type !== 'object') {
|
|
36
|
+
throw new SyntaxError('Only object schemas can be converted to mongo schema')
|
|
37
|
+
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
const mongoSchems: Document = {
|
|
40
|
+
bsonType: 'object',
|
|
41
|
+
properties: _schema.properties != null
|
|
42
|
+
? 'allOf' in _schema.properties
|
|
43
|
+
? covertAllOfProperties(_schema.properties.allOf) :
|
|
44
|
+
convertProperties(_schema.properties ?? {})
|
|
45
|
+
: undefined,
|
|
46
|
+
additionalProperties:
|
|
47
|
+
_schema.additionalProperties != null
|
|
48
|
+
? convertAdditionalProperties(_schema.additionalProperties)
|
|
49
|
+
: false,
|
|
50
|
+
...(_schema.required != null ? { required: _schema.required } : {}),
|
|
51
|
+
}
|
|
42
52
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
? 'allOf' in _schema.properties
|
|
47
|
-
? covertAllOfProperties(_schema.properties.allOf) :
|
|
48
|
-
convertProperties(_schema.properties ?? {})
|
|
49
|
-
: undefined,
|
|
50
|
-
additionalProperties:
|
|
51
|
-
_schema.additionalProperties != null
|
|
52
|
-
? convertAdditionalProperties(_schema.additionalProperties)
|
|
53
|
-
: false,
|
|
54
|
-
...(_schema.required != null ? { required: _schema.required } : {}),
|
|
55
|
-
}
|
|
53
|
+
if (_schema.nullable) {
|
|
54
|
+
mongoSchems.bsonType = [mongoSchems.bsonType, 'null']
|
|
55
|
+
}
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
if ("properties" in mongoSchems && mongoSchems.properties == null) {
|
|
58
|
+
delete mongoSchems.properties
|
|
59
|
+
}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
if ("required" in mongoSchems && mongoSchems.required == null
|
|
62
|
+
|| Array.isArray(mongoSchems.required) && mongoSchems.required.length === 0) {
|
|
63
|
+
delete mongoSchems.required
|
|
64
|
+
}
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|| Array.isArray(mongoSchems.required) && mongoSchems.required.length === 0) {
|
|
67
|
-
delete mongoSchems.required
|
|
66
|
+
return mongoSchems
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
const convertAdditionalProperties = (additionalProperties: boolean | AnySchema): Document | boolean => {
|
|
70
|
+
if (typeof additionalProperties === 'boolean') {
|
|
71
|
+
return additionalProperties
|
|
72
|
+
}
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
if (typeof additionalProperties === 'boolean') {
|
|
75
|
-
return additionalProperties
|
|
74
|
+
return convertToBsonType(additionalProperties as JSONSchemaType<unknown>)
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
const convertProperties = (properties: Record<string, AnySchema>): Document =>
|
|
78
|
+
Object.fromEntries(Object.entries(properties).map(([key, value]) => {
|
|
79
|
+
if (key === 'oneOf') {
|
|
80
|
+
throw new SyntaxError('We dont support schemas with oneof properties definition')
|
|
81
|
+
}
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (key === 'oneOf') {
|
|
84
|
-
throw new SyntaxError('We dont support schemas with oneof properties definition')
|
|
85
|
-
}
|
|
83
|
+
return [key, convertToBsonType(value as JSONSchemaType<unknown>)]
|
|
84
|
+
}))
|
|
86
85
|
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
const covertAllOfProperties = (allOf: Record<string, JSONSchemaType<unknown>>[]): Document =>
|
|
87
|
+
allOf.map(schema => convertProperties(schema.properties ?? {}))
|
|
88
|
+
.reduce((properties, schema) => ({ ...properties, ...schema }), {})
|
|
89
89
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
const prepareScalarType = (value: JSONSchemaType<unknown>): string | string[] => {
|
|
91
|
+
const type = (Array.isArray(value.type) ? value.type : [value.type])
|
|
92
|
+
.map(type => type === 'boolean' ? 'bool' : type)
|
|
93
|
+
if (value.nullable) {
|
|
94
|
+
type.push('null')
|
|
95
|
+
}
|
|
93
96
|
|
|
94
|
-
|
|
95
|
-
const type = (Array.isArray(value.type) ? value.type : [value.type])
|
|
96
|
-
.map(type => type === 'boolean' ? 'bool' : type)
|
|
97
|
-
if (value.nullable) {
|
|
98
|
-
type.push('null')
|
|
97
|
+
return type.length === 1 ? type[0] : type
|
|
99
98
|
}
|
|
100
99
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
...schemaToMongoSchema({ ...value, type: 'object' }),
|
|
109
|
-
bsonType: prepareScalarType(value)
|
|
100
|
+
const convertToBsonType = (value: JSONSchemaType<unknown>): Document => {
|
|
101
|
+
if (Array.isArray(value.type)) {
|
|
102
|
+
if ('properties' in value || 'additionalProperties' in value) {
|
|
103
|
+
return {
|
|
104
|
+
...schemaToMongoSchema({ ...value, type: 'object' }),
|
|
105
|
+
bsonType: prepareScalarType(value)
|
|
106
|
+
}
|
|
110
107
|
}
|
|
108
|
+
return { bsonType: prepareScalarType(value) }
|
|
111
109
|
}
|
|
112
|
-
|
|
110
|
+
|
|
111
|
+
return value.type === 'object'
|
|
112
|
+
// `DateSchema` is `{ type: 'object', format: 'date-time' }`, so a date is detected by
|
|
113
|
+
// format rather than type — but it still has to honor `nullable`, otherwise an optional
|
|
114
|
+
// date left unset (the driver writes `undefined` as `null`) fails collection validation.
|
|
115
|
+
? value.format === 'date-time' ? { bsonType: value.nullable ? ['date', 'null'] : 'date' } : schemaToMongoSchema(value)
|
|
116
|
+
: { bsonType: prepareScalarType(value) }
|
|
113
117
|
}
|
|
114
118
|
|
|
115
|
-
return
|
|
116
|
-
// `DateSchema` is `{ type: 'object', format: 'date-time' }`, so a date is detected by
|
|
117
|
-
// format rather than type — but it still has to honor `nullable`, otherwise an optional
|
|
118
|
-
// date left unset (the driver writes `undefined` as `null`) fails collection validation.
|
|
119
|
-
? value.format === 'date-time' ? { bsonType: value.nullable ? ['date', 'null'] : 'date' } : schemaToMongoSchema(value)
|
|
120
|
-
: { bsonType: prepareScalarType(value) }
|
|
119
|
+
return { applyReferenceTypes, schemaToMongoSchema }
|
|
121
120
|
}
|
|
121
|
+
|
|
122
|
+
export const mongoSchemaUtils = createMongoSchemaUtils()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MigrationStage } from '@owlmeans/resource'
|
|
2
|
+
|
|
3
|
+
export interface LedgerRecord {
|
|
4
|
+
alias: string
|
|
5
|
+
name: string
|
|
6
|
+
stage: MigrationStage
|
|
7
|
+
checksum: string | null
|
|
8
|
+
baseline: boolean
|
|
9
|
+
startedAt: Date
|
|
10
|
+
/** `null` while a replica is running it — the claim, not the completion. */
|
|
11
|
+
completedAt: Date | null
|
|
12
|
+
durationMs: number | null
|
|
13
|
+
}
|
package/tests/context.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomNamespace, cleanupHelper, gateHelper } from '@owlmeans/test-integration'
|
|
2
2
|
import type { IntegrationGate, MongoEnv } from '@owlmeans/test-integration'
|
|
3
3
|
|
|
4
4
|
export interface MongoTestEnv {
|
|
@@ -22,12 +22,12 @@ let cached: MongoTestEnv | null = null
|
|
|
22
22
|
*/
|
|
23
23
|
export const getTestEnv = (): MongoTestEnv => {
|
|
24
24
|
if (cached != null) return cached
|
|
25
|
-
const gate = mongoGate()
|
|
25
|
+
const gate = gateHelper.mongoGate()
|
|
26
26
|
const prefix = process.env.MONGO_TEST_DB_PREFIX ?? 'omt'
|
|
27
27
|
const dbName = randomNamespace(prefix)
|
|
28
28
|
cached = { gate, dbName }
|
|
29
29
|
if (!gate.skip) {
|
|
30
|
-
registerCleanup(async () => {
|
|
30
|
+
cleanupHelper.registerCleanup(async () => {
|
|
31
31
|
const { MongoClient } = await import('mongodb')
|
|
32
32
|
const client = new MongoClient(gate.env.MONGO_URL as string)
|
|
33
33
|
try {
|