@owlmeans/mongo-resource 0.1.18-rc.2 → 0.1.18-rc.21
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 +2 -2
- package/agent-meta/skills/mongo-resource/SKILL.md +80 -30
- 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.d.ts.map +1 -1
- package/build/declarations.js +5 -6
- package/build/declarations.js.map +1 -1
- package/build/index.d.ts +1 -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 +91 -118
- package/build/resource.js.map +1 -1
- package/build/types.d.ts +16 -5
- package/build/types.d.ts.map +1 -1
- package/build/utils/criteria.d.ts +24 -0
- package/build/utils/criteria.d.ts.map +1 -0
- package/build/utils/criteria.js +221 -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/migrations.d.ts.map +1 -1
- package/build/utils/migrations.js +16 -1
- package/build/utils/migrations.js.map +1 -1
- package/build/utils/refs.d.ts +5 -3
- package/build/utils/refs.d.ts.map +1 -1
- package/build/utils/refs.js +4 -1
- package/build/utils/refs.js.map +1 -1
- package/package.json +6 -6
- package/src/consts.ts +8 -4
- package/src/declarations.ts +5 -6
- package/src/index.ts +1 -0
- package/src/resource.ts +115 -144
- package/src/types.ts +17 -5
- package/src/utils/criteria.ts +248 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/migrations.ts +17 -2
- package/src/utils/refs.ts +8 -6
- package/tests/criteria.spec.ts +114 -0
- package/build/.gitkeep +0 -0
package/src/utils/migrations.ts
CHANGED
|
@@ -5,7 +5,8 @@ import type { Collection, Db } from 'mongodb'
|
|
|
5
5
|
import {
|
|
6
6
|
DEF_MIGRATION_POLL, DEF_MIGRATION_WAIT, DEF_MIGRATIONS_COLLECTION, MONGO_DUPLICATE_KEY
|
|
7
7
|
} from '../consts.js'
|
|
8
|
-
import type { MongoResource, MongoTx } from '../types.js'
|
|
8
|
+
import type { MongoDbService, MongoResource, MongoTx } from '../types.js'
|
|
9
|
+
import { DEFAULT_DB_ALIAS } from '../consts.js'
|
|
9
10
|
import { mongoCollectionName } from './name.js'
|
|
10
11
|
|
|
11
12
|
interface LedgerRecord {
|
|
@@ -40,7 +41,21 @@ export const makeMongoTx = (
|
|
|
40
41
|
return collection.collectionName
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
// Name the target's collection from the target's OWN db config. Resources registered under
|
|
45
|
+
// different db aliases can share one database while carrying different `resourcePrefix`es, and
|
|
46
|
+
// naming one of them with this migration's config silently addresses a collection that does not
|
|
47
|
+
// exist — reads come back empty and writes create a decoy beside the real thing.
|
|
48
|
+
const target = context.resource<MongoResource<ResourceRecord>>(alias)
|
|
49
|
+
let targetConfig = config
|
|
50
|
+
try {
|
|
51
|
+
const mongo = context.service<MongoDbService>(target.serviceAlias ?? target.dbAlias ?? DEFAULT_DB_ALIAS)
|
|
52
|
+
targetConfig = mongo.config(target.dbAlias ?? DEFAULT_DB_ALIAS) ?? config
|
|
53
|
+
} catch {
|
|
54
|
+
// A resource whose service is not reachable from here keeps the caller's config — the
|
|
55
|
+
// historical behaviour, and correct whenever both share one alias.
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return mongoCollectionName(targetConfig, target)
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
return {
|
package/src/utils/refs.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { MisshapedRecord } from '@owlmeans/resource'
|
|
2
|
-
import type { ListCriteria } from '@owlmeans/resource'
|
|
3
2
|
import { ObjectId } from 'mongodb'
|
|
4
3
|
import type { Collection, Document } from 'mongodb'
|
|
5
4
|
|
|
@@ -97,7 +96,7 @@ const marshalCriteriaValue = (value: unknown): unknown => {
|
|
|
97
96
|
}
|
|
98
97
|
|
|
99
98
|
/**
|
|
100
|
-
* Convert
|
|
99
|
+
* Convert a mongo filter the way records are converted: values addressed at `_id` or at a
|
|
101
100
|
* declared reference become `ObjectId`s, and the `id` alias records actually carry is
|
|
102
101
|
* mapped onto `_id` — documents never store `id`, so before this mapping such criteria
|
|
103
102
|
* silently matched nothing.
|
|
@@ -105,17 +104,20 @@ const marshalCriteriaValue = (value: unknown): unknown => {
|
|
|
105
104
|
* Tolerant by design: a value that is not 24 hex passes through unconverted. Criteria are
|
|
106
105
|
* matched against the collection, and against an `ObjectId` typed field a stray string
|
|
107
106
|
* matches nothing — which is exactly what it matched before the field was converted.
|
|
107
|
+
*
|
|
108
|
+
* Operators arrive already in mongo's own vocabulary — {@link criteriaToFilter} translates
|
|
109
|
+
* the portable one first, so this pass only has to convert values.
|
|
108
110
|
*/
|
|
109
111
|
export const marshalCriteria = (
|
|
110
|
-
criteria:
|
|
111
|
-
):
|
|
112
|
+
criteria: Document | undefined, refs: Map<string, MongoReference>
|
|
113
|
+
): Document | undefined => {
|
|
112
114
|
if (criteria == null) {
|
|
113
115
|
return criteria
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
return Object.fromEntries(Object.entries(criteria).map(([key, value]) => {
|
|
117
119
|
if (LOGICAL_OPERATORS.includes(key) && Array.isArray(value)) {
|
|
118
|
-
return [key, value.map(sub => marshalCriteria(sub as
|
|
120
|
+
return [key, value.map(sub => marshalCriteria(sub as Document, refs))]
|
|
119
121
|
}
|
|
120
122
|
if (key === 'id' || key === '_id') {
|
|
121
123
|
return ['_id', marshalCriteriaValue(value)]
|
|
@@ -125,7 +127,7 @@ export const marshalCriteria = (
|
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
return [key, value]
|
|
128
|
-
})) as
|
|
130
|
+
})) as Document
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
/**
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { UnsupportedArgumentError } from '@owlmeans/resource'
|
|
3
|
+
import { ObjectId } from 'mongodb'
|
|
4
|
+
|
|
5
|
+
import { criteriaToFilter, sortToMongo } from '../src/utils/criteria.js'
|
|
6
|
+
import type { MongoReference } from '../src/types.js'
|
|
7
|
+
|
|
8
|
+
const HEX = '6712abcdef0123456789abcd'
|
|
9
|
+
const OTHER = '6712abcdef0123456789abce'
|
|
10
|
+
|
|
11
|
+
const refs = (...fields: string[]): Map<string, MongoReference> =>
|
|
12
|
+
new Map(fields.map(field => [field, { field }]))
|
|
13
|
+
|
|
14
|
+
describe('criteria translation', () => {
|
|
15
|
+
test('reads a bare value as equality, a bare array as membership and null as absence', () => {
|
|
16
|
+
expect(criteriaToFilter({ title: 'a' }, refs())).toEqual({ title: 'a' })
|
|
17
|
+
expect(criteriaToFilter({ title: ['a', 'b'] }, refs())).toEqual({ title: { $in: ['a', 'b'] } })
|
|
18
|
+
expect(criteriaToFilter({ title: null }, refs())).toEqual({ title: { $eq: null } })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('skips an undefined value rather than demanding one', () => {
|
|
22
|
+
/** An untouched filter must never empty a list. */
|
|
23
|
+
expect(criteriaToFilter({ title: undefined }, refs())).toEqual({})
|
|
24
|
+
expect(criteriaToFilter(undefined, refs())).toEqual({})
|
|
25
|
+
expect(criteriaToFilter({ title: 'a', slug: undefined }, refs())).toEqual({ title: 'a' })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('reads sibling fields as a conjunction', () => {
|
|
29
|
+
expect(criteriaToFilter({ title: 'a', slug: 'b' }, refs())).toEqual({ title: 'a', slug: 'b' })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('passes mongo\'s own operators through and merges them per field', () => {
|
|
33
|
+
expect(criteriaToFilter({ age: { $gte: 1, $lt: 5 } }, refs())).toEqual({ age: { $gte: 1, $lt: 5 } })
|
|
34
|
+
expect(criteriaToFilter({ title: { $nin: ['a'] } }, refs())).toEqual({ title: { $nin: ['a'] } })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('answers presence as a comparison against null, the way the other stores do', () => {
|
|
38
|
+
expect(criteriaToFilter({ slug: { $null: true } }, refs())).toEqual({ slug: { $eq: null } })
|
|
39
|
+
expect(criteriaToFilter({ slug: { $null: false } }, refs())).toEqual({ slug: { $ne: null } })
|
|
40
|
+
expect(criteriaToFilter({ slug: { $exists: true } }, refs())).toEqual({ slug: { $ne: null } })
|
|
41
|
+
expect(criteriaToFilter({ slug: { $exists: false } }, refs())).toEqual({ slug: { $eq: null } })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('turns the text operators into anchored regular expressions', () => {
|
|
45
|
+
expect(criteriaToFilter({ title: { $like: 'ab_%' } }, refs()))
|
|
46
|
+
.toEqual({ title: { $regex: '^ab..*$' } })
|
|
47
|
+
expect(criteriaToFilter({ title: { $ilike: 'a%' } }, refs()))
|
|
48
|
+
.toEqual({ title: { $regex: '^a.*$', $options: 'i' } })
|
|
49
|
+
expect(criteriaToFilter({ title: { $startsWith: 'a.b' } }, refs()))
|
|
50
|
+
.toEqual({ title: { $regex: '^a\\.b' } })
|
|
51
|
+
expect(criteriaToFilter({ title: { $endsWith: 'a.b' } }, refs()))
|
|
52
|
+
.toEqual({ title: { $regex: 'a\\.b$' } })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('splits two operators that would claim the same mongo key', () => {
|
|
56
|
+
expect(criteriaToFilter({ title: { $startsWith: 'a', $endsWith: 'z' } }, refs()))
|
|
57
|
+
.toEqual({ title: { $regex: '^a' }, $and: [{ title: { $regex: 'z$' } }] })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('turns a range into its bounds and refuses a malformed one', () => {
|
|
61
|
+
expect(criteriaToFilter({ age: { $between: [1, 5] } }, refs()))
|
|
62
|
+
.toEqual({ age: { $gte: 1, $lte: 5 } })
|
|
63
|
+
expect(() => criteriaToFilter({ age: { $between: [1] } }, refs()))
|
|
64
|
+
.toThrow(UnsupportedArgumentError)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('maps the array operators onto their mongo counterparts', () => {
|
|
68
|
+
expect(criteriaToFilter({ tags: { $contains: ['a', 'b'] } }, refs()))
|
|
69
|
+
.toEqual({ tags: { $all: ['a', 'b'] } })
|
|
70
|
+
expect(criteriaToFilter({ tags: { $overlaps: ['a', 'b'] } }, refs()))
|
|
71
|
+
.toEqual({ tags: { $in: ['a', 'b'] } })
|
|
72
|
+
expect(criteriaToFilter({ tags: { $contained: ['a', 'b'] } }, refs()))
|
|
73
|
+
.toEqual({ tags: { $not: { $elemMatch: { $nin: ['a', 'b'] } }, $ne: null } })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('carries the logical branches over, negating through $nor', () => {
|
|
77
|
+
expect(criteriaToFilter({ $or: [{ title: 'a' }, { title: 'b' }] }, refs()))
|
|
78
|
+
.toEqual({ $or: [{ title: 'a' }, { title: 'b' }] })
|
|
79
|
+
expect(criteriaToFilter({ $and: [{ title: 'a', slug: 'b' }] }, refs()))
|
|
80
|
+
.toEqual({ $and: [{ title: 'a', slug: 'b' }] })
|
|
81
|
+
/** Mongo has no top level `$not`. */
|
|
82
|
+
expect(criteriaToFilter({ $not: { title: 'a' } }, refs()))
|
|
83
|
+
.toEqual({ $nor: [{ title: 'a' }] })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('refuses an operator this store cannot answer', () => {
|
|
87
|
+
expect(() => criteriaToFilter({ title: { $nope: 1 } }, refs())).toThrow(UnsupportedArgumentError)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('converts ids and declared references on the way through', () => {
|
|
91
|
+
expect(criteriaToFilter({ id: HEX }, refs())).toEqual({ _id: new ObjectId(HEX) })
|
|
92
|
+
expect(criteriaToFilter({ ownerId: [HEX, OTHER] }, refs('ownerId')))
|
|
93
|
+
.toEqual({ ownerId: { $in: [new ObjectId(HEX), new ObjectId(OTHER)] } })
|
|
94
|
+
expect(criteriaToFilter({ ownerIds: { $contains: [HEX] } }, refs('ownerIds')))
|
|
95
|
+
.toEqual({ ownerIds: { $all: [new ObjectId(HEX)] } })
|
|
96
|
+
/** Not a mongo id — probes tolerantly, matching nothing rather than throwing. */
|
|
97
|
+
expect(criteriaToFilter({ ownerId: 'ext:key' }, refs('ownerId'))).toEqual({ ownerId: 'ext:key' })
|
|
98
|
+
/** A 24-hex value on a field that was never declared a reference stays a string. */
|
|
99
|
+
expect(criteriaToFilter({ title: HEX }, refs('ownerId'))).toEqual({ title: HEX })
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('sort translation', () => {
|
|
104
|
+
test('reads a bare field as ascending and maps id onto _id', () => {
|
|
105
|
+
expect(sortToMongo(['createdAt', { field: 'title', order: 'desc' }, 'id']))
|
|
106
|
+
.toEqual({ createdAt: 1, title: -1, _id: 1 })
|
|
107
|
+
expect(sortToMongo([{ field: 'title' }])).toEqual({ title: 1 })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('answers nothing when nothing is asked for', () => {
|
|
111
|
+
expect(sortToMongo()).toBeUndefined()
|
|
112
|
+
expect(sortToMongo([])).toBeUndefined()
|
|
113
|
+
})
|
|
114
|
+
})
|
package/build/.gitkeep
DELETED
|
File without changes
|