@owlmeans/mongo-resource 0.1.14 → 0.1.16-rc.0

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