@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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operators mongo speaks natively and that mean the same thing here as they do in SQL and in
|
|
3
|
+
* the in-memory engine. Everything else in the shared vocabulary is rewritten below into a
|
|
4
|
+
* mongo expression with the same meaning — a criteria object has to answer identically
|
|
5
|
+
* whichever store it reaches.
|
|
6
|
+
*/
|
|
7
|
+
export const NATIVE = new Set(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin', '$regex'])
|
|
8
|
+
|
|
9
|
+
/** What the SERVER reports about an index rather than what a declaration asks for — never a
|
|
10
|
+
* reason to recreate one. `collation` is here because a collection's default collation is
|
|
11
|
+
* reported on every index in it, including the ones declared without any. */
|
|
12
|
+
export const REPORTED_FIELDS = new Set([
|
|
13
|
+
'v', 'ns', 'name', 'collation', 'textIndexVersion', '2dsphereIndexVersion', 'background'
|
|
14
|
+
])
|
|
15
|
+
|
|
16
|
+
// Collection names must use only alphanumeric characters, underscore, and hyphen — no
|
|
17
|
+
// colons or other delimiters. This guard enforces the rule for every collection created
|
|
18
|
+
// through makeMongoResource across all projects (common/internal/app), failing fast at
|
|
19
|
+
// initialization rather than silently creating a badly-named collection.
|
|
20
|
+
export const VALID_COLLECTION_NAME = /^[a-zA-Z0-9_-]+$/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The only shape a stored reference is converted from. Deliberately stricter than
|
|
24
|
+
* `ObjectId.isValid`, which also accepts any 12 character string and would silently
|
|
25
|
+
* swallow short business keys.
|
|
26
|
+
*/
|
|
27
|
+
export const HEX24 = /^[0-9a-fA-F]{24}$/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Operators whose operand is never an id — a 24 hex string under `$regex` is a pattern,
|
|
31
|
+
* not a reference.
|
|
32
|
+
*/
|
|
33
|
+
export const OPAQUE_OPERATORS = ['$regex', '$options', '$type', '$size', '$mod', '$exists', '$where']
|
|
34
|
+
|
|
35
|
+
export const LOGICAL_OPERATORS = ['$and', '$or', '$nor']
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Criteria, Sort } from '@owlmeans/resource'
|
|
2
|
+
import type { Document } from 'mongodb'
|
|
3
|
+
|
|
4
|
+
import type { MongoReference } from '../../types.js'
|
|
5
|
+
|
|
6
|
+
/** The portable criteria and sort vocabulary, as the documents a collection takes. */
|
|
7
|
+
export interface MongoCriteriaHelper {
|
|
8
|
+
/**
|
|
9
|
+
* Translate `Criteria<T>` into the filter a collection takes.
|
|
10
|
+
*
|
|
11
|
+
* Two passes: the shared operator vocabulary becomes mongo expressions, then
|
|
12
|
+
* `mongoRefHelper.marshalCriteria` converts the values addressed at `_id` or at a declared
|
|
13
|
+
* reference into `ObjectId`s and maps the `id` alias onto `_id`. Both halves are needed — a
|
|
14
|
+
* criteria object carries string ids and portable operators, a collection stores neither.
|
|
15
|
+
*
|
|
16
|
+
* An empty result is an empty filter, which matches everything. Callers that must not act on
|
|
17
|
+
* "everything" — `purge` — check for it themselves.
|
|
18
|
+
*
|
|
19
|
+
* @throws {UnsupportedArgumentError}
|
|
20
|
+
*/
|
|
21
|
+
criteriaToFilter: (criteria: Criteria<any> | undefined, refs: Map<string, MongoReference>) => Document
|
|
22
|
+
/**
|
|
23
|
+
* Translate `Sort<T>[]` into a mongo sort document. A bare field name is ascending, and `id`
|
|
24
|
+
* addresses `_id` — documents never store an `id` field, so sorting by the name records carry
|
|
25
|
+
* would silently order by nothing.
|
|
26
|
+
*/
|
|
27
|
+
sortToMongo: (sort?: Sort<any>[]) => Document | undefined
|
|
28
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { UnsupportedArgumentError, type Criteria, type Sort } from '@owlmeans/resource'
|
|
2
|
+
import type { Document } from 'mongodb'
|
|
3
|
+
|
|
4
|
+
import type { MongoReference } from '../types.js'
|
|
5
|
+
import { mongoRefHelper } from './refs.js'
|
|
6
|
+
import { NATIVE } from './consts.local.js'
|
|
7
|
+
import type { MongoCriteriaHelper } from './criteria/types.js'
|
|
8
|
+
|
|
9
|
+
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A SQL `LIKE` pattern as an anchored regular expression: `%` is any run, `_` is one
|
|
13
|
+
* character, and `\` escapes either. The mapping is the one the in-memory engine applies, so
|
|
14
|
+
* the same pattern selects the same records against a collection.
|
|
15
|
+
*/
|
|
16
|
+
const likeToRegExp = (pattern: string): string => {
|
|
17
|
+
let source = ''
|
|
18
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
19
|
+
const char = pattern[index]!
|
|
20
|
+
if (char === '\\' && index + 1 < pattern.length) {
|
|
21
|
+
source += escapeRegExp(pattern[++index]!)
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
if (char === '%') { source += '.*'; continue }
|
|
25
|
+
if (char === '_') { source += '.'; continue }
|
|
26
|
+
source += escapeRegExp(char)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return `^${source}$`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const asArray = (operand: unknown): unknown[] => Array.isArray(operand) ? operand : [operand]
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One operator as the mongo field expression that answers it.
|
|
36
|
+
*
|
|
37
|
+
* `$exists` and `$null` both become a comparison against `null`, not mongo's own `$exists`:
|
|
38
|
+
* the shared vocabulary asks whether a field *has a value*, which the relational and
|
|
39
|
+
* in-memory stores answer as `IS NULL` / `value == null`. Mongo's `$exists` answers a
|
|
40
|
+
* different question — whether the key is present — and a key present but null would part
|
|
41
|
+
* the three stores over one criteria object.
|
|
42
|
+
*
|
|
43
|
+
* @throws {UnsupportedArgumentError} on an operator this store cannot answer.
|
|
44
|
+
*/
|
|
45
|
+
const operatorToFilter = (field: string, operator: string, operand: unknown): Document => {
|
|
46
|
+
if (NATIVE.has(operator)) {
|
|
47
|
+
return { [operator]: operand }
|
|
48
|
+
}
|
|
49
|
+
switch (operator) {
|
|
50
|
+
case '$exists':
|
|
51
|
+
return operand === false ? { $eq: null } : { $ne: null }
|
|
52
|
+
case '$null':
|
|
53
|
+
return operand === false ? { $ne: null } : { $eq: null }
|
|
54
|
+
case '$like':
|
|
55
|
+
return { $regex: likeToRegExp(`${operand}`) }
|
|
56
|
+
case '$ilike':
|
|
57
|
+
return { $regex: likeToRegExp(`${operand}`), $options: 'i' }
|
|
58
|
+
case '$startsWith':
|
|
59
|
+
return { $regex: `^${escapeRegExp(`${operand}`)}` }
|
|
60
|
+
case '$endsWith':
|
|
61
|
+
return { $regex: `${escapeRegExp(`${operand}`)}$` }
|
|
62
|
+
case '$between':
|
|
63
|
+
if (!Array.isArray(operand) || operand.length !== 2) {
|
|
64
|
+
throw new UnsupportedArgumentError(`criteria:$between:${field}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { $gte: operand[0], $lte: operand[1] }
|
|
68
|
+
/** Array membership, mirroring the postgres array operators `@>`, `<@` and `&&`. */
|
|
69
|
+
case '$contains':
|
|
70
|
+
return { $all: asArray(operand) }
|
|
71
|
+
case '$contained':
|
|
72
|
+
/**
|
|
73
|
+
* Nothing outside the operand list may appear in the field. The `$ne: null` keeps an
|
|
74
|
+
* absent field out of the result — `$not` alone is satisfied by a document that has no
|
|
75
|
+
* such field at all, where the relational store answers NULL and matches nothing.
|
|
76
|
+
*/
|
|
77
|
+
return { $not: { $elemMatch: { $nin: asArray(operand) } }, $ne: null }
|
|
78
|
+
case '$overlaps':
|
|
79
|
+
return { $in: asArray(operand) }
|
|
80
|
+
default:
|
|
81
|
+
throw new UnsupportedArgumentError(`criteria-operator:${operator}`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* An object naming at least one `$` key is a spec, not a value to compare against. A Date and
|
|
87
|
+
* an array are values even though both are objects.
|
|
88
|
+
*/
|
|
89
|
+
const isOperatorSpec = (value: unknown): value is Record<string, unknown> =>
|
|
90
|
+
value != null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)
|
|
91
|
+
&& Object.keys(value).some(key => key.startsWith('$'))
|
|
92
|
+
|
|
93
|
+
export const createMongoCriteriaHelper = (): MongoCriteriaHelper => {
|
|
94
|
+
const { marshalCriteria } = mongoRefHelper
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One field's criteria as mongo conditions. Operators that translate into the same mongo key —
|
|
98
|
+
* `$like` and `$startsWith` both produce `$regex` — are emitted as separate conditions rather
|
|
99
|
+
* than merged, so neither silently overwrites the other.
|
|
100
|
+
*/
|
|
101
|
+
const fieldConditions = (field: string, spec: Record<string, unknown>): Document[] => {
|
|
102
|
+
const merged: Document = {}
|
|
103
|
+
const conditions: Document[] = []
|
|
104
|
+
|
|
105
|
+
for (const [operator, operand] of Object.entries(spec)) {
|
|
106
|
+
if (operand === undefined) {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
const fragment = operatorToFilter(field, operator, operand)
|
|
110
|
+
if (Object.keys(fragment).some(key => key in merged)) {
|
|
111
|
+
conditions.push({ [field]: fragment })
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
Object.assign(merged, fragment)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (Object.keys(merged).length > 0) {
|
|
118
|
+
conditions.unshift({ [field]: merged })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return conditions
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Flatten the conditions into one filter document. Mongo reads sibling keys as a conjunction,
|
|
126
|
+
* so only the ones that would overwrite an earlier key — two `$regex` expressions over the
|
|
127
|
+
* same field, say — need an explicit `$and`.
|
|
128
|
+
*/
|
|
129
|
+
const flatten = (conditions: Document[]): Document => {
|
|
130
|
+
const filter: Document = {}
|
|
131
|
+
const conflicting: Document[] = []
|
|
132
|
+
|
|
133
|
+
for (const condition of conditions) {
|
|
134
|
+
if (Object.keys(condition).some(key => key in filter)) {
|
|
135
|
+
conflicting.push(condition)
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
Object.assign(filter, condition)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (conflicting.length > 0) {
|
|
142
|
+
filter.$and = [...(Array.isArray(filter.$and) ? filter.$and : []), ...conflicting]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return filter
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const build = (criteria: Criteria<any> | undefined): Document[] => {
|
|
149
|
+
const conditions: Document[] = []
|
|
150
|
+
|
|
151
|
+
for (const [key, raw] of Object.entries(criteria ?? {})) {
|
|
152
|
+
/**
|
|
153
|
+
* An untouched filter must never empty a list, so `undefined` is skipped rather than
|
|
154
|
+
* compared. `null` asks for the absence of a value.
|
|
155
|
+
*/
|
|
156
|
+
if (raw === undefined) {
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (key === '$and' || key === '$or') {
|
|
161
|
+
const parts = (Array.isArray(raw) ? raw : [raw])
|
|
162
|
+
.map(part => build(part as Criteria<any>))
|
|
163
|
+
.filter(part => part.length > 0)
|
|
164
|
+
.map(part => flatten(part))
|
|
165
|
+
if (parts.length > 0) {
|
|
166
|
+
conditions.push(key === '$and' ? { $and: parts } : { $or: parts })
|
|
167
|
+
}
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
if (key === '$not') {
|
|
171
|
+
const inner = build(raw as Criteria<any>)
|
|
172
|
+
if (inner.length > 0) {
|
|
173
|
+
/** Mongo has no top level `$not`; `$nor` over a single branch is its negation. */
|
|
174
|
+
conditions.push({ $nor: [flatten(inner)] })
|
|
175
|
+
}
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (raw === null) {
|
|
180
|
+
conditions.push({ [key]: { $eq: null } })
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
if (isOperatorSpec(raw)) {
|
|
184
|
+
conditions.push(...fieldConditions(key, raw as Record<string, unknown>))
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
if (Array.isArray(raw)) {
|
|
188
|
+
/**
|
|
189
|
+
* A bare array means "any of these", exactly as it does against a relational store.
|
|
190
|
+
* Exact array equality stays reachable as `{ $eq: [...] }`.
|
|
191
|
+
*/
|
|
192
|
+
conditions.push({ [key]: { $in: raw } })
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
conditions.push({ [key]: raw })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return conditions
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const criteriaToFilter = (
|
|
203
|
+
criteria: Criteria<any> | undefined, refs: Map<string, MongoReference>
|
|
204
|
+
): Document => {
|
|
205
|
+
const conditions = build(criteria)
|
|
206
|
+
if (conditions.length < 1) {
|
|
207
|
+
return {}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return marshalCriteria(flatten(conditions), refs) ?? {}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const sortToMongo = (sort?: Sort<any>[]): Document | undefined => {
|
|
214
|
+
if (sort == null || sort.length < 1) {
|
|
215
|
+
return undefined
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return sort.reduce<Document>((order, entry) => {
|
|
219
|
+
const [field, direction]: [string, number] = typeof entry === 'string'
|
|
220
|
+
? [entry, 1]
|
|
221
|
+
: [entry.field, entry.order === 'desc' ? -1 : 1]
|
|
222
|
+
order[field === 'id' ? '_id' : field] = direction
|
|
223
|
+
|
|
224
|
+
return order
|
|
225
|
+
}, {})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { criteriaToFilter, sortToMongo }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const mongoCriteriaHelper = createMongoCriteriaHelper()
|
|
232
|
+
|
|
233
|
+
/** @deprecated compat:factory-refactor — use `mongoCriteriaHelper.criteriaToFilter(…)` */
|
|
234
|
+
export const criteriaToFilter = (criteria: Criteria<any> | undefined, refs: Map<string, MongoReference>): Document =>
|
|
235
|
+
mongoCriteriaHelper.criteriaToFilter(criteria, refs)
|
package/src/utils/index.ts
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ResourceRecord } from '@owlmeans/resource'
|
|
2
|
+
import type { Collection, Document } from 'mongodb'
|
|
3
|
+
|
|
4
|
+
import type { MongoResource } from '../../types.js'
|
|
5
|
+
|
|
6
|
+
/** A collection's indexes against the ones its resource declares. */
|
|
7
|
+
export interface MongoIndexUtils {
|
|
8
|
+
/**
|
|
9
|
+
* Whether an index the database already holds differs from the one declared.
|
|
10
|
+
*
|
|
11
|
+
* Mongo returns an index's options in ITS order, not the declaration's, so comparing the two
|
|
12
|
+
* documents as JSON reported every index as changed — and every boot then dropped and recreated
|
|
13
|
+
* every index. That is invisible until two processes boot at once, when the second one's drop
|
|
14
|
+
* fails with `IndexNotFound` and takes the service down with it. Only the KEY is order-sensitive
|
|
15
|
+
* (a compound index is its key order); options are a set.
|
|
16
|
+
*/
|
|
17
|
+
indexChanged: (existing: Document, proposed: Document) => boolean
|
|
18
|
+
/** Create the declared indexes the collection lacks and recreate the ones that changed. */
|
|
19
|
+
updateIndexes: (collection: Collection, resource: MongoResource<ResourceRecord>) => Promise<void>
|
|
20
|
+
}
|
package/src/utils/indexes.ts
CHANGED
|
@@ -1,38 +1,58 @@
|
|
|
1
1
|
import type { ResourceRecord } from '@owlmeans/resource'
|
|
2
2
|
import type { MongoResource } from '../types.js'
|
|
3
|
-
import type { Collection } from 'mongodb'
|
|
3
|
+
import type { Collection, Document } from 'mongodb'
|
|
4
|
+
import { logger } from '@owlmeans/log'
|
|
5
|
+
import { REPORTED_FIELDS } from './consts.local.js'
|
|
6
|
+
import type { MongoIndexUtils } from './indexes/types.js'
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
8
|
+
const log = logger('mongo-resource')
|
|
9
|
+
|
|
10
|
+
const options = (spec: Document): [string, unknown][] => Object.entries(spec)
|
|
11
|
+
.filter(([field]) => field !== 'key' && !REPORTED_FIELDS.has(field))
|
|
12
|
+
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
13
|
+
|
|
14
|
+
export const createMongoIndexUtils = (): MongoIndexUtils => {
|
|
15
|
+
const indexChanged = (existing: Document, proposed: Document): boolean => {
|
|
16
|
+
// We do not recreate text indexes.
|
|
17
|
+
if ('weights' in existing) return false
|
|
18
|
+
if (JSON.stringify(existing.key) !== JSON.stringify(proposed.key)) return true
|
|
19
|
+
|
|
20
|
+
return JSON.stringify(options(existing)) !== JSON.stringify(options(proposed))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const updateIndexes = async (collection: Collection, resource: MongoResource<ResourceRecord>): Promise<void> => {
|
|
24
|
+
if (resource.indexes == null) {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
const present = await collection.indexes()
|
|
7
28
|
await Promise.all(resource.indexes.map(
|
|
8
29
|
async index => {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
const propsed = { key: index.index, ...index.options }
|
|
19
|
-
// We do not recreate text indexes
|
|
20
|
-
if (JSON.stringify(existing) !== JSON.stringify(propsed) && !('weights' in existing)) {
|
|
21
|
-
console.info(JSON.stringify(existing))
|
|
22
|
-
console.info(JSON.stringify(propsed))
|
|
23
|
-
console.debug(`Recreate index ${resource.alias} : ${index.name}`)
|
|
24
|
-
await collection.dropIndex(index.name)
|
|
25
|
-
await collection.createIndex(index.index, {
|
|
26
|
-
name: index.name, ...((index.options != null) ? index.options : {})
|
|
27
|
-
})
|
|
28
|
-
}
|
|
29
|
-
} else {
|
|
30
|
-
console.debug(`Create index ${resource.alias} : ${index.name}`)
|
|
31
|
-
await collection.createIndex(index.index, {
|
|
32
|
-
name: index.name, ...((index.options != null) ? index.options : {})
|
|
33
|
-
})
|
|
30
|
+
const create = async () => await collection.createIndex(index.index, {
|
|
31
|
+
name: index.name, ...((index.options != null) ? index.options : {})
|
|
32
|
+
})
|
|
33
|
+
const existing = present.find(_index => _index.name === index.name)
|
|
34
|
+
if (existing == null) {
|
|
35
|
+
log.debug('Create index', { resource: resource.alias, index: index.name })
|
|
36
|
+
await create()
|
|
37
|
+
return
|
|
34
38
|
}
|
|
39
|
+
if (!indexChanged(existing, { key: index.index, ...index.options })) {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
log.debug('Recreate index', { resource: resource.alias, index: index.name })
|
|
43
|
+
try {
|
|
44
|
+
await collection.dropIndex(index.name)
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// IndexNotFound: another process booting alongside this one dropped it first — it is
|
|
47
|
+
// recreating the same declaration, so there is nothing here to fail over.
|
|
48
|
+
if ((e as { code?: number }).code !== 27) throw e
|
|
49
|
+
}
|
|
50
|
+
await create()
|
|
35
51
|
}
|
|
36
52
|
))
|
|
37
53
|
}
|
|
54
|
+
|
|
55
|
+
return { indexChanged, updateIndexes }
|
|
38
56
|
}
|
|
57
|
+
|
|
58
|
+
export const mongoIndexUtils = createMongoIndexUtils()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { BasicContext } from '@owlmeans/context'
|
|
2
|
+
import type { DbConfig, ResourceRecord } from '@owlmeans/resource'
|
|
3
|
+
import type { Collection } from 'mongodb'
|
|
4
|
+
|
|
5
|
+
import type { MongoResource } from '../../types.js'
|
|
6
|
+
|
|
7
|
+
/** A resource collection's initialization over one database. */
|
|
8
|
+
export interface MongoLifeCycleUtils {
|
|
9
|
+
/**
|
|
10
|
+
* Bring a resource's collection to the shape its schema declares.
|
|
11
|
+
*
|
|
12
|
+
* Order is deliberate, and matches the Postgres counterpart:
|
|
13
|
+
*
|
|
14
|
+
* 1. probe for the collection
|
|
15
|
+
* 2. absent → *baseline* every registered migration; present → run the `pre` ones
|
|
16
|
+
* (including the system `$ref:` migrations that convert declared references)
|
|
17
|
+
* 3. create the collection, or update its validator and indexes
|
|
18
|
+
* 4. present → run the `post` migrations
|
|
19
|
+
* 5. reconcile declared references — the second half of their double check: the ledger
|
|
20
|
+
* said whether the `$ref:` migration ran, this probes the collection itself and
|
|
21
|
+
* converts whatever strings still slipped through
|
|
22
|
+
*
|
|
23
|
+
* Step 2 is what stops the two mechanisms colliding. A `pre` migration runs before the
|
|
24
|
+
* validator is tightened, so it can reshape documents the new validator would reject. On a
|
|
25
|
+
* collection this call just created there is nothing to reshape — replaying a historical
|
|
26
|
+
* migration against a collection born in its final shape would at best scan for nothing —
|
|
27
|
+
* so the migrations are recorded as satisfied instead of run.
|
|
28
|
+
*
|
|
29
|
+
* `context` is optional so the existing three-argument call keeps working; without it a
|
|
30
|
+
* migration's `use`/`ref` can only address the owning resource.
|
|
31
|
+
*/
|
|
32
|
+
initializeCollection: (
|
|
33
|
+
config: DbConfig, resource: MongoResource<ResourceRecord>, context?: BasicContext<any>
|
|
34
|
+
) => Promise<Collection>
|
|
35
|
+
/** Create the collection with its validator and its declared indexes. */
|
|
36
|
+
createCollection: (name: string, resource: MongoResource<ResourceRecord>) => Promise<Collection>
|
|
37
|
+
/** Replace the validator of an existing collection and bring its indexes up to date. */
|
|
38
|
+
updateCollection: (name: string, resource: MongoResource<ResourceRecord>) => Promise<Collection>
|
|
39
|
+
}
|