@ditojs/server 2.96.0 → 2.98.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.96.0",
3
+ "version": "2.98.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/main/packages/server",
@@ -25,10 +25,10 @@
25
25
  "node >= 18"
26
26
  ],
27
27
  "dependencies": {
28
- "@ditojs/admin": "^2.96.0",
29
- "@ditojs/build": "^2.96.0",
30
- "@ditojs/router": "^2.96.0",
31
- "@ditojs/utils": "^2.96.0",
28
+ "@ditojs/admin": "^2.98.0",
29
+ "@ditojs/build": "^2.98.0",
30
+ "@ditojs/router": "^2.98.0",
31
+ "@ditojs/utils": "^2.98.0",
32
32
  "@koa/cors": "^5.0.0",
33
33
  "@koa/etag": "^5.0.2",
34
34
  "@koa/multer": "^4.0.0",
@@ -90,5 +90,5 @@
90
90
  "objection": "^3.1.5",
91
91
  "typescript": "^6.0.3"
92
92
  },
93
- "gitHead": "689f4d061088500884114c5ced79d68209717bc5"
93
+ "gitHead": "70d0c1d3aa16844959e3c06d71d8253f21abad3f"
94
94
  }
@@ -924,7 +924,7 @@ export class Application extends Koa {
924
924
  changedFiles,
925
925
  transaction = null
926
926
  ) {
927
- let importedFiles = []
927
+ let importedFiles
928
928
  const AssetModel = this.getModel('Asset')
929
929
  if (AssetModel) {
930
930
  importedFiles = await this.addForeignAssets(
@@ -12,6 +12,7 @@ import {
12
12
  parseDataPath,
13
13
  normalizeDataPath,
14
14
  getValueAtDataPath,
15
+ getEntriesAtDataPath,
15
16
  setValueAtDataPath,
16
17
  mapConcurrently,
17
18
  assignDeeply,
@@ -481,6 +482,25 @@ export class Model extends objection.Model {
481
482
  }
482
483
  }
483
484
  }
485
+ // `discriminator`/`oneOf`/`anyOf`/`allOf` schemas don't always carry an
486
+ // explicit top-level `type`, so callers checking `property.type` (e.g.
487
+ // `loadDataPath()`, which gates traversal of nested data paths on the
488
+ // root being object/array) reject them. Fill in the type when it can be
489
+ // inferred unambiguously: a `discriminator` implies an object, and a
490
+ // homogeneous `oneOf`/`anyOf`/`allOf` adopts its branches' shared type.
491
+ if (property && !property.type) {
492
+ if (property.discriminator) {
493
+ property = { ...property, type: 'object' }
494
+ } else {
495
+ const branches = property.oneOf || property.anyOf || property.allOf
496
+ const types = new Set(
497
+ branches?.map(branch => branch?.type).filter(Boolean)
498
+ )
499
+ if (types.size === 1) {
500
+ property = { ...property, type: [...types][0] }
501
+ }
502
+ }
503
+ }
484
504
  return property ?? null
485
505
  }
486
506
 
@@ -1018,14 +1038,20 @@ export class Model extends objection.Model {
1018
1038
  const { assets } = this.definition
1019
1039
  if (assets) {
1020
1040
  for (const dataPath in assets) {
1021
- const data = getValueAtDataPath(json, dataPath, noop)
1022
- if (!data) continue
1023
1041
  const storage = this.app.getStorage(assets[dataPath].storage)
1024
- setValueAtDataPath(
1025
- json,
1026
- dataPath,
1027
- mapAssetFiles(data, storage, callback)
1028
- )
1042
+ // Wildcard data paths (e.g. `**.file`) expand to multiple concrete
1043
+ // paths; iterate them so each match is written back to its own
1044
+ // location instead of the flattened wildcard result.
1045
+ const entries = getEntriesAtDataPath(json, dataPath, noop)
1046
+ for (const [path, data] of Object.entries(entries)) {
1047
+ if (data) {
1048
+ setValueAtDataPath(
1049
+ json,
1050
+ path,
1051
+ mapAssetFiles(data, storage, callback)
1052
+ )
1053
+ }
1054
+ }
1029
1055
  }
1030
1056
  }
1031
1057
  }
@@ -0,0 +1,180 @@
1
+ import { Model } from './Model.js'
2
+
3
+ function makeFakeModel(assets) {
4
+ const storage = {
5
+ signAssetFile(file) {
6
+ file.signature = `sig-of-${file.key}`
7
+ }
8
+ }
9
+ return {
10
+ app: {
11
+ getStorage(name) {
12
+ return name === 'test' ? storage : null
13
+ }
14
+ },
15
+ definition: { assets }
16
+ }
17
+ }
18
+
19
+ function signCallback(file, storage) {
20
+ const signed = { ...file }
21
+ storage.signAssetFile(signed)
22
+ return signed
23
+ }
24
+
25
+ describe('Model._mapAssetFiles: asset signing across data paths', () => {
26
+ it('writes the signature back to a literal data path', () => {
27
+ const model = makeFakeModel({ 'image.file': { storage: 'test' } })
28
+ const json = { image: { file: { key: 'foo.png' } } }
29
+ Model._mapAssetFiles.call(model, json, signCallback)
30
+ expect(json.image.file.signature).toBe('sig-of-foo.png')
31
+ })
32
+
33
+ it('writes the signature back to every match of a deep wildcard', () => {
34
+ const model = makeFakeModel({ '**.file': { storage: 'test' } })
35
+ const json = {
36
+ content: {
37
+ file: { key: 'a.png' },
38
+ sections: [
39
+ { file: { key: 'b.png' } },
40
+ { file: { key: 'c.png' } }
41
+ ]
42
+ }
43
+ }
44
+ Model._mapAssetFiles.call(model, json, signCallback)
45
+ expect(json.content.file.signature).toBe('sig-of-a.png')
46
+ expect(json.content.sections[0].file.signature).toBe('sig-of-b.png')
47
+ expect(json.content.sections[1].file.signature).toBe('sig-of-c.png')
48
+ })
49
+
50
+ it('writes the signature back into wildcard array values', () => {
51
+ const model = makeFakeModel({ '**.files': { storage: 'test' } })
52
+ const json = {
53
+ content: {
54
+ files: [{ key: 'x.png' }, { key: 'y.png' }]
55
+ }
56
+ }
57
+ Model._mapAssetFiles.call(model, json, signCallback)
58
+ expect(json.content.files[0].signature).toBe('sig-of-x.png')
59
+ expect(json.content.files[1].signature).toBe('sig-of-y.png')
60
+ })
61
+
62
+ it('is a no-op when the wildcard matches nothing', () => {
63
+ const model = makeFakeModel({ '**.file': { storage: 'test' } })
64
+ const json = { name: 'no-files-here' }
65
+ Model._mapAssetFiles.call(model, json, signCallback)
66
+ expect(json).toEqual({ name: 'no-files-here' })
67
+ })
68
+ })
69
+
70
+ describe('Model.getProperty: type inference for $ref/discriminator schemas', () => {
71
+ function getProperty({ name, properties, definitions = {} }) {
72
+ return Model.getProperty.call(
73
+ { definition: { properties }, jsonSchema: { definitions } },
74
+ name
75
+ )
76
+ }
77
+
78
+ it('returns the property unchanged when it has an explicit type', () => {
79
+ expect(
80
+ getProperty({
81
+ name: 'name',
82
+ properties: { name: { type: 'string' } }
83
+ })
84
+ ).toEqual({ type: 'string' })
85
+ })
86
+
87
+ it('infers type: object from a discriminator on a $ref schema', () => {
88
+ const property = getProperty({
89
+ name: 'content',
90
+ properties: { content: { $ref: '#StoryContent' } },
91
+ definitions: {
92
+ '#StoryContent': {
93
+ discriminator: { propertyName: 'type' },
94
+ oneOf: [
95
+ { type: 'object', properties: { type: { const: 'a' } } },
96
+ { type: 'object', properties: { type: { const: 'b' } } }
97
+ ]
98
+ }
99
+ }
100
+ })
101
+ expect(property.type).toBe('object')
102
+ expect(property.discriminator).toEqual({ propertyName: 'type' })
103
+ })
104
+
105
+ it('infers a shared type from a homogeneous oneOf', () => {
106
+ const property = getProperty({
107
+ name: 'content',
108
+ properties: {
109
+ content: { oneOf: [{ type: 'object' }, { type: 'object' }] }
110
+ }
111
+ })
112
+ expect(property.type).toBe('object')
113
+ })
114
+
115
+ it('infers a shared type from a homogeneous anyOf', () => {
116
+ const property = getProperty({
117
+ name: 'content',
118
+ properties: {
119
+ content: { anyOf: [{ type: 'array' }, { type: 'array' }] }
120
+ }
121
+ })
122
+ expect(property.type).toBe('array')
123
+ })
124
+
125
+ it('leaves type undefined when oneOf branches disagree', () => {
126
+ const property = getProperty({
127
+ name: 'content',
128
+ properties: {
129
+ content: { oneOf: [{ type: 'object' }, { type: 'string' }] }
130
+ }
131
+ })
132
+ expect(property.type).toBeUndefined()
133
+ })
134
+
135
+ it('returns null for an unknown property', () => {
136
+ expect(getProperty({ name: 'missing', properties: {} })).toBeNull()
137
+ })
138
+ })
139
+
140
+ describe('Model.getPropertyOrRelationAtDataPath: traversal through inferred types', () => {
141
+ function lookup({ dataPath, properties, definitions = {} }) {
142
+ const host = {
143
+ definition: { properties },
144
+ jsonSchema: { definitions },
145
+ getRelations: () => ({}),
146
+ getProperty(name) {
147
+ return Model.getProperty.call(this, name)
148
+ }
149
+ }
150
+ return Model.getPropertyOrRelationAtDataPath.call(host, dataPath)
151
+ }
152
+
153
+ it('exposes object type for a discriminator-rooted nested wildcard path', () => {
154
+ // Reproduces the original loadDataPath() crash on
155
+ // `content.article.items[**].file`: before the fix, `content` had
156
+ // no resolved `type`, so loadDataPath rejected the nested path.
157
+ const result = lookup({
158
+ dataPath: 'content.article.items[**].file',
159
+ properties: { content: { $ref: '#StoryContent' } },
160
+ definitions: {
161
+ '#StoryContent': {
162
+ discriminator: { propertyName: 'type' },
163
+ oneOf: [{ type: 'object' }]
164
+ }
165
+ }
166
+ })
167
+ expect(result.property.type).toBe('object')
168
+ expect(result.nestedDataPath).toBe('article/items/**/file')
169
+ expect(result.dataPath).toBe('content')
170
+ })
171
+
172
+ it('returns no nestedDataPath for a leaf property lookup', () => {
173
+ const result = lookup({
174
+ dataPath: 'name',
175
+ properties: { name: { type: 'string' } }
176
+ })
177
+ expect(result.property).toEqual({ type: 'string' })
178
+ expect(result.nestedDataPath).toBe('')
179
+ })
180
+ })