dobo 2.34.1 → 2.36.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.
@@ -175,10 +175,11 @@ async function adapterFactory () {
175
175
  * Sanitizes a record retrieved from the database, converting data types as necessary
176
176
  * and ensuring that the record conforms to the model's schema.
177
177
  * @param {DoboModel} model - The model instance for which the record is being sanitized
178
- * @param {object} record - The record retrieved from the database
178
+ * @param {object} [record={}] - The record retrieved from the database
179
+ * @param {object} [options={}] - Additional options for sanitization
179
180
  * @returns {object} - Sanitized record
180
181
  */
181
- sanitizeRecord (model, record = {}) {
182
+ sanitizeRecord (model, record = {}, options = {}) {
182
183
  const { dayjs } = this.app.lib
183
184
  const { isString } = this.app.lib._
184
185
  const item = { ...record }
@@ -461,7 +462,7 @@ async function adapterFactory () {
461
462
  await this._attachHook('afterCreateRecord', model, body, result, options)
462
463
 
463
464
  if (options.noResult) return
464
- result.data = this.sanitizeRecord(model, result.data)
465
+ result.data = this.sanitizeRecord(model, result.data, options)
465
466
  this._injectMeta(result, options)
466
467
  return result
467
468
  }
@@ -512,7 +513,7 @@ async function adapterFactory () {
512
513
  await this._attachHook('afterGetRecord', model, id, result, options)
513
514
 
514
515
  if (isEmpty(result.data) && options.throwNotFound) throw this.plugin.error('recordNotFound%s%s', id, model.name)
515
- result.data = this.sanitizeRecord(model, result.data)
516
+ result.data = this.sanitizeRecord(model, result.data, options)
516
517
  this._injectMeta(result, options)
517
518
  return result
518
519
  }
@@ -547,8 +548,8 @@ async function adapterFactory () {
547
548
  await this._attachHook('afterUpdateRecord', model, id, body, result, options)
548
549
 
549
550
  if (options.noResult) return
550
- result.oldData = this.sanitizeRecord(model, result.oldData)
551
- result.data = this.sanitizeRecord(model, result.data)
551
+ result.oldData = this.sanitizeRecord(model, result.oldData, options)
552
+ result.data = this.sanitizeRecord(model, result.data, options)
552
553
  this._injectMeta(result, options)
553
554
  return result
554
555
  }
@@ -584,8 +585,8 @@ async function adapterFactory () {
584
585
  await this._attachHook('afterUpsertRecord', model, body, result, options)
585
586
 
586
587
  if (options.noResult) return
587
- if (result.oldData) result.oldData = this.sanitizeRecord(model, result.oldData)
588
- result.data = this.sanitizeRecord(model, result.data)
588
+ if (result.oldData) result.oldData = this.sanitizeRecord(model, result.oldData, options)
589
+ result.data = this.sanitizeRecord(model, result.data, options)
589
590
  this._injectMeta(result, options)
590
591
  return result
591
592
  }
@@ -613,7 +614,7 @@ async function adapterFactory () {
613
614
  await this._attachHook('afterRemoveRecord', model, id, result, options)
614
615
 
615
616
  if (options.noResult) return
616
- result.oldData = this.sanitizeRecord(model, result.oldData)
617
+ result.oldData = this.sanitizeRecord(model, result.oldData, options)
617
618
  this._injectMeta(result, options)
618
619
  return result
619
620
  }
@@ -664,7 +665,7 @@ async function adapterFactory () {
664
665
  }
665
666
 
666
667
  for (const idx in result.data) {
667
- result.data[idx] = this.sanitizeRecord(model, result.data[idx])
668
+ result.data[idx] = this.sanitizeRecord(model, result.data[idx], options)
668
669
  }
669
670
  this._injectMeta(result, options)
670
671
  return result
@@ -698,7 +699,7 @@ async function adapterFactory () {
698
699
  }
699
700
 
700
701
  for (const idx in result.data) {
701
- result.data[idx] = this.sanitizeRecord(model, result.data[idx])
702
+ result.data[idx] = this.sanitizeRecord(model, result.data[idx], options)
702
703
  }
703
704
  this._injectMeta(result, options)
704
705
  return result
@@ -10,11 +10,12 @@
10
10
  * @param {boolean} [opts.forceNoHidden=false] - If `true`, force ALL fields to be picked, thus ignoring hidden fields. If an array, force all fields except those in the array to be picked.
11
11
  * @param {boolean} [opts.fmt=false] - If `true`, add a `_fmt` property to the sanitized record with formatted values automatically based on the model's properties and rules
12
12
  * @param {Object} [opts.req] - Request object, used for formatting values based on request context
13
- * @param {Object} [opts.i18n] - Internationalization options, used for formatting values based on i18n context
14
- * @param {string} [opts.i18n.lang] - Language code for formatting values
15
- * @param {string} [opts.i18n.dateStyle] - Date style for formatting date values
16
- * @param {string} [opts.i18n.timeStyle] - Time style for formatting time values
17
- * @param {string} [opts.i18n.timeZone] - Time zone for formatting date/time values
13
+ * @param {Object} [opts.intl] - Internationalization options, used for formatting values based on i18n context
14
+ * @param {string} [opts.intl.lang] - Language code for formatting values
15
+ * @param {string} [opts.intl.datetime] - Date and time style for formatting datetime values
16
+ * @param {string} [opts.intl.date] - Date style for formatting date values
17
+ * @param {string} [opts.intl.time] - Time style for formatting time values
18
+ * @param {string} [opts.intl.timeZone] - Time zone for formatting date/time values
18
19
  * @returns {Object} Returns sanitized record object
19
20
  */
20
21
  async function sanitizeRecord (record = {}, opts = {}) {
@@ -42,6 +43,14 @@ async function sanitizeRecord (record = {}, opts = {}) {
42
43
  if (opts.fmt) {
43
44
  newRecord._fmt = cloneDeep(newRecord)
44
45
  delete newRecord._fmt._ref
46
+ const options = {
47
+ lang: get(opts, 'intl.lang', get(opts, 'req.lang')),
48
+ timeZone: get(opts, 'intl.timeZone', get(opts, 'req.site.setting.bajo.intl.timeZone')),
49
+ emptyValue: get(opts, 'intl.emptyValue', get(opts, 'req.site.setting.bajo.intl.emptyValue')),
50
+ unitSys: get(opts, 'intl.unitSys', get(opts, 'req.site.setting.bajo.intl.unitSys')),
51
+ formatter: get(opts, 'intl.formatter', get(opts, 'req.site.setting.bajo.intl.formatter'))
52
+ }
53
+
45
54
  for (const key in newRecord) {
46
55
  const prop = this.getProperty(key)
47
56
  if (!prop) continue
@@ -55,17 +64,13 @@ async function sanitizeRecord (record = {}, opts = {}) {
55
64
  else if (isFunction(prop.format)) newRecord._fmt[key] = await prop.format.call(this, value, newRecord, opts)
56
65
  else if (isString(prop.format)) newRecord._fmt[key] = await callHandler(this.plugin, this, value, newRecord, opts)
57
66
  else {
58
- const options = {
59
- lang: get(opts, 'i18n.lang', get(opts, 'req.lang')),
60
- datetime: {
61
- dateStyle: get(opts, 'i18n.dateStyle', get(opts, 'req.site.setting.sumba.dateStyle')),
62
- timeStyle: get(opts, 'i18n.timeStyle', get(opts, 'req.site.setting.sumba.timeStyle')),
63
- timeZone: get(opts, 'i18n.timeZone', get(opts, 'req.site.setting.sumba.timeZone'))
64
- },
67
+ const _options = {
68
+ ...options,
69
+ field: key,
65
70
  latitude: ['lat', 'latitude'].includes(key),
66
71
  longitude: ['lon', 'lng', 'longitude'].includes(key)
67
72
  }
68
- newRecord._fmt[key] = format(value, prop.type, options)
73
+ newRecord._fmt[key] = format(value, prop.type, _options)
69
74
  }
70
75
  }
71
76
  }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "dobo",
3
- "version": "2.34.1",
3
+ "version": "2.36.0",
4
4
  "description": "DBMS for Bajo Framework",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "build-doc": "jsdoc -c .jsdoc.conf.json",
8
- "test": "mocha"
8
+ "test": "mocha --recursive"
9
9
  },
10
10
  "type": "module",
11
11
  "bajo": {
@@ -39,7 +39,9 @@
39
39
  "uuid": "^13.0.0"
40
40
  },
41
41
  "devDependencies": {
42
+ "chai": "^6.2.2",
42
43
  "clean-jsdoc-theme": "^4.3.0",
43
- "jsdoc-plugin-intersection": "^1.0.4"
44
+ "jsdoc-plugin-intersection": "^1.0.4",
45
+ "mocha": "^11.7.6"
44
46
  }
45
47
  }
@@ -0,0 +1,12 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ export const runNodeInline = async (code, cwd) => {
4
+ return await new Promise((resolve) => {
5
+ const child = spawn(process.execPath, ['--input-type=module', '-e', code], { cwd, stdio: ['ignore', 'pipe', 'pipe'] })
6
+ let stdout = ''
7
+ let stderr = ''
8
+ child.stdout.on('data', d => { stdout += d.toString() })
9
+ child.stderr.on('data', d => { stderr += d.toString() })
10
+ child.on('close', (exitCode) => resolve({ code: exitCode, stdout, stderr }))
11
+ })
12
+ }
@@ -0,0 +1,25 @@
1
+ /* global describe, it */
2
+
3
+ import { expect } from 'chai'
4
+ import { runNodeInline } from './_run.js'
5
+
6
+ describe('e2e factory process', () => {
7
+ it('builds dobo class and executes runtime methods in separate process', async function () {
8
+ this.timeout(10000)
9
+
10
+ const code = `
11
+ import factory from './index.js'
12
+ import { createAppStub } from './test/unit/_stub.js'
13
+ const app = createAppStub('/tmp/dobo-e2e')
14
+ const Dobo = await factory.call({ app }, 'dobo')
15
+ const dobo = new Dobo()
16
+ const rows = [{ g: 'a', n: 1 }, { g: 'a', n: 3 }]
17
+ const agg = dobo.calcAggregate({ data: rows, group: 'g', field: 'n', aggregates: ['count', 'avg'] })
18
+ console.log('E2E_OK:' + (Array.isArray(agg) && agg.length === 1 && agg[0].count === 2))
19
+ `
20
+
21
+ const res = await runNodeInline(code, '/mnt/d/Projects/Dobo/dobo')
22
+ expect(res.code).to.equal(0)
23
+ expect(res.stdout).to.include('E2E_OK:true')
24
+ })
25
+ })
@@ -0,0 +1,52 @@
1
+ /* global describe, it */
2
+
3
+ import { expect } from 'chai'
4
+ import { runNodeInline } from './_run.js'
5
+
6
+ describe('e2e model-adapter process', () => {
7
+ it('creates model and adapter classes and runs wrapper calls in separate process', async function () {
8
+ this.timeout(12000)
9
+
10
+ const code = `
11
+ import adapterFactory from './lib/factory/adapter.js'
12
+ import modelFactory from './lib/factory/model.js'
13
+ import { createAppStub, Base } from './test/unit/_stub.js'
14
+
15
+ const app = createAppStub('/tmp/dobo-e2e-model-adapter')
16
+ app.bajo.runHook = async () => {}
17
+ app.dobo = {
18
+ ns: 'dobo',
19
+ t: (x) => x,
20
+ error: (m) => new Error(m),
21
+ checkAggregateParams: () => {},
22
+ checkHistogramParams: () => {}
23
+ }
24
+
25
+ const plugin = new Base('dobo', app)
26
+ plugin.error = (m) => new Error(m)
27
+ await adapterFactory.call(plugin)
28
+ await modelFactory.call(plugin)
29
+
30
+ const adapter = new app.baseClass.DoboAdapter(plugin, 'memory')
31
+ const conn = { name: 'default', adapter }
32
+ const model = new app.baseClass.DoboModel(plugin, {
33
+ name: 'Order',
34
+ connection: conn,
35
+ properties: [{ name: 'id', type: 'string', maxLength: 50 }, { name: 'name', type: 'string' }],
36
+ indexes: [{ name: 'uidx_id', type: 'unique', fields: ['id'] }],
37
+ hooks: []
38
+ })
39
+
40
+ adapter.createRecord = async (m, body) => ({ data: { ...body } })
41
+ adapter.getRecord = async (m, id) => ({ data: { _id: id, name: 'x' } })
42
+
43
+ const created = await adapter._createRecord(model, { id: 'e1', name: 'ok' }, { noUniqueCheck: true, noIdCheck: true })
44
+ const got = await adapter._getRecord(model, 'e1', {})
45
+ console.log('E2E_MODEL_ADAPTER_OK:' + (created.data.id === 'e1' && got.data.id === 'e1'))
46
+ `
47
+
48
+ const res = await runNodeInline(code, '/mnt/d/Projects/Dobo/dobo')
49
+ expect(res.code).to.equal(0)
50
+ expect(res.stdout).to.include('E2E_MODEL_ADAPTER_OK:true')
51
+ })
52
+ })
@@ -0,0 +1,33 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import factory from '../../index.js'
5
+ import { createAppStub } from '../unit/_stub.js'
6
+
7
+ describe('integration aspect 01 - start connections', () => {
8
+ let dobo
9
+
10
+ beforeEach(async () => {
11
+ const app = createAppStub('/tmp/dobo-int-01')
12
+ const Dobo = await factory.call({ app }, 'dobo')
13
+ dobo = new Dobo()
14
+ })
15
+
16
+ it('starts all, single, and selected connections', async () => {
17
+ const calls = []
18
+ const c1 = { name: 'primary', connect: async () => calls.push('primary'), adapter: { plugin: { ns: 'dobo' }, name: 'memory' } }
19
+ const c2 = { name: 'audit', connect: async () => calls.push('audit'), adapter: { plugin: { ns: 'dobo' }, name: 'memory' } }
20
+ dobo.connections = [c1, c2]
21
+
22
+ await dobo.start('all')
23
+ expect(calls).to.deep.equal(['primary', 'audit'])
24
+
25
+ calls.length = 0
26
+ await dobo.start('audit')
27
+ expect(calls).to.deep.equal(['audit'])
28
+
29
+ calls.length = 0
30
+ await dobo.start(['primary'])
31
+ expect(calls).to.deep.equal(['primary'])
32
+ })
33
+ })
@@ -0,0 +1,34 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import factory from '../../index.js'
5
+ import { createAppStub } from '../unit/_stub.js'
6
+
7
+ describe('integration aspect 02 - query and regex', () => {
8
+ let dobo
9
+
10
+ beforeEach(async () => {
11
+ const app = createAppStub('/tmp/dobo-int-02')
12
+ const Dobo = await factory.call({ app }, 'dobo')
13
+ dobo = new Dobo()
14
+ })
15
+
16
+ it('parses query text and preserves regex via json conversion', () => {
17
+ const model = {
18
+ scanables: ['title'],
19
+ sortables: ['title'],
20
+ properties: [{ name: 'title', type: 'string' }],
21
+ adapter: { idField: { name: '_id' } }
22
+ }
23
+
24
+ const q1 = dobo.parseQuery('title:~\'book\'', model)
25
+ expect(q1).to.be.an('object')
26
+
27
+ const q2 = dobo.parseQuery('{"id":"x"}', model)
28
+ expect(q2._id).to.equal('x')
29
+
30
+ const str = dobo.replaceRegexInJson({ title: /book/i })
31
+ const obj = dobo.reviveRegexInJson(str)
32
+ expect(obj.title).to.be.instanceOf(RegExp)
33
+ })
34
+ })
@@ -0,0 +1,88 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import adapterFactory from '../../lib/factory/adapter.js'
5
+ import modelFactory from '../../lib/factory/model.js'
6
+ import { createAppStub, Base } from '../unit/_stub.js'
7
+
8
+ describe('integration aspect 03 - model adapter flow', () => {
9
+ let app
10
+ let plugin
11
+ let model
12
+ let adapter
13
+ const store = {}
14
+
15
+ beforeEach(async () => {
16
+ app = createAppStub('/tmp/dobo-int-03')
17
+ app.bajo.runHook = async () => {}
18
+ app.dobo = {
19
+ ns: 'dobo',
20
+ t: (x) => x,
21
+ error: (msg, payload) => {
22
+ const err = new Error(msg)
23
+ err.payload = payload
24
+ return err
25
+ },
26
+ getDefaultValues: () => ({ warnings: true }),
27
+ checkAggregateParams: () => {},
28
+ checkHistogramParams: () => {}
29
+ }
30
+
31
+ plugin = new Base('dobo', app)
32
+ plugin.error = (msg, ...args) => new Error(`${msg}:${args.join(',')}`)
33
+ await adapterFactory.call(plugin)
34
+ await modelFactory.call(plugin)
35
+
36
+ adapter = new app.baseClass.DoboAdapter(plugin, 'memory')
37
+ const conn = { name: 'default', adapter }
38
+
39
+ model = new app.baseClass.DoboModel(plugin, {
40
+ name: 'Order',
41
+ connection: conn,
42
+ properties: [
43
+ { name: 'id', type: 'string', maxLength: 50 },
44
+ { name: 'name', type: 'string' },
45
+ { name: 'amount', type: 'double' },
46
+ { name: 'createdAt', type: 'datetime' }
47
+ ],
48
+ indexes: [{ name: 'uidx_id', type: 'unique', fields: ['id'] }],
49
+ hooks: []
50
+ })
51
+
52
+ adapter.getRecord = async (m, id) => ({ data: store[id] ? { ...store[id] } : null })
53
+ adapter.createRecord = async (m, body) => {
54
+ const key = body._id ?? body.id
55
+ store[key] = { ...body }
56
+ return { data: { ...body } }
57
+ }
58
+ adapter.updateRecord = async (m, id, body) => {
59
+ const oldData = { ...(store[id] ?? {}) }
60
+ store[id] = { ...(store[id] ?? {}), ...body }
61
+ return { oldData, data: { ...store[id] } }
62
+ }
63
+ adapter.findRecord = async () => ({ data: Object.values(store), count: Object.keys(store).length })
64
+ adapter.removeRecord = async (m, id) => {
65
+ const oldData = store[id] ? { ...store[id] } : null
66
+ delete store[id]
67
+ return { oldData }
68
+ }
69
+ adapter.clearRecord = async () => {
70
+ for (const k in store) delete store[k]
71
+ return { data: true }
72
+ }
73
+ })
74
+
75
+ it('executes create/find/update/remove lifecycle through adapter wrappers', async () => {
76
+ const c = await adapter._createRecord(model, { id: 'o1', name: 'book', amount: 10 }, { noUniqueCheck: true, noIdCheck: true })
77
+ expect(c.data.id).to.equal('o1')
78
+
79
+ const f = await adapter._findRecord(model, {}, {})
80
+ expect(f.count).to.equal(1)
81
+
82
+ const u = await adapter._updateRecord(model, 'o1', { amount: 15 }, { _data: { ...store.o1 } })
83
+ expect(u.data.amount).to.equal(15)
84
+
85
+ const r = await adapter._removeRecord(model, 'o1', { _data: { ...store.o1 } })
86
+ expect(r.oldData.id).to.equal('o1')
87
+ })
88
+ })
@@ -0,0 +1,244 @@
1
+ import path from 'node:path'
2
+
3
+ export const isPlainObject = (v) => Object.prototype.toString.call(v) === '[object Object]'
4
+
5
+ export const camelCase = (s = '') => {
6
+ return String(s)
7
+ .replace(/^[\s_-]+|[\s_-]+$/g, '')
8
+ .split(/[\s_.-]+/)
9
+ .map((p, i) => i === 0 ? (p[0] ? p[0].toLowerCase() + p.slice(1) : '') : (p[0] ? p[0].toUpperCase() + p.slice(1) : ''))
10
+ .join('')
11
+ }
12
+
13
+ export const pascalCase = (s = '') => {
14
+ const cc = camelCase(s)
15
+ return cc[0] ? cc[0].toUpperCase() + cc.slice(1) : cc
16
+ }
17
+
18
+ export const find = (arr = [], matcher = {}) => {
19
+ if (typeof matcher === 'function') return arr.find(matcher)
20
+ return arr.find(item => Object.keys(matcher).every(k => item?.[k] === matcher[k]))
21
+ }
22
+ export const filter = (arr = [], matcher = {}) => {
23
+ if (typeof matcher === 'function') return arr.filter(matcher)
24
+ return arr.filter(item => Object.keys(matcher).every(k => item?.[k] === matcher[k]))
25
+ }
26
+ export const map = (arr = [], fn) => {
27
+ if (typeof fn === 'function') return arr.map(fn)
28
+ if (typeof fn === 'string') return arr.map(item => item?.[fn])
29
+ return [...arr]
30
+ }
31
+ export const pick = (obj = {}, keys = []) => keys.reduce((a, k) => { if (Object.prototype.hasOwnProperty.call(obj, k)) a[k] = obj[k]; return a }, {})
32
+ export const groupBy = (arr = [], key) => arr.reduce((a, item) => { const k = item?.[key]; a[k] = a[k] ?? []; a[k].push(item); return a }, {})
33
+ export const isEmpty = (v) => {
34
+ if (v === null || v === undefined) return true
35
+ if (typeof v === 'string') return v.trim() === ''
36
+ if (Array.isArray(v)) return v.length === 0
37
+ if (isPlainObject(v)) return Object.keys(v).length === 0
38
+ return false
39
+ }
40
+ export const uniq = (arr = []) => [...new Set(arr)]
41
+ export const without = (arr = [], ...vals) => {
42
+ const flat = vals.flat()
43
+ return arr.filter(item => !flat.includes(item))
44
+ }
45
+ export const trim = (s = '') => String(s).trim()
46
+ export const has = (obj, k) => Object.prototype.hasOwnProperty.call(obj ?? {}, k)
47
+ export const keys = (obj = {}) => Object.keys(obj)
48
+ export const defaults = (obj = {}, src = {}) => {
49
+ for (const k of Object.keys(src)) if (obj[k] === undefined) obj[k] = src[k]
50
+ return obj
51
+ }
52
+ export const isFunction = (v) => typeof v === 'function'
53
+ export const kebabCase = (s = '') => String(s).replace(/([a-z])([A-Z])/g, '$1-$2').replace(/[\s_.]+/g, '-').toLowerCase()
54
+ export const chunk = (arr = [], size = 1) => {
55
+ const out = []
56
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
57
+ return out
58
+ }
59
+ export const last = (arr = []) => arr[arr.length - 1]
60
+ export const forOwn = (obj = {}, fn) => {
61
+ for (const k of Object.keys(obj)) fn(obj[k], k)
62
+ }
63
+ export const findIndex = (arr = [], matcher = {}) => {
64
+ if (typeof matcher === 'function') return arr.findIndex(matcher)
65
+ return arr.findIndex(item => Object.keys(matcher).every(k => item?.[k] === matcher[k]))
66
+ }
67
+ export const omit = (obj = {}, drop = []) => {
68
+ const d = Array.isArray(drop) ? drop : [drop]
69
+ const out = {}
70
+ for (const k in obj) if (!d.includes(k)) out[k] = obj[k]
71
+ return out
72
+ }
73
+ export const cloneDeep = (obj) => JSON.parse(JSON.stringify(obj))
74
+ export const pullAt = (arr = [], indexes = []) => {
75
+ const idxs = [...indexes].sort((a, b) => b - a)
76
+ for (const idx of idxs) if (Number.isInteger(idx) && idx >= 0 && idx < arr.length) arr.splice(idx, 1)
77
+ return arr
78
+ }
79
+ export const orderBy = (arr = [], fields = []) => {
80
+ const [field] = fields
81
+ return [...arr].sort((a, b) => {
82
+ if (a[field] === b[field]) return 0
83
+ return a[field] > b[field] ? 1 : -1
84
+ })
85
+ }
86
+
87
+ export const defaultsDeep = (...items) => {
88
+ const out = {}
89
+ const apply = (target, src) => {
90
+ if (!isPlainObject(src)) return target
91
+ for (const k of Object.keys(src)) {
92
+ const tv = target[k]
93
+ const sv = src[k]
94
+ if (isPlainObject(sv)) {
95
+ target[k] = apply(isPlainObject(tv) ? tv : {}, sv)
96
+ } else if (target[k] === undefined) {
97
+ target[k] = sv
98
+ }
99
+ }
100
+ return target
101
+ }
102
+ for (let i = 0; i < items.length; i++) apply(out, items[i])
103
+ return out
104
+ }
105
+
106
+ export const get = (obj, p, fallback) => {
107
+ const parts = String(p).split('.')
108
+ let cur = obj
109
+ for (const part of parts) {
110
+ if (cur == null) return fallback
111
+ cur = cur[part]
112
+ }
113
+ return cur === undefined ? fallback : cur
114
+ }
115
+
116
+ let gid = 0
117
+ export const generateId = () => `id-${++gid}`
118
+ export const isSet = (v) => v !== undefined && v !== null
119
+
120
+ export class Base {
121
+ constructor (pkgName, app) {
122
+ this.pkgName = pkgName
123
+ this.app = app
124
+ this.ns = camelCase(pkgName)
125
+ this.alias = this.ns
126
+ this.log = {
127
+ trace: () => {},
128
+ debug: () => {},
129
+ info: () => {},
130
+ warn: () => {},
131
+ error: () => {}
132
+ }
133
+ }
134
+
135
+ t = (text) => text
136
+
137
+ error = (msg, ...args) => new Error(`${msg}:${args.join(',')}`)
138
+
139
+ getConfig = (pathLike) => get(this.config, pathLike)
140
+ }
141
+
142
+ export class Tools {
143
+ constructor (plugin) {
144
+ this.plugin = plugin
145
+ this.app = plugin.app
146
+ }
147
+
148
+ async dispose () {
149
+ this.plugin = null
150
+ this.app = null
151
+ }
152
+
153
+ bindThis (...fns) {
154
+ for (const fn of fns) {
155
+ if (!fn?.name) continue
156
+ this[fn.name] = fn.bind(this)
157
+ }
158
+ }
159
+ }
160
+
161
+ export const createAppStub = (root = '/tmp/dobo-test') => {
162
+ const app = {
163
+ dir: root,
164
+ t: (text) => text,
165
+ lib: {
166
+ _: {
167
+ find,
168
+ filter,
169
+ isString: (v) => typeof v === 'string',
170
+ isArray: Array.isArray,
171
+ isFunction,
172
+ map,
173
+ get,
174
+ pick,
175
+ groupBy,
176
+ isEmpty,
177
+ isNaN: Number.isNaN,
178
+ trim,
179
+ isPlainObject,
180
+ uniq,
181
+ without,
182
+ defaults,
183
+ findIndex,
184
+ chunk,
185
+ last,
186
+ forOwn,
187
+ kebabCase,
188
+ keys,
189
+ has,
190
+ camelCase,
191
+ isNumber: (v) => typeof v === 'number' && !Number.isNaN(v),
192
+ omit,
193
+ cloneDeep,
194
+ pullAt,
195
+ orderBy
196
+ },
197
+ aneka: {
198
+ pascalCase,
199
+ camelCase,
200
+ defaultsDeep,
201
+ generateId,
202
+ isSet
203
+ },
204
+ dayjs: (value) => {
205
+ const d = value instanceof Date ? value : new Date(value)
206
+ return {
207
+ isValid: () => !Number.isNaN(d.getTime()),
208
+ toDate: () => new Date(d.getTime()),
209
+ format: (pattern) => {
210
+ const y = d.getUTCFullYear()
211
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0')
212
+ const day = String(d.getUTCDate()).padStart(2, '0')
213
+ if (pattern === 'YYYY-MM-DD') return `${y}-${m}-${day}`
214
+ if (pattern === 'YYYY-MM') return `${y}-${m}`
215
+ if (pattern === 'YYYY') return `${y}`
216
+ return d.toISOString()
217
+ }
218
+ }
219
+ },
220
+ fs: {
221
+ ensureDirSync: () => {}
222
+ }
223
+ },
224
+ baseClass: {
225
+ Base,
226
+ Tools
227
+ },
228
+ bajo: {
229
+ breakNsPath: (v) => {
230
+ const i = v.indexOf(':')
231
+ return i === -1 ? { ns: v, path: '' } : { ns: v.slice(0, i), path: v.slice(i + 1) }
232
+ },
233
+ join: (arr = []) => arr.join(', '),
234
+ runHook: async () => {},
235
+ eachPlugins: async () => {},
236
+ buildCollections: async () => [],
237
+ importModule: async () => {},
238
+ callHandler: async (scope, handler, ...args) => typeof handler === 'function' ? handler.call(scope, ...args) : undefined,
239
+ readConfig: async () => ({})
240
+ },
241
+ getPluginDataDir: (name) => path.join(root, 'plugins', name)
242
+ }
243
+ return app
244
+ }
@@ -0,0 +1,58 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import actionFactory from '../../lib/factory/action.js'
5
+ import { createAppStub } from './_stub.js'
6
+
7
+ describe('dobo action class (unit)', () => {
8
+ let app
9
+ let plugin
10
+
11
+ beforeEach(async () => {
12
+ app = createAppStub('/tmp/dobo-action')
13
+ plugin = { app }
14
+ await actionFactory.call(plugin)
15
+ })
16
+
17
+ it('creates chainable action class and runs model handlers', async () => {
18
+ const calls = []
19
+ const model = {
20
+ plugin,
21
+ getRecord: async (...args) => {
22
+ calls.push(args)
23
+ return { ok: true }
24
+ }
25
+ }
26
+
27
+ const action = new app.baseClass.DoboAction(model, 'getRecord', 'abc')
28
+ const resp = await action
29
+ .noHook()
30
+ .dataOnly(false)
31
+ .run({ noCache: false })
32
+
33
+ expect(resp.ok).to.equal(true)
34
+ expect(calls).to.have.length(1)
35
+ expect(calls[0][0]).to.equal('abc')
36
+ expect(calls[0][1]).to.be.an('object')
37
+ })
38
+
39
+ it('builder methods for create/update payload set expected positional args', async () => {
40
+ const model = {
41
+ plugin,
42
+ updateRecord: async (id, body, options) => ({ id, body, options })
43
+ }
44
+
45
+ const action = new app.baseClass.DoboAction(model)
46
+ const result = await action.updateRecord('id-1', { name: 'A' }).run()
47
+ expect(result.id).to.equal('id-1')
48
+ expect(result.body.name).to.equal('A')
49
+ })
50
+
51
+ it('dispose clears action references', async () => {
52
+ const model = { plugin, getRecord: async () => ({}) }
53
+ const action = new app.baseClass.DoboAction(model, 'getRecord', 'x')
54
+ await action.dispose()
55
+ expect(action.model).to.equal(null)
56
+ expect(action._options).to.equal(null)
57
+ })
58
+ })
@@ -0,0 +1,37 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import connectionFactory from '../../lib/factory/connection.js'
5
+ import { createAppStub } from './_stub.js'
6
+
7
+ describe('dobo connection class (unit)', () => {
8
+ let app
9
+ let plugin
10
+
11
+ beforeEach(async () => {
12
+ app = createAppStub('/tmp/dobo-connection')
13
+ app.baseClass.DoboNullAdapter = class DoboNullAdapter {}
14
+ app.baseClass.DoboAdapter = class DoboAdapter {}
15
+ plugin = {
16
+ app,
17
+ getAdapter: () => ({ sanitizeConnection: async () => {}, connect: async () => ({ client: true }) })
18
+ }
19
+ await connectionFactory.call(plugin)
20
+ })
21
+
22
+ it('creates DoboConnection, initializes adapter and connects', async () => {
23
+ const conn = new app.baseClass.DoboConnection(plugin, { name: 'default', adapter: 'dobo:memory', models: ['User'] })
24
+ await conn.initAdapter('dobo:memory')
25
+ expect(conn.adapter).to.be.an('object')
26
+ await conn.connect(true)
27
+ expect(conn.connected).to.equal(true)
28
+ expect(conn.client).to.deep.equal({ client: true })
29
+ })
30
+
31
+ it('dispose clears adapter reference', async () => {
32
+ const conn = new app.baseClass.DoboConnection(plugin, { name: 'default', adapter: 'dobo:memory' })
33
+ conn.adapter = { ok: true }
34
+ await conn.dispose()
35
+ expect(conn.adapter).to.equal(null)
36
+ })
37
+ })
@@ -0,0 +1,136 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import factory from '../../index.js'
5
+ import { createAppStub } from './_stub.js'
6
+
7
+ describe('dobo core class (unit)', () => {
8
+ let app
9
+ let Dobo
10
+ let dobo
11
+
12
+ beforeEach(async () => {
13
+ app = createAppStub('/tmp/dobo-unit')
14
+ Dobo = await factory.call({ app }, 'dobo')
15
+ dobo = new Dobo()
16
+ })
17
+
18
+ it('creates plugin class with expected static values and defaults', () => {
19
+ expect(Dobo.aggregateTypes).to.include('count')
20
+ expect(Dobo.histogramTypes).to.include('yearly')
21
+ expect(Dobo.idTypes).to.include('string')
22
+ expect(dobo.config.default.filter.limit).to.equal(25)
23
+ expect(dobo.adapters).to.deep.equal([])
24
+ expect(dobo.connections).to.deep.equal([])
25
+ expect(dobo.features).to.deep.equal([])
26
+ expect(dobo.models).to.deep.equal([])
27
+ })
28
+
29
+ it('property key helpers include type-specific fields', () => {
30
+ expect(dobo.getPropertyKeysByType('string')).to.include('maxLength')
31
+ expect(dobo.getPropertyKeysByType('text')).to.include('textType')
32
+ expect(dobo.getPropertyKeysByType('smallint')).to.include('autoInc')
33
+
34
+ const keys = dobo.getAllPropertyKeys({ constructor: { propertyKeys: ['xFlag'] } })
35
+ expect(keys).to.include('name')
36
+ expect(keys).to.include('validator')
37
+ expect(keys).to.include('xFlag')
38
+ })
39
+
40
+ it('connection, adapter, feature and model lookups work', () => {
41
+ dobo.connections = [{ name: 'default' }, { name: 'report' }]
42
+ expect(dobo.getConnection('report').name).to.equal('report')
43
+ expect(dobo.getConnection('none', true)).to.equal(undefined)
44
+
45
+ dobo.adapters = [
46
+ { name: 'memory', plugin: { ns: 'dobo' } },
47
+ { name: 'proxy', plugin: { ns: 'ext' } }
48
+ ]
49
+ expect(dobo.getAdapter('memory').name).to.equal('memory')
50
+ expect(dobo.getAdapter('ext:proxy').name).to.equal('proxy')
51
+ expect(dobo.getAdapter('missing', true)).to.equal(undefined)
52
+
53
+ dobo.features = [{ name: 'createdAt', plugin: { ns: 'dobo' } }]
54
+ expect(dobo.getFeature('createdAt').name).to.equal('createdAt')
55
+
56
+ dobo.models = [{ name: 'Order' }, { name: 'Customer' }]
57
+ expect(dobo.getModel('Order').name).to.equal('Order')
58
+ expect(dobo.getModel('order').name).to.equal('Order')
59
+ expect(dobo.getModel('none', true)).to.equal(undefined)
60
+ })
61
+
62
+ it('sanitize helpers and aggregate calculators work', () => {
63
+ expect(dobo.sanitizeBoolean('true')).to.equal(true)
64
+ expect(dobo.sanitizeBoolean(false)).to.equal(false)
65
+ expect(dobo.sanitizeFloat('12.4')).to.equal(12.4)
66
+ expect(dobo.sanitizeInt('12')).to.equal(12)
67
+ expect(dobo.sanitizeString(44)).to.equal('44')
68
+ expect(dobo.sanitizeObject('{"a":1}')).to.deep.equal({ a: 1 })
69
+
70
+ const rows = [
71
+ { grp: 'a', price: 10 },
72
+ { grp: 'a', price: 30 },
73
+ { grp: 'b', price: 20 }
74
+ ]
75
+ const agg = dobo.calcAggregate({ data: rows, group: 'grp', field: 'price', aggregates: ['count', 'avg', 'min', 'max'] })
76
+ expect(agg).to.have.length(2)
77
+ const itemA = agg.find(i => i.grp === 'a')
78
+ expect(itemA.count).to.equal(2)
79
+ expect(itemA.max).to.equal(30)
80
+ })
81
+
82
+ it('histogram and query parsers work with valid input', () => {
83
+ const rows = [
84
+ { createdAt: '2026-01-01T00:00:00Z', score: 5 },
85
+ { createdAt: '2026-01-01T12:00:00Z', score: 7 },
86
+ { createdAt: '2026-01-02T00:00:00Z', score: 10 }
87
+ ]
88
+ const hist = dobo.calcHistogram({ data: rows, type: 'daily', group: 'createdAt', field: 'score', aggregates: ['count'] })
89
+ expect(hist).to.have.length(2)
90
+ expect(hist[0]).to.have.property('date')
91
+
92
+ const model = {
93
+ scanables: ['name'],
94
+ sortables: ['name'],
95
+ properties: [{ name: 'name', type: 'string' }],
96
+ adapter: { idField: { name: '_id' } }
97
+ }
98
+ const parsed = dobo.parseQuery('name:~\'john\'', model)
99
+ expect(parsed).to.be.an('object')
100
+
101
+ const parsedAny = dobo.parseQuery('john*', model)
102
+ expect(parsedAny).to.be.an('object')
103
+ })
104
+
105
+ it('regex json helpers and model hooks work', async () => {
106
+ const input = { n: /abc/gi }
107
+ const text = dobo.replaceRegexInJson(input, true)
108
+ const revived = dobo.reviveRegexInJson(text, true)
109
+ expect(revived.n).to.be.instanceOf(RegExp)
110
+ expect(revived.n.flags).to.include('g')
111
+
112
+ const calls = []
113
+ const model = {
114
+ hooks: [
115
+ { name: 'beforeCreateRecord', level: 2, handler: async () => calls.push(2) },
116
+ { name: 'beforeCreateRecord', level: 1, handler: async () => calls.push(1) }
117
+ ]
118
+ }
119
+ await dobo.runModelHook(model, 'beforeCreateRecord', {})
120
+ expect(calls).to.deep.equal([1, 2])
121
+ })
122
+
123
+ it('default values and hard-cap last page handler work', () => {
124
+ dobo.config.default.filter = { limit: 10, maxLimit: 50, maxPage: 30, sort: [] }
125
+ dobo.config.default.hardCap = 100
126
+ dobo.config.default.warnings = true
127
+
128
+ const def = dobo.getDefaultValues()
129
+ expect(def.limit).to.equal(10)
130
+ expect(def.hardCap).to.equal(100)
131
+
132
+ const result = dobo.handleLastPage({ count: 300, limit: 10, page: 25 })
133
+ expect(result.count).to.equal(100)
134
+ expect(result.data).to.deep.equal([])
135
+ })
136
+ })
@@ -0,0 +1,25 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import featureFactory from '../../lib/factory/feature.js'
5
+ import { createAppStub } from './_stub.js'
6
+
7
+ describe('dobo feature class (unit)', () => {
8
+ let app
9
+ let plugin
10
+
11
+ beforeEach(async () => {
12
+ app = createAppStub('/tmp/dobo-feature')
13
+ plugin = { app }
14
+ await featureFactory.call(plugin)
15
+ })
16
+
17
+ it('creates DoboFeature and disposes cleanly', async () => {
18
+ const feat = new app.baseClass.DoboFeature(plugin, { name: 'createdAt', handler: async () => ({}) })
19
+ expect(feat.name).to.equal('createdAt')
20
+ expect(feat.handler).to.be.a('function')
21
+ await feat.dispose()
22
+ expect(feat.name).to.equal(null)
23
+ expect(feat.handler).to.equal(null)
24
+ })
25
+ })
@@ -0,0 +1,115 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import modelFactory from '../../lib/factory/model.js'
5
+ import { createAppStub, Base } from './_stub.js'
6
+
7
+ describe('dobo model class (unit)', () => {
8
+ let app
9
+ let plugin
10
+ let model
11
+
12
+ beforeEach(async () => {
13
+ app = createAppStub('/tmp/dobo-model-unit')
14
+ app.dobo = {
15
+ models: [],
16
+ sanitizeDate: (v) => v,
17
+ sanitizeByType: (v) => v,
18
+ checkAggregateParams: () => {},
19
+ checkHistogramParams: () => {},
20
+ runModelHook: async () => {},
21
+ parseQuery: (q) => q
22
+ }
23
+ app.baseClass.DoboAction = class DoboAction {
24
+ constructor (m, name, ...args) {
25
+ this.model = m
26
+ this.name = name
27
+ this.args = args
28
+ }
29
+ }
30
+
31
+ plugin = new Base('dobo', app)
32
+ await modelFactory.call(plugin)
33
+
34
+ const options = {
35
+ name: 'Order',
36
+ baseName: 'order',
37
+ connection: {
38
+ name: 'default',
39
+ adapter: { idField: { name: '_id', type: 'string', maxLength: 50 } }
40
+ },
41
+ properties: [
42
+ { name: 'id', type: 'string', maxLength: 50 },
43
+ { name: 'title', type: 'string', maxLength: 50 },
44
+ { name: 'total', type: 'double' },
45
+ { name: 'virtualTitle', type: 'string', virtual: true }
46
+ ],
47
+ indexes: [{ name: 'idx_title', type: 'index', fields: ['title'] }],
48
+ hooks: []
49
+ }
50
+ model = new app.baseClass.DoboModel(plugin, options)
51
+ app.dobo.models.push(model)
52
+ })
53
+
54
+ it('builds aliases, action and property getters correctly', () => {
55
+ const act = model.action('getRecord', 'id-1')
56
+ expect(act.name).to.equal('getRecord')
57
+ expect(model.getProperty('title').type).to.equal('string')
58
+ expect(model.getField('title').name).to.equal('title')
59
+ expect(model.hasProperty('title')).to.equal(true)
60
+ expect(model.hasField('ghost')).to.equal(false)
61
+ expect(model.getIndexes()).to.have.length(1)
62
+ })
63
+
64
+ it('returns filtered property lists (all, virtual, non-virtual)', () => {
65
+ expect(model.getProperties().length).to.equal(4)
66
+ expect(model.getProperties({ noVirtual: true }).length).to.equal(3)
67
+ expect(model.getProperties({ noVirtual: true, namesOnly: true })).to.include('title')
68
+ expect(model.getVirtualProperties(true)).to.deep.equal(['virtualTitle'])
69
+ expect(model.getNonVirtualProperties(true)).to.include('id')
70
+ })
71
+
72
+ it('sanitizes and syncs id field', () => {
73
+ model.properties[0].type = 'integer'
74
+ expect(model.sanitizeId('12')).to.equal(12)
75
+
76
+ model.syncIdField({ name: 'id', type: 'string', maxLength: 20 })
77
+ expect(model.properties[0].maxLength).to.equal(20)
78
+ })
79
+
80
+ it('buildPropValues supports array and req translation hooks', async () => {
81
+ const prop = {
82
+ name: 'status',
83
+ values: ['draft', { value: 'done', text: 'done' }]
84
+ }
85
+ const req = {
86
+ te: (k) => k === 'statusDone',
87
+ t: () => 'Selesai'
88
+ }
89
+ const vals = await model.buildPropValues(prop, { req })
90
+ expect(vals).to.have.length(2)
91
+ expect(vals[1].text).to.equal('Selesai')
92
+ })
93
+
94
+ it('_simpleLookup handles string/object/array definitions', async () => {
95
+ const userModel = {
96
+ findOneRecord: async () => ({ id: 'u1', username: 'john' })
97
+ }
98
+ app.dobo.getModel = () => userModel
99
+
100
+ const a = await model._simpleLookup('User:username:id=u1', {})
101
+ expect(a).to.equal('john')
102
+
103
+ const b = await model._simpleLookup({ model: 'User', field: 'id', query: 'id={id}' }, { id: 'u9' })
104
+ expect(b).to.equal('u1')
105
+
106
+ const c = await model._simpleLookup(['User', 'id', 'id=u5'], {})
107
+ expect(c).to.equal('u1')
108
+ })
109
+
110
+ it('dispose clears connection and adapter references', async () => {
111
+ await model.dispose()
112
+ expect(model.connection).to.equal(null)
113
+ expect(model.adapter).to.equal(null)
114
+ })
115
+ })
@@ -0,0 +1,40 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import factory from '../../index.js'
5
+ import { sanitizeAll } from '../../lib/helper.js'
6
+ import { createAppStub } from './_stub.js'
7
+
8
+ describe('helper sanitizeAll (unit)', () => {
9
+ let app
10
+ let dobo
11
+
12
+ beforeEach(async () => {
13
+ app = createAppStub('/tmp/dobo-helper-all')
14
+ const Dobo = await factory.call({ app }, 'dobo')
15
+ dobo = new Dobo()
16
+ app.dobo = dobo
17
+ dobo.fatal = (msg, ...args) => { throw new Error(`${msg}:${args.join(',')}`) }
18
+ })
19
+
20
+ it('normalizes properties, indexes, sortables and hidden fields', async () => {
21
+ const schema = {
22
+ name: 'Order',
23
+ properties: [
24
+ { name: 'id', type: 'string', index: 'primary' },
25
+ { name: 'name', type: 'string', minLength: '0', maxLength: '20' },
26
+ { name: 'amount', type: 'double' }
27
+ ],
28
+ indexes: [{ name: 'idx_name', type: 'index', fields: ['name'] }],
29
+ hidden: ['name', 'ghost']
30
+ }
31
+
32
+ await sanitizeAll.call(dobo, schema)
33
+
34
+ expect(schema.sortables).to.include('name')
35
+ expect(schema.hidden).to.deep.equal(['name'])
36
+ const nameProp = schema.properties.find(p => p.name === 'name')
37
+ expect(nameProp.maxLength).to.equal(20)
38
+ expect(nameProp.minLength).to.equal(undefined)
39
+ })
40
+ })
@@ -0,0 +1,64 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import factory from '../../index.js'
5
+ import { sanitizeRef } from '../../lib/helper.js'
6
+ import { createAppStub } from './_stub.js'
7
+
8
+ describe('helper sanitizeRef (unit)', () => {
9
+ let app
10
+ let dobo
11
+
12
+ beforeEach(async () => {
13
+ app = createAppStub('/tmp/dobo-helper-ref')
14
+ const Dobo = await factory.call({ app }, 'dobo')
15
+ dobo = new Dobo()
16
+ app.dobo = dobo
17
+ dobo.fatal = (msg, ...args) => { throw new Error(`${msg}:${args.join(',')}`) }
18
+ })
19
+
20
+ it('normalizes reference fields and filters unknown refs', async () => {
21
+ const user = {
22
+ name: 'User',
23
+ properties: [{ name: 'id' }, { name: 'name' }]
24
+ }
25
+ const order = {
26
+ name: 'Order',
27
+ properties: [
28
+ {
29
+ name: 'userId',
30
+ ref: {
31
+ user: { model: 'User', field: 'id', fields: ['name'] },
32
+ missing: { model: 'Unknown', field: 'id' }
33
+ }
34
+ }
35
+ ]
36
+ }
37
+
38
+ await sanitizeRef.call(dobo, order, [user, order])
39
+
40
+ expect(order.properties[0].ref.user.type).to.equal('1:1')
41
+ expect(order.properties[0].ref.user.searchField).to.equal('id')
42
+ expect(order.properties[0].ref.user.labelField).to.equal('id')
43
+ expect(order.properties[0].ref.user.fields).to.include('id')
44
+ expect(order.properties[0].ref.missing).to.equal(undefined)
45
+ })
46
+
47
+ it('throws when duplicate ref keys exist across properties', async () => {
48
+ const schema = {
49
+ name: 'Order',
50
+ properties: [
51
+ { name: 'a', ref: { rel: { model: 'User' } } },
52
+ { name: 'b', ref: { rel: { model: 'User' } } }
53
+ ]
54
+ }
55
+ const user = { name: 'User', properties: [{ name: 'id' }] }
56
+
57
+ let err
58
+ try {
59
+ await sanitizeRef.call(dobo, schema, [schema, user])
60
+ } catch (e) { err = e }
61
+ expect(err).to.be.instanceOf(Error)
62
+ expect(err.message).to.include('duplicateRefKeys')
63
+ })
64
+ })
package/wiki/CHANGES.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changes
2
2
 
3
+ ## 2026-07-31
4
+
5
+ - [2.36.0] Add test suite
6
+ - [2.36.0] Add parameter `options` to `model.sanitizeRecord()`
7
+
8
+ ## 2026-07-29
9
+
10
+ - [2.35.0] Add parameter `options` to `adapter.sanitizeRecord()` to allow passing additional options for sanitization
11
+ - [2.35.0] Change `options.i18n` to `options.intl` in `adapter.sanitizeRecord()` for consistency with other methods
12
+
3
13
  ## 2026-07-28
4
14
 
5
15
  - [2.34.1] Bug fix in `adapter.sanitizeRecord()`