@ditojs/server 1.23.0 → 1.25.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": "1.23.0",
3
+ "version": "1.25.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",
@@ -22,10 +22,10 @@
22
22
  "node >= 18"
23
23
  ],
24
24
  "dependencies": {
25
- "@ditojs/admin": "^1.23.0",
26
- "@ditojs/build": "^1.23.0",
27
- "@ditojs/router": "^1.23.0",
28
- "@ditojs/utils": "^1.23.0",
25
+ "@ditojs/admin": "^1.25.0",
26
+ "@ditojs/build": "^1.25.0",
27
+ "@ditojs/router": "^1.25.0",
28
+ "@ditojs/utils": "^1.25.0",
29
29
  "@koa/cors": "^4.0.0",
30
30
  "@koa/multer": "^3.0.2",
31
31
  "@originjs/vite-plugin-commonjs": "^1.0.3",
@@ -35,9 +35,8 @@
35
35
  "bytes": "^3.1.2",
36
36
  "data-uri-to-buffer": "^4.0.1",
37
37
  "eventemitter2": "^6.4.9",
38
- "file-type": "^18.2.0",
38
+ "file-type": "^18.2.1",
39
39
  "image-size": "^1.0.2",
40
- "is-svg": "^4.3.2",
41
40
  "koa": "^2.14.1",
42
41
  "koa-bodyparser": "^4.3.0",
43
42
  "koa-compose": "^4.1.0",
@@ -54,17 +53,17 @@
54
53
  "multer": "^1.4.5-lts.1",
55
54
  "multer-s3": "^3.0.1",
56
55
  "nanoid": "^4.0.1",
57
- "parse-duration": "^1.0.2",
56
+ "parse-duration": "^1.0.3",
58
57
  "passport-local": "^1.0.0",
59
58
  "passthrough-counter": "^1.0.0",
60
59
  "picocolors": "^1.0.0",
61
60
  "picomatch": "^2.3.1",
62
- "pino": "^8.9.0",
63
- "pino-pretty": "^9.1.1",
61
+ "pino": "^8.11.0",
62
+ "pino-pretty": "^9.4.0",
64
63
  "pluralize": "^8.0.0",
65
64
  "repl": "^0.1.3",
66
65
  "uuid": "^9.0.0",
67
- "vite": "^4.1.1",
66
+ "vite": "^4.1.4",
68
67
  "vite-plugin-vue2": "^2.0.3",
69
68
  "vue": "^2.7.14",
70
69
  "vue-template-compiler": "^2.7.14"
@@ -75,7 +74,7 @@
75
74
  "objection": "^3.0.1"
76
75
  },
77
76
  "devDependencies": {
78
- "@aws-sdk/client-s3": "^3.264.0",
77
+ "@aws-sdk/client-s3": "^3.282.0",
79
78
  "@types/koa-bodyparser": "^4.3.10",
80
79
  "@types/koa-compress": "^4.0.3",
81
80
  "@types/koa-logger": "^3.1.2",
@@ -83,15 +82,15 @@
83
82
  "@types/koa-response-time": "^2.1.2",
84
83
  "@types/koa-session": "^5.10.6",
85
84
  "@types/koa-static": "^4.0.2",
86
- "@types/koa__cors": "^3.3.0",
87
- "@types/node": "^18.11.19",
85
+ "@types/koa__cors": "^3.3.1",
86
+ "@types/node": "^18.14.6",
88
87
  "knex": "^2.4.2",
89
88
  "objection": "^3.0.1",
90
- "type-fest": "^3.5.5",
89
+ "type-fest": "^3.6.1",
91
90
  "typescript": "^4.9.5"
92
91
  },
93
92
  "types": "types",
94
- "gitHead": "3258fd030226062efa6837bfc5e5a761e80cead5",
93
+ "gitHead": "29e7f8d8c613c48a856d90d1ee876e85c8d84c08",
95
94
  "scripts": {
96
95
  "types": "tsc --noEmit ./src/index.d.ts"
97
96
  },
@@ -22,7 +22,8 @@ import { Model, knexSnakeCaseMappers, ref } from 'objection'
22
22
  import Router from '@ditojs/router'
23
23
  import {
24
24
  isArray, isObject, isString, asArray, isPlainObject, isModule,
25
- hyphenate, clone, merge, parseDataPath, normalizeDataPath, toPromiseCallback
25
+ hyphenate, clone, merge, parseDataPath, normalizeDataPath, toPromiseCallback,
26
+ mapConcurrently
26
27
  } from '@ditojs/utils'
27
28
  import SessionStore from './SessionStore.js'
28
29
  import { Validator } from './Validator.js'
@@ -389,22 +390,23 @@ export class Application extends Koa {
389
390
  for (const [assetDataPath, config] of Object.entries(assets)) {
390
391
  const {
391
392
  property,
393
+ relation,
394
+ wildcard,
392
395
  nestedDataPath,
393
- name,
394
- index
396
+ name
395
397
  } = modelClass.getPropertyOrRelationAtDataPath(assetDataPath)
396
- if (property && index === 0) {
398
+ if (relation) {
399
+ throw new Error('Assets on nested relations are not supported')
400
+ } else if (property || wildcard) {
397
401
  const normalizedName = normalizeDbNames
398
402
  ? this.normalizeIdentifier(name)
399
403
  : name
400
404
  const dataPath = normalizeDataPath([
401
- normalizedName,
405
+ wildcard || normalizedName,
402
406
  ...parseDataPath(nestedDataPath)
403
407
  ])
404
408
  const assetConfigs = convertedAssets[normalizedName] ||= {}
405
409
  assetConfigs[dataPath] = config
406
- } else {
407
- throw new Error('Nested graph properties are not supported yet')
408
410
  }
409
411
  }
410
412
  assetConfig[normalizedModelName] = convertedAssets
@@ -706,7 +708,7 @@ export class Application extends Koa {
706
708
  this.on('error', this.logError)
707
709
  }
708
710
  // It's ok to call this multiple times, because only the entries in the
709
- // registres (storages, services, models, controllers) that weren't
711
+ // registers (storages, services, models, controllers) that weren't
710
712
  // initialized yet will be initialized.
711
713
  await this.setup()
712
714
  await this.emit('before:start')
@@ -797,7 +799,7 @@ export class Application extends Koa {
797
799
  return null
798
800
  }
799
801
 
800
- async handleAdddedAndRemovedAssets(
802
+ async handleAddedAndRemovedAssets(
801
803
  storage,
802
804
  addedFiles,
803
805
  removedFiles,
@@ -808,16 +810,18 @@ export class Application extends Koa {
808
810
  cleanupTimeThreshold = 0
809
811
  } = {}
810
812
  } = this.config
811
- // Only remove unused assets that haven't seen changes for given timeframe.
813
+ // Only remove unused assets that haven't seen changes for given time frame.
812
814
  const timeThreshold = isString(cleanupTimeThreshold)
813
815
  ? parseDuration(cleanupTimeThreshold)
814
816
  : cleanupTimeThreshold
815
817
 
816
- const importedFiles = []
818
+ let importedFiles = []
817
819
  const AssetModel = this.getModel('Asset')
818
820
  if (AssetModel) {
819
- importedFiles.push(
820
- ...await this.addForeignAssets(storage, addedFiles, trx)
821
+ importedFiles = await this.addForeignAssets(
822
+ storage,
823
+ addedFiles,
824
+ trx
821
825
  )
822
826
  if (
823
827
  addedFiles.length > 0 ||
@@ -854,24 +858,23 @@ export class Application extends Koa {
854
858
  const AssetModel = this.getModel('Asset')
855
859
  if (AssetModel) {
856
860
  // Find missing assets (copied from another system), and add them.
857
- await Promise.all(
858
- files.map(async file => {
859
- const asset = await AssetModel.query(trx).findOne('key', file.key)
860
- if (!asset) {
861
- if (file.data || file.url) {
862
- let { data } = file
863
- if (!data) {
864
- const { url } = file
865
- if (!storage.isImportSourceAllowed(url)) {
866
- throw new AssetError(
861
+ await mapConcurrently(files, async file => {
862
+ const asset = await AssetModel.query(trx).findOne('key', file.key)
863
+ if (!asset) {
864
+ if (file.data || file.url) {
865
+ let { data } = file
866
+ if (!data) {
867
+ const { url } = file
868
+ if (!storage.isImportSourceAllowed(url)) {
869
+ throw new AssetError(
867
870
  `Unable to import asset from foreign source: '${
868
871
  file.name
869
872
  }' ('${
870
873
  url
871
874
  }'): The source needs to be explicitly allowed.`
872
- )
873
- }
874
- console.info(
875
+ )
876
+ }
877
+ console.info(
875
878
  `${
876
879
  pico.red('INFO:')
877
880
  } Asset ${
@@ -881,41 +884,40 @@ export class Application extends Koa {
881
884
  } and adding to storage ${
882
885
  pico.green(`'${storage.name}'`)
883
886
  }...`
884
- )
885
- if (url.startsWith('file://')) {
886
- const filepath = path.resolve(url.substring(7))
887
- data = await fs.readFile(filepath)
888
- } else {
889
- const response = await fetch(url)
890
- const buffer = await response.arrayBuffer()
891
- data = new DataView(buffer)
892
- }
887
+ )
888
+ if (url.startsWith('file://')) {
889
+ const filepath = path.resolve(url.substring(7))
890
+ data = await fs.readFile(filepath)
891
+ } else {
892
+ const response = await fetch(url)
893
+ const buffer = await response.arrayBuffer()
894
+ data = new DataView(buffer)
893
895
  }
894
- const importedFile = await storage.addFile(file, data)
895
- await this.createAssets(storage, [importedFile], 0, trx)
896
- // Merge back the changed file properties into the actual files
897
- // object, so that the data from the static model hook can be used
898
- // directly for the actual running query.
899
- Object.assign(file, importedFile)
900
- importedFiles.push(importedFile)
901
- } else {
902
- throw new AssetError(
896
+ }
897
+ const importedFile = await storage.addFile(file, data)
898
+ await this.createAssets(storage, [importedFile], 0, trx)
899
+ // Merge back the changed file properties into the actual files
900
+ // object, so that the data from the static model hook can be used
901
+ // directly for the actual running query.
902
+ Object.assign(file, importedFile)
903
+ importedFiles.push(importedFile)
904
+ } else {
905
+ throw new AssetError(
903
906
  `Unable to import asset from foreign source: '${
904
907
  file.name
905
908
  }' ('${
906
909
  file.key
907
910
  }')`
908
- )
909
- }
910
- } else {
911
- // Asset is from a foreign source, but was already imported and can
912
- // be reused. See above for an explanation of this merge.
913
- Object.assign(file, asset.file)
914
- // NOTE: No need to add `file` to `importedFiles`, since it's
915
- // already been imported to the storage before.
911
+ )
916
912
  }
917
- })
918
- )
913
+ } else {
914
+ // Asset is from a foreign source, but was already imported and can
915
+ // be reused. See above for an explanation of this merge.
916
+ Object.assign(file, asset.file)
917
+ // NOTE: No need to add `file` to `importedFiles`, since it's
918
+ // already been imported to the storage before.
919
+ }
920
+ }, { concurrency: storage.concurrency })
919
921
  }
920
922
  return importedFiles
921
923
  }
@@ -924,29 +926,27 @@ export class Application extends Koa {
924
926
  const modifiedFiles = []
925
927
  const AssetModel = this.getModel('Asset')
926
928
  if (AssetModel) {
927
- await Promise.all(
928
- files.map(async file => {
929
- if (file.data) {
930
- const asset = await AssetModel.query(trx).findOne('key', file.key)
931
- if (asset) {
932
- const changedFile = await storage.addFile(file, file.data)
933
- // Merge back the changed file properties into the actual files
934
- // object, so that the data from the static model hook can be used
935
- // directly for the actual running query.
936
- Object.assign(file, changedFile)
937
- modifiedFiles.push(changedFile)
938
- } else {
939
- throw new AssetError(
929
+ await mapConcurrently(files, async file => {
930
+ if (file.data) {
931
+ const asset = await AssetModel.query(trx).findOne('key', file.key)
932
+ if (asset) {
933
+ const changedFile = await storage.addFile(file, file.data)
934
+ // Merge back the changed file properties into the actual files
935
+ // object, so that the data from the static model hook can be used
936
+ // directly for the actual running query.
937
+ Object.assign(file, changedFile)
938
+ modifiedFiles.push(changedFile)
939
+ } else {
940
+ throw new AssetError(
940
941
  `Unable to update modified asset from memory source: '${
941
942
  file.name
942
943
  }' ('${
943
944
  file.key
944
945
  }')`
945
- )
946
- }
946
+ )
947
947
  }
948
- })
949
- )
948
+ }
949
+ })
950
950
  }
951
951
  return modifiedFiles
952
952
  }
@@ -967,8 +967,9 @@ export class Application extends Koa {
967
967
  // .e.g. when `config.assets.cleanupTimeThreshold = 0`
968
968
  .andWhere('updatedAt', '>', ref('createdAt'))
969
969
  if (orphanedAssets.length > 0) {
970
- const orphanedKeys = await Promise.all(
971
- orphanedAssets.map(async asset => {
970
+ const orphanedKeys = await mapConcurrently(
971
+ orphanedAssets,
972
+ async asset => {
972
973
  try {
973
974
  await this.getStorage(asset.storage).removeFile(asset.file)
974
975
  } catch (error) {
@@ -976,7 +977,7 @@ export class Application extends Koa {
976
977
  asset.error = error
977
978
  }
978
979
  return asset.key
979
- })
980
+ }
980
981
  )
981
982
  await AssetModel
982
983
  .query(trx)
@@ -29,7 +29,7 @@ export class AdminController extends Controller {
29
29
  }
30
30
  // If no mode is specified, use `production` since that's just the hosting
31
31
  // of the pre-built admin files. `development` serves the admin directly
32
- // sources with HRM, and thus should be explicitely activated.
32
+ // sources with HRM, and thus should be explicitly activated.
33
33
  this.mode = this.config.mode || (
34
34
  this.app.config.env === 'development' ? 'development' : 'production'
35
35
  )
@@ -36,7 +36,7 @@ export class CollectionController extends Controller {
36
36
  this.assets = modelClass.definition.assets || null
37
37
  } else if (isObject(this.assets)) {
38
38
  // Merge in the assets definition from the model into the assets config.
39
- // That way, we can still use `allow` and `authorize` to controll the
39
+ // That way, we can still use `allow` and `authorize` to control the
40
40
  // upload access, while keeping the assets definitions in one central
41
41
  // location on the model.
42
42
  this.assets = {
@@ -228,14 +228,10 @@ export class Controller {
228
228
  const tokens = parseDataPath(dataPath)
229
229
  const getDataPath = callback => normalizeDataPath(tokens.map(callback))
230
230
 
231
- // Replace wildcards with numbered indices and convert to '/'-notation:
232
- let index = 0
233
- const multipleWildcards = tokens.filter(token => token === '*').length > 1
234
231
  const normalizedPath = getDataPath(
235
- token => token === '*'
236
- ? multipleWildcards
237
- ? `:index${++index}`
238
- : ':index'
232
+ // Router supports both shallow & deep wildcards, no normalization needed.
233
+ token => token === '*' || token === '**'
234
+ ? token
239
235
  : this.app.normalizePath(token)
240
236
  )
241
237
 
@@ -243,7 +239,16 @@ export class Controller {
243
239
  // against, but convert wildcards (*) to match both numeric ids and words,
244
240
  // e.g. 'create':
245
241
  const matchDataPath = new RegExp(
246
- `^${getDataPath(token => token === '*' ? '\\w+' : token)}$`
242
+ `^${
243
+ getDataPath(
244
+ // Use the exact same regexps as in `Router`:
245
+ token => token === '*'
246
+ ? '[^/]+' // shallow wildcard
247
+ : token === '**'
248
+ ? '.+?' // deep wildcard
249
+ : token
250
+ )
251
+ }$`
247
252
  )
248
253
 
249
254
  const url = this.getUrl('controller', `upload/${normalizedPath}`)
@@ -557,7 +562,7 @@ function convertActionObject(name, object, actions) {
557
562
  ...rest
558
563
  } = object
559
564
 
560
- // In order to suport `super` calls in the `handler` function in object
565
+ // In order to support `super` calls in the `handler` function in object
561
566
  // notation, deploy this crazy JS sorcery:
562
567
  Object.setPrototypeOf(object, Object.getPrototypeOf(actions))
563
568
 
@@ -123,7 +123,7 @@ export default class ControllerAction {
123
123
  type, // String: What type should this validated against / coerced to.
124
124
  from, // String: Allow parameters to be 'borrowed' from other objects.
125
125
  root, // Boolean: Use full root object, instead of data at given property.
126
- member // Boolean: Fetch member instance insted of data from request.
126
+ member // Boolean: Fetch member instance instead of data from request.
127
127
  } of this.parameters.list) {
128
128
  // Don't validate member parameters as they get resolved separately after.
129
129
  if (member) continue
@@ -271,7 +271,7 @@ export default class ControllerAction {
271
271
  // - `"key1":X, "key2":Y` (curly braces are added and parsed through
272
272
  // `JSON.parse()`)
273
273
  // - `key1:X,key2:Y` (a simple parser is applied, splitting into
274
- // entries and key/value pairs, valuse are parsed with
274
+ // entries and key/value pairs, values are parsed with
275
275
  // `JSON.parse()`, falling back to string.
276
276
  if (/"/.test(value)) {
277
277
  // Just add the curly braces and parse as JSON
@@ -10,12 +10,12 @@ export default class MemberAction extends ControllerAction {
10
10
  // @override
11
11
  async getMember(ctx, param) {
12
12
  // member parameters can provide special query parameters as well,
13
- // and they can even controll `forUpdate()` behavior:
13
+ // and they can even control `forUpdate()` behavior:
14
14
  // {
15
15
  // member: true,
16
16
  // query: { ... },
17
17
  // forUpdate: true,
18
- // modify: query => query.degbug()
18
+ // modify: query => query.debug()
19
19
  // }
20
20
  // These are passed on to and handled in `CollectionController#getMember()`.
21
21
  // For handling of `member: true` and calling of `MemberAction.getMember()`,
@@ -44,7 +44,7 @@ export class ModelController extends CollectionController {
44
44
  this, object, relationInstance, relationDefinition
45
45
  )
46
46
  // RelationController instances are not registered with the app, but are
47
- // manged by their parent controller instead.
47
+ // managed by their parent controller instead.
48
48
  relation.configure()
49
49
  relation.setup()
50
50
  return relation
@@ -1,4 +1,4 @@
1
- import { isObject, isArray, asArray } from '@ditojs/utils'
1
+ import { isObject, isArray, asArray, mapConcurrently } from '@ditojs/utils'
2
2
  import { QueryBuilder } from '../query/index.js'
3
3
  import { collectExpressionPaths, expressionPathToString } from './expression.js'
4
4
 
@@ -212,8 +212,9 @@ export async function populateGraph(rootModelClass, graph, expr, trx) {
212
212
  // Load all found models by ids asynchronously, within provided transaction.
213
213
  // NOTE: Using the same transaction means that all involved tables need to
214
214
  // be in the same database.
215
- await Promise.all(
216
- groups.map(async ({ modelClass, modify, expr, ids, modelsById }) => {
215
+ await mapConcurrently(
216
+ groups,
217
+ async ({ modelClass, modify, expr, ids, modelsById }) => {
217
218
  const query = modelClass.query(trx).findByIds(ids).modify(modify)
218
219
  if (expr) {
219
220
  // TODO: Make algorithm configurable through options.
@@ -224,7 +225,7 @@ export async function populateGraph(rootModelClass, graph, expr, trx) {
224
225
  for (const model of models) {
225
226
  modelsById[model.$id()] = model
226
227
  }
227
- })
228
+ }
228
229
  )
229
230
 
230
231
  // Finally populate the targets with the loaded models.
@@ -40,7 +40,7 @@ export function handleConnectMiddleware(middleware, {
40
40
  if (isArray(headers)) {
41
41
  // Convert raw headers array to object.
42
42
  headers = Object.fromEntries(headers.reduce(
43
- // Translate raw array to [field, value] tuplets.
43
+ // Translate raw array to [field, value] tuples.
44
44
  (entries, value, index) => {
45
45
  if (index & 1) { // Odd: value
46
46
  entries[entries.length - 1].push(value)
@@ -36,7 +36,7 @@ export const AssetMixin = mixin(Model => class extends TimeStampedMixin(Model) {
36
36
  type: 'integer',
37
37
  required: true
38
38
  },
39
- // Use for storages configured for files to be publically accessible:
39
+ // Use for storages configured for files to be publicly accessible:
40
40
  url: {
41
41
  type: 'string'
42
42
  },
@@ -1,7 +1,8 @@
1
1
  import objection from 'objection'
2
2
  import {
3
3
  isString, isObject, isArray, isFunction, isPromise, asArray, merge, flatten,
4
- parseDataPath, normalizeDataPath, getValueAtDataPath
4
+ parseDataPath, normalizeDataPath, getValueAtDataPath,
5
+ mapConcurrently
5
6
  } from '@ditojs/utils'
6
7
  import { QueryBuilder } from '../query/index.js'
7
8
  import { EventEmitter, KnexHelper } from '../lib/index.js'
@@ -617,9 +618,13 @@ export class Model extends objection.Model {
617
618
  const parsedDataPath = parseDataPath(dataPath)
618
619
  let index = 0
619
620
 
620
- const getResult = (property = null, relation = null) => {
621
- const found = property || relation
622
- const name = parsedDataPath[index]
621
+ const getResult = ({
622
+ property = null,
623
+ relation = null,
624
+ wildcard = null
625
+ } = {}) => {
626
+ const found = !!(property || relation || wildcard)
627
+ const name = wildcard ? '*' : parsedDataPath[index]
623
628
  const next = index + 1
624
629
  const dataPath = found
625
630
  ? normalizeDataPath(parsedDataPath.slice(0, next))
@@ -634,18 +639,20 @@ export class Model extends objection.Model {
634
639
  return {
635
640
  property,
636
641
  relation,
642
+ wildcard,
637
643
  dataPath,
638
644
  nestedDataPath,
639
645
  name,
640
- expression,
641
- index
646
+ expression
642
647
  }
643
648
  }
644
649
 
645
650
  const [firstToken, ...otherTokens] = parsedDataPath
646
651
  const property = this.definition.properties[firstToken]
647
652
  if (property) {
648
- return getResult(property)
653
+ return getResult({ property })
654
+ } else if (firstToken === '*' || firstToken === '**') {
655
+ return getResult({ wildcard: firstToken })
649
656
  } else {
650
657
  let relation = this.getRelations()[firstToken]
651
658
  if (relation) {
@@ -654,27 +661,32 @@ export class Model extends objection.Model {
654
661
  index++
655
662
  const property = relatedModelClass.definition.properties[token]
656
663
  if (property) {
657
- return getResult(property)
664
+ return getResult({ property, relation })
658
665
  } else if (token === '*') {
659
666
  if (relation.isOneToOne()) {
660
667
  // Do not support wildcards on one-to-one relations:
661
- return getResult()
668
+ return getResult() // Not found.
662
669
  } else {
663
670
  continue
664
671
  }
672
+ } else if (token === '**') {
673
+ throw new ModelError(
674
+ this,
675
+ 'Deep wildcards on relations are unsupported.'
676
+ )
665
677
  } else {
666
678
  // Found a relation? Keep iterating.
667
679
  relation = relatedModelClass.getRelations()[token]
668
680
  if (relation) {
669
681
  relatedModelClass = relation.relatedModelClass
670
682
  } else {
671
- return getResult()
683
+ return getResult() // Not found.
672
684
  }
673
685
  }
674
686
  }
675
687
  if (relation) {
676
688
  // Still here? Found a relation at the end of the data-path.
677
- return getResult(null, relation)
689
+ return getResult({ relation })
678
690
  }
679
691
  }
680
692
  }
@@ -704,6 +716,10 @@ export class Model extends objection.Model {
704
716
  return query.ignoreScope(modifier.slice(1))
705
717
  case '#': // Select column:
706
718
  return query.select(modifier.slice(1))
719
+ case '*': // Select all columns:
720
+ if (modifier.length === 1) {
721
+ return query.select('*')
722
+ }
707
723
  }
708
724
  }
709
725
  super.modifierNotFound(query, modifier)
@@ -958,10 +974,9 @@ export class Model extends objection.Model {
958
974
  importedFiles.map(file => `'${file.name}'`)
959
975
  }`
960
976
  )
961
- await Promise.all(
962
- importedFiles.map(
963
- file => file.storage.removeFile(file)
964
- )
977
+ await mapConcurrently(
978
+ importedFiles,
979
+ file => file.storage.removeFile(file)
965
980
  )
966
981
  }
967
982
  if (modifiedFiles.length > 0) {
@@ -985,14 +1000,14 @@ export class Model extends objection.Model {
985
1000
  const removedFiles = beforeFiles.filter(file => !afterByKey[file.key])
986
1001
  const addedFiles = afterFiles.filter(file => !beforeByKey[file.key])
987
1002
  // Also handle modified files, which are files where the data property
988
- // is changed before update / patch, meanting the file is changed.
1003
+ // is changed before update / patch, meaning the file is changed.
989
1004
  // NOTE: This will change the content for all the references to it,
990
1005
  // and thus should only really be used when there's only one reference.
991
1006
  const modifiedFiles = afterFiles.filter(
992
1007
  file => file.data && beforeByKey[file.key]
993
1008
  )
994
1009
  importedFiles.push(
995
- ...await this.app.handleAdddedAndRemovedAssets(
1010
+ ...await this.app.handleAddedAndRemovedAssets(
996
1011
  storage,
997
1012
  addedFiles,
998
1013
  removedFiles,
@@ -48,7 +48,7 @@ export class QueryBuilder extends objection.QueryBuilder {
48
48
  // to not mess with special selects such as `count`, etc:
49
49
  this._ignoreGraph = !isNormalFind
50
50
  // All scopes in `_scopes` were already checked against `_allowScopes`.
51
- // They themeselves are allowed to apply / request other scopes that
51
+ // They themselves are allowed to apply / request other scopes that
52
52
  // aren't listed, so clear `_applyScope` and restore again after:
53
53
  const { _allowScopes } = this
54
54
  this._allowScopes = null
@@ -347,16 +347,22 @@ export class QueryBuilder extends objection.QueryBuilder {
347
347
 
348
348
  const {
349
349
  property,
350
+ relation,
351
+ wildcard,
350
352
  expression,
351
353
  nestedDataPath,
352
- name,
353
- index
354
+ name
354
355
  } = this.modelClass().getPropertyOrRelationAtDataPath(parsedDataPath)
355
356
 
356
357
  if (nestedDataPath) {
357
358
  // Once a JSON data type is reached, even if it's not at the end of the
358
359
  // provided path, load it and assume we're done with the loading part.
359
- if (!(property && ['object', 'array'].includes(property.type))) {
360
+ if (
361
+ !(
362
+ wildcard ||
363
+ property && ['object', 'array'].includes(property.type)
364
+ )
365
+ ) {
360
366
  throw new QueryBuilderError(
361
367
  `Unable to load full data-path '${
362
368
  dataPath
@@ -369,7 +375,7 @@ export class QueryBuilder extends objection.QueryBuilder {
369
375
 
370
376
  // Handle the special case of root-level property separately,
371
377
  // because `withGraph('(#propertyName)')` is not supported.
372
- if (property && index === 0) {
378
+ if (name && !relation) {
373
379
  this.select(name)
374
380
  } else {
375
381
  this.withGraph(expression, options)
@@ -12,7 +12,7 @@ export function convertSchema(schema, options = {}) {
12
12
  // Our 'required' is not the same as JSON Schema's: Use the 'required'
13
13
  // format instead that only validates if the required value is not empty,
14
14
  // meaning neither nullish nor an empty string. The JSON schema `required`
15
- // array is generated seperately below through `convertProperties()`.
15
+ // array is generated separately below through `convertProperties()`.
16
16
  delete schema.required
17
17
  schema = addFormat(schema, 'required')
18
18
  }
@@ -58,7 +58,7 @@ export function convertSchema(schema, options = {}) {
58
58
  schema.type = jsonType
59
59
  if (hasConvertedProperties && !('additionalProperties' in schema)) {
60
60
  // Invert the logic of `additionalProperties` so that it needs to be
61
- // explicitely set to `true`:
61
+ // explicitly set to `true`:
62
62
  schema.additionalProperties = false
63
63
  }
64
64
  } else if (['date', 'datetime', 'timestamp'].includes(type)) {
@@ -69,7 +69,7 @@ describe('convertSchema()', () => {
69
69
  })
70
70
  })
71
71
 
72
- it(`expands 'text' typess to 'string' JSON schema typess`, () => {
72
+ it(`expands 'text' types to 'string' JSON schema types`, () => {
73
73
  expect(convertSchema({
74
74
  type: 'object',
75
75
  properties: {
@@ -159,7 +159,7 @@ describe('convertSchema()', () => {
159
159
  })
160
160
  })
161
161
 
162
- it('preserves preexisting settings for no additional properties', () => {
162
+ it('preserves pre-existing settings for no additional properties', () => {
163
163
  expect(convertSchema({
164
164
  type: 'object',
165
165
  properties: {
@@ -8,11 +8,13 @@ const SYMBOL_STORAGE = Symbol('storage')
8
8
  const SYMBOL_DATA = Symbol('data')
9
9
 
10
10
  export class AssetFile {
11
- constructor(name, data, type) {
11
+ constructor({ name, data, type, width, height }) {
12
12
  this.key = AssetFile.getUniqueKey(name)
13
13
  this.name = name
14
14
  // Set `type` before `data`, so it can be used as default in `set data`
15
15
  this.type = type
16
+ this.width = width
17
+ this.height = height
16
18
  this.data = data
17
19
  }
18
20
 
@@ -60,8 +62,8 @@ export class AssetFile {
60
62
  return object
61
63
  }
62
64
 
63
- static create({ name, data, type }) {
64
- return new AssetFile(name, data, type)
65
+ static create(options) {
66
+ return new AssetFile(options)
65
67
  }
66
68
 
67
69
  static getUniqueKey(name) {
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs/promises'
2
2
  import path from 'path'
3
3
  import multer from '@koa/multer'
4
+ import { mapConcurrently } from '@ditojs/utils'
4
5
  import { Storage } from './Storage.js'
5
6
 
6
7
  export class DiskStorage extends Storage {
@@ -38,11 +39,11 @@ export class DiskStorage extends Storage {
38
39
  }
39
40
 
40
41
  // @override
41
- async _addFile(file, buffer) {
42
+ async _addFile(file, data) {
42
43
  const filePath = this._getFilePath(file)
43
44
  const dir = path.dirname(filePath)
44
45
  await fs.mkdir(dir, { recursive: true })
45
- await fs.writeFile(filePath, buffer)
46
+ await fs.writeFile(filePath, data)
46
47
  return file
47
48
  }
48
49
 
@@ -82,12 +83,14 @@ export class DiskStorage extends Storage {
82
83
 
83
84
  const files = []
84
85
  const list1 = await readDir()
85
- await Promise.all(
86
- list1.map(async level1 => {
86
+ await mapConcurrently(
87
+ list1,
88
+ async level1 => {
87
89
  if (level1.isDirectory() && level1.name.length === 1) {
88
90
  const list2 = await readDir(level1.name)
89
- await Promise.all(
90
- list2.map(async level2 => {
91
+ await mapConcurrently(
92
+ list2,
93
+ async level2 => {
91
94
  if (level2.isDirectory() && level2.name.length === 1) {
92
95
  const nestedFolder = this._getPath(level1.name, level2.name)
93
96
  for (const file of await fs.readdir(nestedFolder)) {
@@ -96,10 +99,10 @@ export class DiskStorage extends Storage {
96
99
  }
97
100
  }
98
101
  }
99
- })
102
+ }
100
103
  )
101
104
  }
102
- })
105
+ }
103
106
  )
104
107
  return files
105
108
  }
@@ -1,9 +1,9 @@
1
1
  import multerS3 from 'multer-s3'
2
2
  import { fileTypeFromBuffer } from 'file-type'
3
- import isSvg from 'is-svg'
4
3
  import { Storage } from './Storage.js'
5
4
  import { PassThrough } from 'stream'
6
5
  import consumers from 'stream/consumers'
6
+ import imageSize from 'image-size'
7
7
 
8
8
  export class S3Storage extends Storage {
9
9
  static type = 's3'
@@ -47,6 +47,7 @@ export class S3Storage extends Storage {
47
47
  let data = null
48
48
 
49
49
  const done = type => {
50
+ stream.off('data', onData)
50
51
  const outStream = new PassThrough()
51
52
  outStream.write(data)
52
53
  stream.pipe(outStream)
@@ -56,20 +57,15 @@ export class S3Storage extends Storage {
56
57
  const onData = chunk => {
57
58
  if (!data) {
58
59
  // 2. Try reading the mimetype from the first chunk.
59
- const type = fileTypeFromBuffer(chunk)?.mime
60
+ const type = getFileTypeFromBuffer(chunk)
60
61
  if (type) {
61
- stream.off('data', onData)
62
62
  done(type)
63
63
  } else {
64
64
  // 3. If that fails, keep collecting all chunks and determine
65
65
  // the mimetype using the full data.
66
- stream.once('end', () => {
67
- const type = (
68
- fileTypeFromBuffer(data)?.mime ||
69
- (isSvg(data) ? 'image/svg+xml' : 'application/octet-stream')
70
- )
71
- done(type)
72
- })
66
+ stream.once('end', () => done(
67
+ getFileTypeFromBuffer(data) || 'application/octet-stream'
68
+ ))
73
69
  }
74
70
  }
75
71
  data = data ? Buffer.concat([data, chunk]) : chunk
@@ -107,19 +103,19 @@ export class S3Storage extends Storage {
107
103
  }
108
104
 
109
105
  // @override
110
- async _addFile(file, buffer) {
111
- const data = await this.s3.putObject({
106
+ async _addFile(file, data) {
107
+ const result = await this.s3.putObject({
112
108
  Bucket: this.bucket,
113
109
  ACL: this.acl,
114
110
  Key: file.key,
115
111
  ContentType: file.type,
116
- Body: buffer
112
+ Body: data
117
113
  })
118
114
  // "Convert" `file` to something looking more like a S3 `storageFile`.
119
115
  // For now, only the `location` property is of interest:
120
116
  return {
121
117
  ...file,
122
- location: data.Location
118
+ location: result.Location
123
119
  }
124
120
  }
125
121
 
@@ -163,3 +159,33 @@ export class S3Storage extends Storage {
163
159
  return files
164
160
  }
165
161
  }
162
+
163
+ function getFileTypeFromBuffer(buffer) {
164
+ const type = fileTypeFromBuffer(buffer)
165
+ if (type) {
166
+ return type.mime
167
+ }
168
+ try {
169
+ const { type } = imageSize(buffer)
170
+ return {
171
+ jpg: 'image/jpeg',
172
+ png: 'image/png',
173
+ gif: 'image/gif',
174
+ svg: 'image/svg+xml',
175
+ webp: 'image/webp',
176
+ tiff: 'image/tiff',
177
+ j2c: 'image/jp2',
178
+ jp2: 'image/jp2',
179
+ ktx: 'image/ktx',
180
+ bmp: 'image/bmp',
181
+ tga: 'image/x-targa',
182
+ cur: 'image/x-win-bitmap',
183
+ icns: 'image/x-icon',
184
+ ico: 'image/x-icon',
185
+ pnm: 'image/x-portable-anymap',
186
+ dds: 'image/vnd-ms.dds',
187
+ psd: 'image/vnd.adobe.photoshop'
188
+ }[type]
189
+ } catch (err) {}
190
+ return null
191
+ }
@@ -18,6 +18,7 @@ export class Storage {
18
18
  this.name = config.name
19
19
  this.url = config.url
20
20
  this.path = config.path
21
+ this.concurrency = config.concurrency ?? null
21
22
  // The actual multer storage object.
22
23
  this.storage = null
23
24
  }
@@ -94,9 +95,9 @@ export class Storage {
94
95
  return storageFiles.map(storageFile => this.convertStorageFile(storageFile))
95
96
  }
96
97
 
97
- async addFile(file, buffer) {
98
- const storageFile = await this._addFile(file, buffer)
99
- file.size = Buffer.byteLength(buffer)
98
+ async addFile(file, data) {
99
+ const storageFile = await this._addFile(file, data)
100
+ file.size = Buffer.byteLength(data)
100
101
  file.url = this._getFileUrl(storageFile)
101
102
  // TODO: Support `config.readImageSize`, but this can only be done onces
102
103
  // there are separate storage instances per model assets config!
@@ -146,7 +147,7 @@ export class Storage {
146
147
  _getFileUrl(_file) {}
147
148
 
148
149
  // @overridable
149
- async _addFile(_file, _buffer) {}
150
+ async _addFile(_file, _data) {}
150
151
 
151
152
  // @overridable
152
153
  async _removeFile(_file) {}
@@ -1,7 +1,8 @@
1
+ import { mapConcurrently } from '@ditojs/utils'
2
+
1
3
  export function emitAsync(emitter, event, ...args) {
2
- return Promise.all(
3
- emitter.listeners(event).map(
4
- listener => listener.call(emitter, ...args)
5
- )
4
+ return mapConcurrently(
5
+ emitter.listeners(event),
6
+ listener => listener.call(emitter, ...args)
6
7
  )
7
8
  }