@ditojs/server 2.51.2 → 2.53.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ditojs/server",
3
- "version": "2.51.2",
3
+ "version": "2.53.0",
4
4
  "type": "module",
5
5
  "description": "Dito.js Server – Dito.js is a declarative and modern web framework, based on Objection.js, Koa.js and Vue.js",
6
6
  "repository": "https://github.com/ditojs/dito/tree/master/packages/server",
@@ -26,12 +26,12 @@
26
26
  "node >= 18"
27
27
  ],
28
28
  "dependencies": {
29
- "@ditojs/admin": "^2.51.2",
30
- "@ditojs/build": "^2.51.2",
31
- "@ditojs/router": "^2.51.2",
32
- "@ditojs/utils": "^2.51.2",
29
+ "@ditojs/admin": "^2.53.0",
30
+ "@ditojs/build": "^2.53.0",
31
+ "@ditojs/router": "^2.53.0",
32
+ "@ditojs/utils": "^2.53.0",
33
33
  "@koa/cors": "^5.0.0",
34
- "@koa/multer": "^3.1.0",
34
+ "@koa/multer": "^4.0.0",
35
35
  "@originjs/vite-plugin-commonjs": "^1.0.3",
36
36
  "ajv": "^8.17.1",
37
37
  "ajv-formats": "^3.0.1",
@@ -39,14 +39,14 @@
39
39
  "bytes": "^3.1.2",
40
40
  "data-uri-to-buffer": "^6.0.2",
41
41
  "eventemitter2": "^6.4.9",
42
- "file-type": "^20.5.0",
42
+ "file-type": "^21.0.0",
43
43
  "helmet": "^8.1.0",
44
44
  "koa": "^3.0.0",
45
45
  "koa-bodyparser": "^4.4.1",
46
46
  "koa-compose": "^4.1.0",
47
47
  "koa-compress": "^5.1.1",
48
48
  "koa-conditional-get": "^3.0.0",
49
- "koa-etag": "^4.0.0",
49
+ "koa-etag": "^5.0.0",
50
50
  "koa-helmet": "^8.0.1",
51
51
  "koa-mount": "^4.2.0",
52
52
  "koa-passport": "^6.0.0",
@@ -55,7 +55,7 @@
55
55
  "koa-static": "^5.0.0",
56
56
  "leather": "^3.0.3",
57
57
  "mime-types": "^3.0.1",
58
- "multer": "^1.4.5-lts.2",
58
+ "multer": "^2.0.1",
59
59
  "multer-s3": "https://github.com/ditojs/multer-s3#dito",
60
60
  "nanoid": "^5.1.5",
61
61
  "parse-duration": "^2.1.4",
@@ -63,7 +63,7 @@
63
63
  "passthrough-counter": "^1.0.0",
64
64
  "picocolors": "^1.1.1",
65
65
  "picomatch": "^4.0.2",
66
- "pino": "^9.6.0",
66
+ "pino": "^9.7.0",
67
67
  "pino-pretty": "^13.0.0",
68
68
  "pluralize": "^8.0.0",
69
69
  "repl": "^0.1.3",
@@ -77,18 +77,18 @@
77
77
  "objection": "^3.0.1"
78
78
  },
79
79
  "devDependencies": {
80
- "@aws-sdk/client-s3": "^3.701.0",
81
- "@aws-sdk/lib-storage": "^3.701.0",
80
+ "@aws-sdk/client-s3": "^3.832.0",
81
+ "@aws-sdk/lib-storage": "^3.832.0",
82
82
  "@types/koa-bodyparser": "^4.3.12",
83
83
  "@types/koa-compress": "^4.0.6",
84
84
  "@types/koa-response-time": "^2.1.5",
85
85
  "@types/koa-session": "^6.4.5",
86
86
  "@types/koa-static": "^4.0.4",
87
87
  "@types/koa__cors": "^5.0.0",
88
- "@types/node": "^22.15.18",
88
+ "@types/node": "^24.0.3",
89
89
  "knex": "^3.1.0",
90
90
  "objection": "^3.1.5",
91
91
  "typescript": "^5.8.3"
92
92
  },
93
- "gitHead": "6df9c1bdbf04e6d4e58f53cab7ad2475fbb08161"
93
+ "gitHead": "7f36fa29f11708527b85e37233b83ecf6bf40497"
94
94
  }
@@ -64,6 +64,7 @@ export class Application extends Koa {
64
64
  #logger
65
65
 
66
66
  constructor({
67
+ basePath = process.cwd(),
67
68
  config = {},
68
69
  validator,
69
70
  router,
@@ -74,6 +75,7 @@ export class Application extends Koa {
74
75
  controllers
75
76
  } = {}) {
76
77
  super()
78
+ this.basePath = basePath
77
79
  this._configureEmitter(events)
78
80
  const {
79
81
  // Pluck keys out of `config.app` to keep them secret
@@ -430,9 +432,8 @@ export class Application extends Koa {
430
432
  }
431
433
 
432
434
  async loadAdminViteConfig() {
433
- const cwd = process.cwd()
434
435
  for (const extension of ['js', 'mjs', 'cjs', 'ts']) {
435
- const file = path.join(cwd, `admin.vite.config.${extension}`)
436
+ const file = path.join(this.basePath, `admin.vite.config.${extension}`)
436
437
  try {
437
438
  await fs.access(file)
438
439
  return (await import(file)).default
@@ -21,9 +21,8 @@ const defaultValues = {
21
21
  'now()': `knex.raw('CURRENT_TIMESTAMP')`
22
22
  }
23
23
 
24
- const migrationDir = path.join(process.cwd(), 'migrations')
25
-
26
24
  export async function createMigration(app, name, ...modelNames) {
25
+ const migrationDir = path.join(app.basePath, 'migrations')
27
26
  const models = modelNames.map(modelName => {
28
27
  const modelClass = app.models[modelName]
29
28
  if (!modelClass) {
@@ -6,7 +6,7 @@ import pluralize from 'pluralize'
6
6
  import { isFunction, isArray, camelize } from '@ditojs/utils'
7
7
 
8
8
  export async function seed(app) {
9
- const seedDir = path.join(process.cwd(), 'seeds')
9
+ const seedDir = path.join(app.basePath, 'seeds')
10
10
  const files = await fs.readdir(seedDir)
11
11
  const seeds = []
12
12
  // Create a lookup table with sort indices per model name.
@@ -164,7 +164,6 @@ export class AdminController extends Controller {
164
164
  defineViteConfig(config = {}) {
165
165
  const isDevelopment = this.mode === 'development'
166
166
 
167
- const cwd = process.cwd()
168
167
  const root = this.getPath('root')
169
168
  const base = `${this.url}/`
170
169
  const views = path.join(root, 'views')
@@ -205,10 +204,10 @@ export class AdminController extends Controller {
205
204
  chunkSizeWarningLimit: 1000,
206
205
  rollupOptions: {
207
206
  output: {
208
- manualChunks(id) {
207
+ manualChunks: id => {
209
208
  if (id.startsWith(views)) {
210
209
  return 'views'
211
- } else if (id.startsWith(cwd)) {
210
+ } else if (id.startsWith(this.app.basePath)) {
212
211
  return 'common'
213
212
  } else {
214
213
  const module = id.match(
@@ -390,21 +390,13 @@ export class Model extends objection.Model {
390
390
  return this._getCached(
391
391
  'jsonSchema',
392
392
  () => {
393
- const definitions = {}
394
- const schema = convertSchema(
395
- {
396
- type: 'object',
397
- properties: this.definition.properties
398
- },
399
- { definitions }
400
- )
393
+ const schema = convertSchema({
394
+ type: 'object',
395
+ properties: this.definition.properties
396
+ })
401
397
  addRelationSchemas(this, schema.properties)
402
398
  // Merge in root-level schema additions
403
399
  assignDeeply(schema, this.definition.schema)
404
- // Merge in definitions
405
- if (Object.keys(definitions).length > 0) {
406
- schema.definitions = definitions
407
- }
408
400
  return {
409
401
  $id: this.name,
410
402
  ...schema
@@ -1,17 +1,42 @@
1
1
  import { isObject, isArray, isString, equals } from '@ditojs/utils'
2
2
 
3
- export function convertSchema(schema, options = {}) {
3
+ const schemaCaches = {}
4
+
5
+ function getSchemaCache(options) {
6
+ const key = Object.entries(options || {})
7
+ .toSorted()
8
+ .map(([key, value]) => `${key}:${value}`)
9
+ .join(',') || 'default'
10
+ return (schemaCaches[key] ||= new WeakMap())
11
+ }
12
+
13
+ export function convertSchema(schema, options = {}, rootDefinitions = null) {
14
+ const original = schema
15
+
16
+ const schemaCache = getSchemaCache(options)
17
+ if (schemaCache.has(original)) {
18
+ const { schema, definitions } = schemaCache.get(original)
19
+ mergeDefinitions(rootDefinitions, definitions)
20
+ return schema
21
+ }
22
+
23
+ let definitions = null
4
24
  if (isArray(schema)) {
5
25
  // Needed for allOf, anyOf, oneOf, not, items, see below:
6
- schema = schema.map(entry => convertSchema(entry, options))
26
+ schema = schema.map(entry => convertSchema(entry, options, rootDefinitions))
7
27
  } else if (isObject(schema)) {
28
+ const isRoot = rootDefinitions === null
29
+ rootDefinitions ??= {}
30
+
8
31
  // Create a shallow clone so we can modify and return:
9
32
  // Also collect and propagate the definitions up to the root schema through
10
33
  // `options.definitions`, as passed from `Model static get jsonSchema()`:
11
- const { definitions, ...rest } = schema
12
- mergeDefinitions(options.definitions, definitions, options)
34
+ const { definitions: defs, ...rest } = schema
35
+ definitions = defs
13
36
  schema = rest
14
37
  const { $ref, type } = schema
38
+ const jsonType = jsonTypes[type]
39
+
15
40
  if (schema.required === true) {
16
41
  // Our 'required' is not the same as JSON Schema's: Use the 'required'
17
42
  // format instead that only validates if the required value is not empty,
@@ -21,37 +46,22 @@ export function convertSchema(schema, options = {}) {
21
46
  schema = addFormat(schema, 'required')
22
47
  }
23
48
 
24
- // Convert properties
25
- let hasConvertedProperties = false
26
- if (schema.properties) {
27
- const { properties, required } = convertProperties(
28
- schema.properties,
29
- options
30
- )
31
- schema.properties = properties
32
- if (required.length > 0) {
33
- schema.required = required
34
- }
35
- hasConvertedProperties = true
36
- }
37
- if (schema.patternProperties) {
38
- // TODO: Don't we need to handle required here too?
39
- const { properties } = convertProperties(
40
- schema.patternProperties,
41
- options
42
- )
43
- schema.patternProperties = properties
44
- hasConvertedProperties = true
45
- }
46
-
47
49
  // Convert array items
48
- schema.prefixItems &&= convertSchema(schema.prefixItems, options)
49
- schema.items &&= convertSchema(schema.items, options)
50
+ schema.prefixItems &&= convertSchema(
51
+ schema.prefixItems,
52
+ options,
53
+ rootDefinitions
54
+ )
55
+ schema.items &&= convertSchema(
56
+ schema.items,
57
+ options,
58
+ rootDefinitions
59
+ )
50
60
 
51
61
  // Handle nested allOf, anyOf, oneOf & co. fields
52
62
  for (const key of ['allOf', 'anyOf', 'oneOf', 'not', '$extend']) {
53
63
  if (key in schema) {
54
- schema[key] = convertSchema(schema[key], options)
64
+ schema[key] = convertSchema(schema[key], options, rootDefinitions)
55
65
  }
56
66
  }
57
67
 
@@ -62,18 +72,8 @@ export function convertSchema(schema, options = {}) {
62
72
  ? `#/definitions/${$ref}`
63
73
  : $ref
64
74
  } else if (isString(type)) {
65
- // Convert schema property notation to JSON schema
66
- const jsonType = jsonTypes[type]
67
75
  if (jsonType) {
68
76
  schema.type = jsonType
69
- if (
70
- (hasConvertedProperties || schema.discriminator) &&
71
- !('unevaluatedProperties' in schema)
72
- ) {
73
- // Invert the logic of `unevaluatedProperties` so that it needs to be
74
- // explicitly set to `true`:
75
- schema.unevaluatedProperties = false
76
- }
77
77
  } else if (['date', 'datetime', 'timestamp'].includes(type)) {
78
78
  // Date properties can be submitted both as a string or a Date object.
79
79
  // Provide validation through date-time format, which in Ajv appears
@@ -114,44 +114,108 @@ export function convertSchema(schema, options = {}) {
114
114
  if (schema.nullable && schema.enum && !schema.enum.includes(null)) {
115
115
  schema.enum.push(null)
116
116
  }
117
+
118
+ // Only convert properties after the schema is remembered, to avoid endless
119
+ // recursion in circular schema definitions.
120
+ let hasConvertedProperties = false
121
+ if (schema.properties) {
122
+ const { properties, required } = convertProperties(
123
+ schema.properties,
124
+ options,
125
+ rootDefinitions
126
+ )
127
+ schema.properties = properties
128
+ if (required.length > 0) {
129
+ schema.required = required
130
+ }
131
+ hasConvertedProperties = true
132
+ }
133
+ if (schema.patternProperties) {
134
+ // TODO: Don't we need to handle required here too?
135
+ const { properties } = convertProperties(
136
+ schema.patternProperties,
137
+ options,
138
+ rootDefinitions
139
+ )
140
+ schema.patternProperties = properties
141
+ hasConvertedProperties = true
142
+ }
143
+ if (
144
+ jsonType &&
145
+ (hasConvertedProperties || schema.discriminator) &&
146
+ !('unevaluatedProperties' in schema)
147
+ ) {
148
+ // Invert the logic of `unevaluatedProperties` so that it needs to be
149
+ // explicitly set to `true`:
150
+ schema.unevaluatedProperties = false
151
+ }
152
+
153
+ if (isRoot && hasDefinitions(rootDefinitions)) {
154
+ schema.definitions = rootDefinitions
155
+ }
156
+ }
157
+
158
+ const entry = { schema, definitions: null }
159
+ schemaCache.set(original, entry)
160
+
161
+ // To prevent circular references, we need to convert the definitions
162
+ // after the schema entry is cached.
163
+ if (definitions) {
164
+ definitions = convertDefinitions(definitions, options)
165
+ mergeDefinitions(rootDefinitions, definitions)
166
+ entry.definitions = definitions
117
167
  }
168
+
118
169
  return schema
119
170
  }
120
171
 
121
- function convertProperties(schemaProperties, options) {
172
+ function convertProperties(schemaProperties, options, definitions = {}) {
122
173
  const properties = {}
123
174
  const required = []
124
175
  for (const [key, property] of Object.entries(schemaProperties)) {
125
- properties[key] = convertSchema(property, options)
176
+ properties[key] = convertSchema(property, options, definitions)
126
177
  if (property?.required) {
127
178
  required.push(key)
128
179
  }
129
180
  }
130
- return { properties, required }
181
+ return { properties, required, definitions }
182
+ }
183
+
184
+ function hasDefinitions(definitions) {
185
+ return definitions && Object.keys(definitions).length > 0
186
+ }
187
+
188
+ function convertDefinitions(definitions, options) {
189
+ const converted = {}
190
+ for (const [key, schema] of Object.entries(definitions)) {
191
+ if (!key.startsWith('#')) {
192
+ throw new Error(
193
+ `Invalid definition '${
194
+ key
195
+ }', the name of nested Dito.js definitions must start with '#': ${
196
+ JSON.stringify(schema)
197
+ }`
198
+ )
199
+ }
200
+ converted[key] = convertSchema(schema, options, converted)
201
+ }
202
+ return hasDefinitions(converted) ? converted : null
131
203
  }
132
204
 
133
- function mergeDefinitions(definitions, defs, options) {
205
+ function mergeDefinitions(definitions, defs) {
134
206
  if (definitions && defs) {
135
207
  for (const [key, def] of Object.entries(defs)) {
136
- if (!key.startsWith('#')) {
137
- throw new Error(
138
- `Invalid definition '${
139
- key
140
- }', the name of nested Dito.js definitions must start with '#': ${
141
- JSON.stringify(def)
142
- }`
143
- )
144
- }
145
208
  const definition = definitions[key]
146
- const converted = convertSchema(def, options)
147
- if (definition && !equals(definition, converted)) {
209
+ if (definition && !equals(definition, def)) {
148
210
  throw new Error(
149
211
  `Duplicate nested definition for '${key}' with different schema: ${
150
- JSON.stringify(def)
212
+ JSON.stringify(def, null, 2)
213
+ }, ${
214
+ JSON.stringify(definition, null, 2)
151
215
  }`
152
216
  )
153
217
  }
154
- definitions[key] = converted
218
+ definitions[key] = def
155
219
  }
156
220
  }
157
221
  }
@@ -633,7 +633,6 @@ describe('convertSchema()', () => {
633
633
  })
634
634
 
635
635
  it('supports nested Dito.js definitions', () => {
636
- const definitions = {}
637
636
  expect(
638
637
  convertSchema(
639
638
  {
@@ -653,8 +652,18 @@ describe('convertSchema()', () => {
653
652
  '#type2': {
654
653
  type: 'object',
655
654
  properties: {
656
- prop3: {
655
+ prop4: {
657
656
  type: 'string'
657
+ },
658
+
659
+ prop5: {
660
+ $ref: '#type3'
661
+ }
662
+ },
663
+
664
+ definitions: {
665
+ '#type3': {
666
+ type: 'boolean'
658
667
  }
659
668
  }
660
669
  }
@@ -667,8 +676,7 @@ describe('convertSchema()', () => {
667
676
  type: 'integer'
668
677
  }
669
678
  }
670
- },
671
- { definitions }
679
+ }
672
680
  )
673
681
  ).toEqual({
674
682
  type: 'object',
@@ -686,19 +694,25 @@ describe('convertSchema()', () => {
686
694
  }
687
695
  }
688
696
  }
689
- }
690
- })
691
- expect(definitions).toEqual({
692
- '#type1': {
693
- type: 'integer'
694
697
  },
695
- '#type2': {
696
- type: 'object',
697
- unevaluatedProperties: false,
698
- properties: {
699
- prop3: {
700
- type: 'string'
698
+ definitions: {
699
+ '#type1': {
700
+ type: 'integer'
701
+ },
702
+ '#type2': {
703
+ type: 'object',
704
+ unevaluatedProperties: false,
705
+ properties: {
706
+ prop4: {
707
+ type: 'string'
708
+ },
709
+ prop5: {
710
+ $ref: '#/definitions/#type3'
711
+ }
701
712
  }
713
+ },
714
+ '#type3': {
715
+ type: 'boolean'
702
716
  }
703
717
  }
704
718
  })
@@ -136,7 +136,7 @@ export class Storage {
136
136
 
137
137
  _getPath(...parts) {
138
138
  return this.path
139
- ? path.join(this.path, ...parts)
139
+ ? path.resolve(this.app.basePath, this.path, ...parts)
140
140
  : undefined // So that it doesn't show up in JSON data.
141
141
  }
142
142
 
@@ -1,4 +1,4 @@
1
- import { isArray, isPlainObject } from '@ditojs/utils'
1
+ import { isPlainObject, isPlainArray } from '@ditojs/utils'
2
2
 
3
3
  /**
4
4
  * Converts Models to their external representation by calling the `$toJson()`
@@ -13,7 +13,7 @@ export function convertModelsToJson(value) {
13
13
  ? value.$toJson()
14
14
  : isPlainObject(value)
15
15
  ? convertToJsonObject(value)
16
- : isArray(value)
16
+ : isPlainArray(value)
17
17
  ? convertToJsonArray(value)
18
18
  : value
19
19
  }
package/types/index.d.ts CHANGED
@@ -352,6 +352,7 @@ interface AsyncRequestLocals {
352
352
 
353
353
  export class Application<$Models extends Models = Models> {
354
354
  constructor(options: {
355
+ basePath?: string
355
356
  config?: ApplicationConfig
356
357
  validator?: Validator
357
358
  // TODO: router types