@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.
Files changed (47) hide show
  1. package/README.md +232 -71
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/mongo-resource/SKILL.md +80 -30
  4. package/build/consts.d.ts +8 -4
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +8 -4
  7. package/build/consts.js.map +1 -1
  8. package/build/declarations.d.ts.map +1 -1
  9. package/build/declarations.js +5 -6
  10. package/build/declarations.js.map +1 -1
  11. package/build/index.d.ts +1 -0
  12. package/build/index.d.ts.map +1 -1
  13. package/build/index.js +1 -0
  14. package/build/index.js.map +1 -1
  15. package/build/resource.d.ts +2 -2
  16. package/build/resource.d.ts.map +1 -1
  17. package/build/resource.js +91 -118
  18. package/build/resource.js.map +1 -1
  19. package/build/types.d.ts +16 -5
  20. package/build/types.d.ts.map +1 -1
  21. package/build/utils/criteria.d.ts +24 -0
  22. package/build/utils/criteria.d.ts.map +1 -0
  23. package/build/utils/criteria.js +221 -0
  24. package/build/utils/criteria.js.map +1 -0
  25. package/build/utils/index.d.ts +1 -0
  26. package/build/utils/index.d.ts.map +1 -1
  27. package/build/utils/index.js +1 -0
  28. package/build/utils/index.js.map +1 -1
  29. package/build/utils/migrations.d.ts.map +1 -1
  30. package/build/utils/migrations.js +16 -1
  31. package/build/utils/migrations.js.map +1 -1
  32. package/build/utils/refs.d.ts +5 -3
  33. package/build/utils/refs.d.ts.map +1 -1
  34. package/build/utils/refs.js +4 -1
  35. package/build/utils/refs.js.map +1 -1
  36. package/package.json +6 -6
  37. package/src/consts.ts +8 -4
  38. package/src/declarations.ts +5 -6
  39. package/src/index.ts +1 -0
  40. package/src/resource.ts +115 -144
  41. package/src/types.ts +17 -5
  42. package/src/utils/criteria.ts +248 -0
  43. package/src/utils/index.ts +1 -0
  44. package/src/utils/migrations.ts +17 -2
  45. package/src/utils/refs.ts +8 -6
  46. package/tests/criteria.spec.ts +114 -0
  47. package/build/.gitkeep +0 -0
package/src/resource.ts CHANGED
@@ -1,19 +1,23 @@
1
1
  import { appendContextual, assertContext } from '@owlmeans/context'
2
- import type { BasicContext, Contextual } from '@owlmeans/context'
3
2
  import { DEFAULT_DB_ALIAS, DEFAULT_PAGE_SIZE } from './consts.js'
4
3
  import { MigrationStage } from '@owlmeans/resource'
5
- import type { ListCriteria, ResourceMaker, ResourceRecord } from '@owlmeans/resource'
4
+ import type {
5
+ Criteria, FirstOptions, ListOptions, ListResult, ResourceRecord, WriteOptions
6
+ } from '@owlmeans/resource'
6
7
  import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
7
8
  import type { MongoDbService, MongoReference, MongoRefOptions, MongoResource, MongoTx } from './types.js'
8
9
  import { initializeCollection } from './utils/life-cycle.js'
9
10
  import { getDeclaration } from './declarations.js'
10
11
  import { ObjectId } from 'mongodb'
11
- import { MisshapedRecord, RecordExists, UnknownRecordError, UnsupportedArgumentError, RecordUpdateFailed, prepareListOptions } from '@owlmeans/resource'
12
+ import type { CreateIndexesOptions, Document, IndexSpecification } from 'mongodb'
13
+ import {
14
+ MisshapedRecord, RecordExists, UnknownRecordError, UnsupportedArgumentError, RecordUpdateFailed
15
+ } from '@owlmeans/resource'
12
16
  import type { JSONSchemaType } from 'ajv'
13
17
  import { getSchemaSecureFeilds } from './helper.js'
18
+ import { criteriaToFilter, sortToMongo } from './utils/criteria.js'
14
19
  import {
15
- demarshalRefs, identityCriteria, makeRefMigration, marshalCriteria, marshalReference,
16
- refMigrationName
20
+ demarshalRefs, identityCriteria, makeRefMigration, marshalReference, refMigrationName
17
21
  } from './utils/refs.js'
18
22
 
19
23
  type Config = ServerConfig
@@ -23,13 +27,14 @@ export const makeMongoResource = <
23
27
  R extends ResourceRecord, T extends MongoResource<R> = MongoResource<R>
24
28
  >(
25
29
  alias: string, dbAlias: string = DEFAULT_DB_ALIAS, serviceAlias: string = DEFAULT_DB_ALIAS,
26
- makeCustomResource?: ResourceMaker<R, T>, collectionName?: string
30
+ collectionName?: string
27
31
  ): T => {
28
32
  const location = `mongo-resource:${alias}`
29
33
 
30
34
  /**
31
35
  * Live view — references may be declared after the resource is built, and the
32
- * declarations are module scoped so they survive `reinitializeContext`.
36
+ * declarations live at module scope keyed by alias, so a second maker run for the
37
+ * same alias sees everything already declared for it.
33
38
  */
34
39
  const refs = (): Map<string, MongoReference> => getDeclaration(alias).references
35
40
 
@@ -39,82 +44,112 @@ export const makeMongoResource = <
39
44
  return demarshalRefs(record, refs())
40
45
  }
41
46
 
47
+ /**
48
+ * A read addressed either way. An id is matched tolerantly: a string that is not a mongo id
49
+ * finds nothing, where handing it to `ObjectId` would raise a driver error at a call site
50
+ * that only asked whether the record exists.
51
+ */
52
+ const filterOf = (idOrWhere: string | Criteria<R>): Document =>
53
+ typeof idOrWhere === 'string'
54
+ ? identityCriteria('id', idOrWhere, refs())
55
+ : criteriaToFilter(idOrWhere, refs())
56
+
57
+ /**
58
+ * The single implementation both overloads of `load` and `get` stand on — an id and a
59
+ * criteria object differ only in how the filter is built.
60
+ */
61
+ const loadOne = async (
62
+ idOrWhere: string | Criteria<R>, opts?: FirstOptions<R>
63
+ ): Promise<R | null> => {
64
+ const sort = sortToMongo(opts?.sort)
65
+ const record = await resource.collection.findOne(
66
+ filterOf(idOrWhere), sort != null ? { sort } : {}
67
+ )
68
+
69
+ return record != null ? demarshal(record as unknown as R) : null
70
+ }
71
+
42
72
  const resource: T = appendContextual<T>(alias, {
43
- get: async (id, field, opts) => {
44
- const record = await resource.load(id, field, opts)
73
+ get: (async (idOrWhere: string | Criteria<R>, opts?: FirstOptions<R>): Promise<R> => {
74
+ const record = await loadOne(idOrWhere, opts)
45
75
  if (record == null) {
46
- throw new UnknownRecordError(id)
76
+ throw new UnknownRecordError(
77
+ typeof idOrWhere === 'string' ? idOrWhere : JSON.stringify(idOrWhere)
78
+ )
47
79
  }
48
80
 
49
81
  return record
50
- },
82
+ }) as T['get'],
51
83
 
52
- load: async (id, field, opts) => {
53
- if (typeof field === 'object') {
54
- opts = field
55
- field = field.field
84
+ load: loadOne as T['load'],
85
+
86
+ list: async (where?: Criteria<R>, opts?: ListOptions<R>): Promise<ListResult<R>> => {
87
+ const filter = criteriaToFilter(where, refs())
88
+ const total = await resource.collection.countDocuments(filter)
89
+
90
+ /** `size: 0` is the explicit ask for everything; an omitted size pages by default. */
91
+ const size = opts?.size ?? DEFAULT_PAGE_SIZE
92
+ const page = opts?.page ?? 0
93
+ if (size < 1 && page !== 0) {
94
+ /** Nothing to page through — answering page 0 instead would be a silent wrong answer. */
95
+ throw new UnsupportedArgumentError('page-without-size')
56
96
  }
57
- field = field ?? '_id'
58
- if (opts?.ttl != null) {
59
- throw new UnsupportedArgumentError('ttl')
97
+
98
+ if (total === 0) {
99
+ return size > 0 ? { items: [], total, page, size } : { items: [], total }
60
100
  }
61
101
 
62
- const record = await resource.collection.findOne(identityCriteria(field, id, refs()))
63
- if (record != null) {
64
- demarshal(record)
102
+ let cursor = resource.collection.find(filter)
103
+ const sort = sortToMongo(opts?.sort)
104
+ if (sort != null) {
105
+ cursor = cursor.sort(sort)
106
+ }
107
+ if (size > 0) {
108
+ cursor = cursor.skip(page * size).limit(size)
65
109
  }
66
110
 
67
- return record
111
+ const items = (await cursor.toArray()).map(item => demarshal({ ...item } as unknown as R))
112
+
113
+ return size > 0 ? { items, total, page, size } : { items, total }
68
114
  },
69
115
 
70
- update: async (record, opts) => {
71
- let field = '_id'
72
- if (typeof opts === 'string') {
73
- field = opts
74
- } else if (typeof opts === 'object') {
75
- field = opts.field ?? field
76
- }
116
+ count: async (where?: Criteria<R>): Promise<number> =>
117
+ await resource.collection.countDocuments(criteriaToFilter(where, refs())),
77
118
 
78
- if (field !== '_id' && record[field as keyof typeof record] == null) {
79
- throw new MisshapedRecord('no-field-value')
119
+ update: async (record: Partial<R>, opts?: WriteOptions): Promise<R> => {
120
+ if (opts?.ttl != null) {
121
+ throw new UnsupportedArgumentError('ttl')
80
122
  }
81
-
82
- const id = field == '_id' ? record.id : record[field as keyof typeof record] as string
123
+ const id = record.id
83
124
  if (id == null) {
84
125
  throw new MisshapedRecord('id')
85
126
  }
86
127
 
87
- const original = await resource.get(id, field)
128
+ /** Absence is an error, not a silent no-op — `replaceOne` would just match nothing. */
129
+ const original = await resource.get(id)
88
130
 
89
- const replace = { ...record, _id: new ObjectId(original.id) }
90
- if (replace.id != null) {
91
- delete replace.id
92
- }
131
+ const replace: Document = { ...record }
132
+ /**
133
+ * Documents never store `id`, and a replacement that omits `_id` keeps the one the
134
+ * document already carries — so the whole record is replaced without touching its key.
135
+ */
136
+ delete replace.id
93
137
 
94
138
  const result = await resource.collection.replaceOne(
95
- identityCriteria(field, id, refs()),
139
+ identityCriteria('id', original.id as string, refs()),
96
140
  _prepareValues(replace, resource.schema as JSONSchemaType<any>, refs())
97
141
  )
98
142
  if (!result.acknowledged) {
99
- throw new RecordUpdateFailed(`${field}:${id}`)
143
+ throw new RecordUpdateFailed(`id:${id}`)
100
144
  }
101
145
 
102
- return resource.get(id, opts)
146
+ return await resource.get(original.id as string)
103
147
  },
104
148
 
105
- save: async (record, opts) => {
106
- const id = record.id
107
- if (id != null || (
108
- typeof opts === 'string' && record[opts as keyof typeof record] != null
109
- ) || (
110
- typeof opts === 'object' && ("field" in opts)
111
- && record[opts.field as keyof typeof record] != null
112
- )) {
113
- return resource.update(record, opts)
114
- }
115
-
116
- return resource.create(record, typeof opts !== 'string' ? opts : undefined)
117
- },
149
+ save: async (record: Partial<R>, opts?: WriteOptions): Promise<R> =>
150
+ record.id != null
151
+ ? await resource.update(record, opts)
152
+ : await resource.create(record, opts),
118
153
 
119
154
  getDefaults: () => {
120
155
  const schema: JSONSchemaType<unknown> | undefined = resource.schema as JSONSchemaType<unknown>
@@ -130,14 +165,14 @@ export const makeMongoResource = <
130
165
  }, {})
131
166
  },
132
167
 
133
- create: async (record, opts) => {
168
+ create: async (record: Partial<R>, opts?: WriteOptions): Promise<R> => {
134
169
  if ("id" in record && record.id == null) {
135
170
  delete record.id
136
171
  }
137
172
  if (record.id != null) {
138
173
  throw new RecordExists('id-present')
139
174
  }
140
- if (opts != null && opts.ttl != null) {
175
+ if (opts?.ttl != null) {
141
176
  throw new UnsupportedArgumentError('ttl')
142
177
  }
143
178
  const result = await resource.collection.insertOne({
@@ -149,89 +184,33 @@ export const makeMongoResource = <
149
184
  throw new RecordUpdateFailed(`creation`)
150
185
  }
151
186
 
152
- const id = result.insertedId
153
-
154
- return resource.get(id.toString())
187
+ return await resource.get(result.insertedId.toString())
155
188
  },
156
189
 
157
- delete: async (id, opts) => {
158
- let record: R | null = null
159
- if (typeof id === 'object') {
160
- if (id.id == null) {
161
- throw new MisshapedRecord('id')
162
- }
163
- record = await resource.load(id.id)
164
- } else {
165
- record = await resource.load(id, opts)
166
- }
190
+ /** Atomic: the record is handed back by the very operation that removed it. */
191
+ delete: async (id: string): Promise<R | null> => {
192
+ const record = await resource.collection.findOneAndDelete(identityCriteria('id', id, refs()))
167
193
 
168
- if (record == null) {
169
- return null
170
- }
171
-
172
- let field = '_id'
173
- if (typeof opts === 'string') {
174
- field = opts
175
- opts = undefined
176
- } else if (typeof opts === 'object') {
177
- field = opts.field ?? field
178
- }
179
-
180
- const _id = field == '_id' ? record.id : record[field as keyof typeof record] as string
181
- if (id == null) {
182
- throw new MisshapedRecord('id')
183
- }
184
-
185
- const result = await resource.collection.deleteOne(identityCriteria(field, _id as string, refs()))
186
- if (!result.acknowledged || result.deletedCount === 0) {
187
- return null
188
- }
189
-
190
- return record
194
+ return record != null ? demarshal(record as unknown as R) : null
191
195
  },
192
196
 
193
- pick: async (id, opts) => {
194
- const record = await resource.delete(id, opts)
197
+ take: async (id: string): Promise<R> => {
198
+ const record = await resource.delete(id)
195
199
  if (record == null) {
196
- throw new UnknownRecordError(typeof id == 'string' ? id : (id.id ?? 'unknown'))
200
+ throw new UnknownRecordError(id)
197
201
  }
198
202
 
199
203
  return record
200
204
  },
201
205
 
202
- list: async (criteria, opts) => {
203
- const options = prepareListOptions(DEFAULT_PAGE_SIZE, criteria, opts)
204
-
205
- criteria = marshalCriteria(options.criteria, refs()) ?? {}
206
- const pager = options.pager ?? {}
207
-
208
- const size = pager?.size ?? DEFAULT_PAGE_SIZE
209
- const total = await resource.collection.countDocuments(criteria as ListCriteria)
210
- pager.total = total
211
-
212
- const skip = (pager.page ?? 0) * size
213
- if (total === 0 && skip >= total) {
214
- return { items: [], pager }
206
+ purge: async (where: Criteria<R>): Promise<number> => {
207
+ const filter = criteriaToFilter(where, refs())
208
+ if (Object.keys(filter).length < 1) {
209
+ /** An empty filter here would empty the collection. */
210
+ throw new UnsupportedArgumentError('purge:no-criteria')
215
211
  }
216
212
 
217
- let cursor = resource.collection.find(criteria as ListCriteria)
218
- .skip(skip).limit(size)
219
-
220
- if (pager.sort != null) {
221
- cursor = cursor.sort(
222
- typeof pager.sort === 'string'
223
- ? pager.sort
224
- : pager.sort.reduce((sort, [field, order]) =>
225
- ({ ...sort, [field as keyof typeof sort]: order ? -1 : 1 }), {}
226
- )
227
- )
228
- }
229
-
230
- const items = await cursor.toArray()
231
-
232
- return {
233
- pager, items: items.map(item => demarshal({ ...item } as unknown as R))
234
- }
213
+ return (await resource.collection.deleteMany(filter)).deletedCount
235
214
  },
236
215
 
237
216
  lock: async (record, fields) => {
@@ -272,17 +251,17 @@ export const makeMongoResource = <
272
251
  return await mongo.client(dbAlias)
273
252
  },
274
253
 
275
- index: (name, index, options) => {
254
+ /**
255
+ * `index`, `migration` and `reference` hand the resource itself back so declarations
256
+ * chain — a return an object literal can't express, hence the member level casts: each
257
+ * implementation returns the closed over `resource`, which is that very object.
258
+ */
259
+ index: ((name: string, index: IndexSpecification, options?: CreateIndexesOptions) => {
276
260
  resource.indexes = resource.indexes ?? []
277
261
  resource.indexes.push({ name, index, options })
278
262
  return resource
279
- },
263
+ }) as T['index'],
280
264
 
281
- /**
282
- * `migration` and `reference` are `this`-returning in the interface, which an object
283
- * literal can't express — hence the member level casts: the implementations return
284
- * the closed over `resource`, which is that very object.
285
- */
286
265
  migration: ((name: string, apply: (tx: MongoTx) => Promise<void>, stage?: MigrationStage) => {
287
266
  getDeclaration(alias).migrations.register(name, apply, stage)
288
267
  return resource
@@ -308,11 +287,12 @@ export const makeMongoResource = <
308
287
  } as Partial<T>)
309
288
 
310
289
  // Explicit collection name override (decoupled from the registration alias, which may
311
- // contain characters that aren't valid in a collection name). Survives reinitializeContext
312
- // because it's threaded back into the recursive makeMongoResource call below.
290
+ // contain characters that aren't valid in a collection name).
313
291
  if (collectionName != null) {
314
292
  resource.name = collectionName
315
293
  }
294
+ resource.dbAlias = dbAlias
295
+ resource.serviceAlias = serviceAlias
316
296
 
317
297
  resource.init = async () => {
318
298
  const context = assertContext<Config, Context>(resource.ctx as Context, location)
@@ -325,15 +305,6 @@ export const makeMongoResource = <
325
305
  )
326
306
  }
327
307
 
328
- resource.reinitializeContext = <Type extends Contextual>(context: BasicContext<Config>) => {
329
- const resource = (makeCustomResource?.(dbAlias, serviceAlias)
330
- ?? makeMongoResource<R, T>(alias, dbAlias, serviceAlias, makeCustomResource, collectionName)) as unknown as Type
331
-
332
- resource.ctx = context
333
-
334
- return resource as Type
335
- }
336
-
337
308
  return resource
338
309
  }
339
310
 
package/src/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type {
2
- Resource, ResourceRecord, ResourceDbService, DbLocker, ResourceLocker, MigratableResource
2
+ Resource, ResourceRecord, ResourceDbService, DbLocker, LockableResource, MigratableResource
3
3
  } from '@owlmeans/resource'
4
4
  import type { Collection, CreateIndexesOptions, Db, IndexSpecification, MongoClient } from 'mongodb'
5
5
  import type { AnySchema } from 'ajv'
@@ -44,20 +44,32 @@ export interface MongoRefOptions {
44
44
  noIndex?: boolean
45
45
  }
46
46
 
47
- export interface MongoResource<T extends ResourceRecord> extends Resource<T>, ResourceLocker<T>, MigratableResource<MongoTx> {
47
+ export interface MongoResource<T extends ResourceRecord> extends Resource<T>, LockableResource<T>,
48
+ MigratableResource<MongoTx, MongoResource<T>> {
48
49
  name?: string
50
+ /**
51
+ * The db-config alias this resource was registered against.
52
+ *
53
+ * Exposed because a collection's NAME depends on it: two resources in one database can carry
54
+ * different `resourcePrefix`es, so anything naming a collection on another resource's behalf —
55
+ * a migration reaching across aliases, for one — has to read that resource's own config rather
56
+ * than assume its caller's.
57
+ */
58
+ dbAlias?: string
59
+ serviceAlias?: string
49
60
  schema?: AnySchema
50
61
  indexes?: Array<{ name: string, index: IndexSpecification, options?: CreateIndexesOptions }>
51
62
  collection: Collection
52
63
  db: () => Promise<Db>
53
64
  client: () => Promise<MongoClient>
54
- index: <Type extends MongoResource<T>>(name: string, index: IndexSpecification, options?: CreateIndexesOptions) => Type
65
+ index: (name: string, index: IndexSpecification, options?: CreateIndexesOptions) => this
55
66
  /**
56
67
  * Declare that a field stores another record's id.
57
68
  *
58
69
  * Chainable and idempotent like {@link MigratableResource.migration}, and stored the same
59
- * way — per alias at module scope — because losing the declaration to a context rebuild
60
- * would silently stop the string/ObjectId conversion for the field.
70
+ * way — per alias at module scope — so a maker that runs more than once for the same alias
71
+ * re-declares the same entry rather than losing it. A lost declaration would silently stop
72
+ * the string/ObjectId conversion for the field.
61
73
  *
62
74
  * Declare only fields whose values really are mongo ids (assigned from another record's
63
75
  * `id`). Composite keys, external provider ids, DIDs and business slugs must stay
@@ -0,0 +1,248 @@
1
+ import { UnsupportedArgumentError } from '@owlmeans/resource'
2
+ import type { Criteria, Sort } from '@owlmeans/resource'
3
+ import type { Document } from 'mongodb'
4
+
5
+ import type { MongoReference } from '../types.js'
6
+ import { marshalCriteria } from './refs.js'
7
+
8
+ /**
9
+ * Operators mongo speaks natively and that mean the same thing here as they do in SQL and in
10
+ * the in-memory engine. Everything else in the shared vocabulary is rewritten below into a
11
+ * mongo expression with the same meaning — a criteria object has to answer identically
12
+ * whichever store it reaches.
13
+ */
14
+ const NATIVE = new Set(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin', '$regex'])
15
+
16
+ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
17
+
18
+ /**
19
+ * A SQL `LIKE` pattern as an anchored regular expression: `%` is any run, `_` is one
20
+ * character, and `\` escapes either. The mapping is the one the in-memory engine applies, so
21
+ * the same pattern selects the same records against a collection.
22
+ */
23
+ const likeToRegExp = (pattern: string): string => {
24
+ let source = ''
25
+ for (let index = 0; index < pattern.length; index++) {
26
+ const char = pattern[index]!
27
+ if (char === '\\' && index + 1 < pattern.length) {
28
+ source += escapeRegExp(pattern[++index]!)
29
+ continue
30
+ }
31
+ if (char === '%') { source += '.*'; continue }
32
+ if (char === '_') { source += '.'; continue }
33
+ source += escapeRegExp(char)
34
+ }
35
+
36
+ return `^${source}$`
37
+ }
38
+
39
+ const asArray = (operand: unknown): unknown[] => Array.isArray(operand) ? operand : [operand]
40
+
41
+ /**
42
+ * One operator as the mongo field expression that answers it.
43
+ *
44
+ * `$exists` and `$null` both become a comparison against `null`, not mongo's own `$exists`:
45
+ * the shared vocabulary asks whether a field *has a value*, which the relational and
46
+ * in-memory stores answer as `IS NULL` / `value == null`. Mongo's `$exists` answers a
47
+ * different question — whether the key is present — and a key present but null would part
48
+ * the three stores over one criteria object.
49
+ *
50
+ * @throws {UnsupportedArgumentError} on an operator this store cannot answer.
51
+ */
52
+ const operatorToFilter = (field: string, operator: string, operand: unknown): Document => {
53
+ if (NATIVE.has(operator)) {
54
+ return { [operator]: operand }
55
+ }
56
+ switch (operator) {
57
+ case '$exists':
58
+ return operand === false ? { $eq: null } : { $ne: null }
59
+ case '$null':
60
+ return operand === false ? { $ne: null } : { $eq: null }
61
+ case '$like':
62
+ return { $regex: likeToRegExp(`${operand}`) }
63
+ case '$ilike':
64
+ return { $regex: likeToRegExp(`${operand}`), $options: 'i' }
65
+ case '$startsWith':
66
+ return { $regex: `^${escapeRegExp(`${operand}`)}` }
67
+ case '$endsWith':
68
+ return { $regex: `${escapeRegExp(`${operand}`)}$` }
69
+ case '$between':
70
+ if (!Array.isArray(operand) || operand.length !== 2) {
71
+ throw new UnsupportedArgumentError(`criteria:$between:${field}`)
72
+ }
73
+
74
+ return { $gte: operand[0], $lte: operand[1] }
75
+ /** Array membership, mirroring the postgres array operators `@>`, `<@` and `&&`. */
76
+ case '$contains':
77
+ return { $all: asArray(operand) }
78
+ case '$contained':
79
+ /**
80
+ * Nothing outside the operand list may appear in the field. The `$ne: null` keeps an
81
+ * absent field out of the result — `$not` alone is satisfied by a document that has no
82
+ * such field at all, where the relational store answers NULL and matches nothing.
83
+ */
84
+ return { $not: { $elemMatch: { $nin: asArray(operand) } }, $ne: null }
85
+ case '$overlaps':
86
+ return { $in: asArray(operand) }
87
+ default:
88
+ throw new UnsupportedArgumentError(`criteria-operator:${operator}`)
89
+ }
90
+ }
91
+
92
+ /**
93
+ * An object naming at least one `$` key is a spec, not a value to compare against. A Date and
94
+ * an array are values even though both are objects.
95
+ */
96
+ const isOperatorSpec = (value: unknown): value is Record<string, unknown> =>
97
+ value != null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)
98
+ && Object.keys(value).some(key => key.startsWith('$'))
99
+
100
+ /**
101
+ * One field's criteria as mongo conditions. Operators that translate into the same mongo key —
102
+ * `$like` and `$startsWith` both produce `$regex` — are emitted as separate conditions rather
103
+ * than merged, so neither silently overwrites the other.
104
+ */
105
+ const fieldConditions = (field: string, spec: Record<string, unknown>): Document[] => {
106
+ const merged: Document = {}
107
+ const conditions: Document[] = []
108
+
109
+ for (const [operator, operand] of Object.entries(spec)) {
110
+ if (operand === undefined) {
111
+ continue
112
+ }
113
+ const fragment = operatorToFilter(field, operator, operand)
114
+ if (Object.keys(fragment).some(key => key in merged)) {
115
+ conditions.push({ [field]: fragment })
116
+ continue
117
+ }
118
+ Object.assign(merged, fragment)
119
+ }
120
+
121
+ if (Object.keys(merged).length > 0) {
122
+ conditions.unshift({ [field]: merged })
123
+ }
124
+
125
+ return conditions
126
+ }
127
+
128
+ /**
129
+ * Flatten the conditions into one filter document. Mongo reads sibling keys as a conjunction,
130
+ * so only the ones that would overwrite an earlier key — two `$regex` expressions over the
131
+ * same field, say — need an explicit `$and`.
132
+ */
133
+ const flatten = (conditions: Document[]): Document => {
134
+ const filter: Document = {}
135
+ const conflicting: Document[] = []
136
+
137
+ for (const condition of conditions) {
138
+ if (Object.keys(condition).some(key => key in filter)) {
139
+ conflicting.push(condition)
140
+ continue
141
+ }
142
+ Object.assign(filter, condition)
143
+ }
144
+
145
+ if (conflicting.length > 0) {
146
+ filter.$and = [...(Array.isArray(filter.$and) ? filter.$and : []), ...conflicting]
147
+ }
148
+
149
+ return filter
150
+ }
151
+
152
+ const build = (criteria: Criteria<any> | undefined): Document[] => {
153
+ const conditions: Document[] = []
154
+
155
+ for (const [key, raw] of Object.entries(criteria ?? {})) {
156
+ /**
157
+ * An untouched filter must never empty a list, so `undefined` is skipped rather than
158
+ * compared. `null` asks for the absence of a value.
159
+ */
160
+ if (raw === undefined) {
161
+ continue
162
+ }
163
+
164
+ if (key === '$and' || key === '$or') {
165
+ const parts = (Array.isArray(raw) ? raw : [raw])
166
+ .map(part => build(part as Criteria<any>))
167
+ .filter(part => part.length > 0)
168
+ .map(part => flatten(part))
169
+ if (parts.length > 0) {
170
+ conditions.push(key === '$and' ? { $and: parts } : { $or: parts })
171
+ }
172
+ continue
173
+ }
174
+ if (key === '$not') {
175
+ const inner = build(raw as Criteria<any>)
176
+ if (inner.length > 0) {
177
+ /** Mongo has no top level `$not`; `$nor` over a single branch is its negation. */
178
+ conditions.push({ $nor: [flatten(inner)] })
179
+ }
180
+ continue
181
+ }
182
+
183
+ if (raw === null) {
184
+ conditions.push({ [key]: { $eq: null } })
185
+ continue
186
+ }
187
+ if (isOperatorSpec(raw)) {
188
+ conditions.push(...fieldConditions(key, raw as Record<string, unknown>))
189
+ continue
190
+ }
191
+ if (Array.isArray(raw)) {
192
+ /**
193
+ * A bare array means "any of these", exactly as it does against a relational store.
194
+ * Exact array equality stays reachable as `{ $eq: [...] }`.
195
+ */
196
+ conditions.push({ [key]: { $in: raw } })
197
+ continue
198
+ }
199
+
200
+ conditions.push({ [key]: raw })
201
+ }
202
+
203
+ return conditions
204
+ }
205
+
206
+ /**
207
+ * Translate `Criteria<T>` into the filter a collection takes.
208
+ *
209
+ * Two passes: the shared operator vocabulary becomes mongo expressions, then
210
+ * {@link marshalCriteria} converts the values addressed at `_id` or at a declared reference
211
+ * into `ObjectId`s and maps the `id` alias onto `_id`. Both halves are needed — a criteria
212
+ * object carries string ids and portable operators, a collection stores neither.
213
+ *
214
+ * An empty result is an empty filter, which matches everything. Callers that must not act on
215
+ * "everything" — `purge` — check for it themselves.
216
+ *
217
+ * @throws {UnsupportedArgumentError}
218
+ */
219
+ export const criteriaToFilter = (
220
+ criteria: Criteria<any> | undefined, refs: Map<string, MongoReference>
221
+ ): Document => {
222
+ const conditions = build(criteria)
223
+ if (conditions.length < 1) {
224
+ return {}
225
+ }
226
+
227
+ return marshalCriteria(flatten(conditions), refs) ?? {}
228
+ }
229
+
230
+ /**
231
+ * Translate `Sort<T>[]` into a mongo sort document. A bare field name is ascending, and `id`
232
+ * addresses `_id` — documents never store an `id` field, so sorting by the name records carry
233
+ * would silently order by nothing.
234
+ */
235
+ export const sortToMongo = (sort?: Sort<any>[]): Document | undefined => {
236
+ if (sort == null || sort.length < 1) {
237
+ return undefined
238
+ }
239
+
240
+ return sort.reduce<Document>((order, entry) => {
241
+ const [field, direction]: [string, number] = typeof entry === 'string'
242
+ ? [entry, 1]
243
+ : [entry.field, entry.order === 'desc' ? -1 : 1]
244
+ order[field === 'id' ? '_id' : field] = direction
245
+
246
+ return order
247
+ }, {})
248
+ }
@@ -5,3 +5,4 @@ export * from './name.js'
5
5
  export * from './life-cycle.js'
6
6
  export * from './migrations.js'
7
7
  export * from './refs.js'
8
+ export * from './criteria.js'