@ditojs/server 1.24.0 → 1.25.1

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.24.0",
3
+ "version": "1.25.1",
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.24.0",
26
- "@ditojs/build": "^1.24.0",
27
- "@ditojs/router": "^1.24.0",
28
- "@ditojs/utils": "^1.24.0",
25
+ "@ditojs/admin": "^1.25.0",
26
+ "@ditojs/build": "^1.25.0",
27
+ "@ditojs/router": "^1.25.1",
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",
@@ -53,7 +53,7 @@
53
53
  "multer": "^1.4.5-lts.1",
54
54
  "multer-s3": "^3.0.1",
55
55
  "nanoid": "^4.0.1",
56
- "parse-duration": "^1.0.2",
56
+ "parse-duration": "^1.0.3",
57
57
  "passport-local": "^1.0.0",
58
58
  "passthrough-counter": "^1.0.0",
59
59
  "picocolors": "^1.0.0",
@@ -74,7 +74,7 @@
74
74
  "objection": "^3.0.1"
75
75
  },
76
76
  "devDependencies": {
77
- "@aws-sdk/client-s3": "^3.264.0",
77
+ "@aws-sdk/client-s3": "^3.282.0",
78
78
  "@types/koa-bodyparser": "^4.3.10",
79
79
  "@types/koa-compress": "^4.0.3",
80
80
  "@types/koa-logger": "^3.1.2",
@@ -83,14 +83,14 @@
83
83
  "@types/koa-session": "^5.10.6",
84
84
  "@types/koa-static": "^4.0.2",
85
85
  "@types/koa__cors": "^3.3.1",
86
- "@types/node": "^18.14.2",
86
+ "@types/node": "^18.14.6",
87
87
  "knex": "^2.4.2",
88
88
  "objection": "^3.0.1",
89
89
  "type-fest": "^3.6.1",
90
90
  "typescript": "^4.9.5"
91
91
  },
92
92
  "types": "types",
93
- "gitHead": "0ca96ce0335509daa9ebbac278ae2d346015baad",
93
+ "gitHead": "bebbf44ddf15a565e50ed8b2cbe0bf8a463c276d",
94
94
  "scripts": {
95
95
  "types": "tsc --noEmit ./src/index.d.ts"
96
96
  },
@@ -6,7 +6,6 @@ import Koa from 'koa'
6
6
  import Knex from 'knex'
7
7
  import pico from 'picocolors'
8
8
  import pino from 'pino'
9
- import parseDuration from 'parse-duration'
10
9
  import bodyParser from 'koa-bodyparser'
11
10
  import cors from '@koa/cors'
12
11
  import compose from 'koa-compose'
@@ -21,8 +20,9 @@ import responseTime from 'koa-response-time'
21
20
  import { Model, knexSnakeCaseMappers, ref } from 'objection'
22
21
  import Router from '@ditojs/router'
23
22
  import {
24
- isArray, isObject, isString, asArray, isPlainObject, isModule,
25
- hyphenate, clone, merge, parseDataPath, normalizeDataPath, toPromiseCallback
23
+ isArray, isObject, asArray, isPlainObject, isModule,
24
+ hyphenate, clone, merge, parseDataPath, normalizeDataPath,
25
+ toPromiseCallback, mapConcurrently
26
26
  } from '@ditojs/utils'
27
27
  import SessionStore from './SessionStore.js'
28
28
  import { Validator } from './Validator.js'
@@ -31,7 +31,7 @@ import { Controller, AdminController } from '../controllers/index.js'
31
31
  import { Service } from '../services/index.js'
32
32
  import { Storage } from '../storage/index.js'
33
33
  import { convertSchema } from '../schema/index.js'
34
- import { deprecate } from '../utils/index.js'
34
+ import { getDuration, subtractDuration, deprecate } from '../utils/index.js'
35
35
  import {
36
36
  ResponseError,
37
37
  ValidationError,
@@ -65,6 +65,7 @@ export class Application extends Koa {
65
65
  // Pluck keys out of `config.app` to keep them secret
66
66
  app: { keys, ...app } = {},
67
67
  log,
68
+ assets,
68
69
  logger,
69
70
  ...rest
70
71
  } = config
@@ -73,6 +74,7 @@ export class Application extends Koa {
73
74
  log: log === false || log?.silent || process.env.DITO_SILENT
74
75
  ? {}
75
76
  : getOptions(log),
77
+ assets: merge(defaultAssetOptions, getOptions(assets)),
76
78
  logger: merge(defaultLoggerOptions, getOptions(logger)),
77
79
  ...rest
78
80
  }
@@ -389,22 +391,23 @@ export class Application extends Koa {
389
391
  for (const [assetDataPath, config] of Object.entries(assets)) {
390
392
  const {
391
393
  property,
394
+ relation,
395
+ wildcard,
392
396
  nestedDataPath,
393
- name,
394
- index
397
+ name
395
398
  } = modelClass.getPropertyOrRelationAtDataPath(assetDataPath)
396
- if (property && index === 0) {
399
+ if (relation) {
400
+ throw new Error('Assets on nested relations are not supported')
401
+ } else if (property || wildcard) {
397
402
  const normalizedName = normalizeDbNames
398
403
  ? this.normalizeIdentifier(name)
399
404
  : name
400
405
  const dataPath = normalizeDataPath([
401
- normalizedName,
406
+ wildcard || normalizedName,
402
407
  ...parseDataPath(nestedDataPath)
403
408
  ])
404
409
  const assetConfigs = convertedAssets[normalizedName] ||= {}
405
410
  assetConfigs[dataPath] = config
406
- } else {
407
- throw new Error('Nested graph properties are not supported yet')
408
411
  }
409
412
  }
410
413
  assetConfig[normalizedModelName] = convertedAssets
@@ -803,21 +806,13 @@ export class Application extends Koa {
803
806
  removedFiles,
804
807
  trx = null
805
808
  ) {
806
- const {
807
- assets: {
808
- cleanupTimeThreshold = 0
809
- } = {}
810
- } = this.config
811
- // Only remove unused assets that haven't seen changes for given time frame.
812
- const timeThreshold = isString(cleanupTimeThreshold)
813
- ? parseDuration(cleanupTimeThreshold)
814
- : cleanupTimeThreshold
815
-
816
- const importedFiles = []
809
+ let importedFiles = []
817
810
  const AssetModel = this.getModel('Asset')
818
811
  if (AssetModel) {
819
- importedFiles.push(
820
- ...await this.addForeignAssets(storage, addedFiles, trx)
812
+ importedFiles = await this.addForeignAssets(
813
+ storage,
814
+ addedFiles,
815
+ trx
821
816
  )
822
817
  if (
823
818
  addedFiles.length > 0 ||
@@ -833,18 +828,20 @@ export class Application extends Koa {
833
828
  changeCount(addedFiles, 1),
834
829
  changeCount(removedFiles, -1)
835
830
  ])
836
- if (timeThreshold > 0) {
831
+ const cleanupTimeThreshold =
832
+ getDuration(this.config.assets.cleanupTimeThreshold)
833
+ if (cleanupTimeThreshold > 0) {
837
834
  setTimeout(
838
835
  // Don't pass `trx` here, as we want this delayed execution to
839
836
  // create its own transaction.
840
- () => this.releaseUnusedAssets(timeThreshold),
841
- timeThreshold
837
+ () => this.releaseUnusedAssets(),
838
+ cleanupTimeThreshold
842
839
  )
843
840
  }
844
841
  }
845
842
  // Also execute releaseUnusedAssets() immediately in the same
846
843
  // transaction, to potentially clean up other pending assets.
847
- await this.releaseUnusedAssets(timeThreshold, trx)
844
+ await this.releaseUnusedAssets(null, trx)
848
845
  return importedFiles
849
846
  }
850
847
  }
@@ -854,24 +851,23 @@ export class Application extends Koa {
854
851
  const AssetModel = this.getModel('Asset')
855
852
  if (AssetModel) {
856
853
  // 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(
854
+ await mapConcurrently(files, async file => {
855
+ const asset = await AssetModel.query(trx).findOne('key', file.key)
856
+ if (!asset) {
857
+ if (file.data || file.url) {
858
+ let { data } = file
859
+ if (!data) {
860
+ const { url } = file
861
+ if (!storage.isImportSourceAllowed(url)) {
862
+ throw new AssetError(
867
863
  `Unable to import asset from foreign source: '${
868
864
  file.name
869
865
  }' ('${
870
866
  url
871
867
  }'): The source needs to be explicitly allowed.`
872
- )
873
- }
874
- console.info(
868
+ )
869
+ }
870
+ console.info(
875
871
  `${
876
872
  pico.red('INFO:')
877
873
  } Asset ${
@@ -881,41 +877,40 @@ export class Application extends Koa {
881
877
  } and adding to storage ${
882
878
  pico.green(`'${storage.name}'`)
883
879
  }...`
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
- }
880
+ )
881
+ if (url.startsWith('file://')) {
882
+ const filepath = path.resolve(url.substring(7))
883
+ data = await fs.readFile(filepath)
884
+ } else {
885
+ const response = await fetch(url)
886
+ const buffer = await response.arrayBuffer()
887
+ data = new DataView(buffer)
893
888
  }
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(
889
+ }
890
+ const importedFile = await storage.addFile(file, data)
891
+ await this.createAssets(storage, [importedFile], 0, trx)
892
+ // Merge back the changed file properties into the actual files
893
+ // object, so that the data from the static model hook can be used
894
+ // directly for the actual running query.
895
+ Object.assign(file, importedFile)
896
+ importedFiles.push(importedFile)
897
+ } else {
898
+ throw new AssetError(
903
899
  `Unable to import asset from foreign source: '${
904
900
  file.name
905
901
  }' ('${
906
902
  file.key
907
903
  }')`
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.
904
+ )
916
905
  }
917
- })
918
- )
906
+ } else {
907
+ // Asset is from a foreign source, but was already imported and can
908
+ // be reused. See above for an explanation of this merge.
909
+ Object.assign(file, asset.file)
910
+ // NOTE: No need to add `file` to `importedFiles`, since it's
911
+ // already been imported to the storage before.
912
+ }
913
+ }, { concurrency: storage.concurrency })
919
914
  }
920
915
  return importedFiles
921
916
  }
@@ -924,51 +919,63 @@ export class Application extends Koa {
924
919
  const modifiedFiles = []
925
920
  const AssetModel = this.getModel('Asset')
926
921
  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(
922
+ await mapConcurrently(files, async file => {
923
+ if (file.data) {
924
+ const asset = await AssetModel.query(trx).findOne('key', file.key)
925
+ if (asset) {
926
+ const changedFile = await storage.addFile(file, file.data)
927
+ // Merge back the changed file properties into the actual files
928
+ // object, so that the data from the static model hook can be used
929
+ // directly for the actual running query.
930
+ Object.assign(file, changedFile)
931
+ modifiedFiles.push(changedFile)
932
+ } else {
933
+ throw new AssetError(
940
934
  `Unable to update modified asset from memory source: '${
941
935
  file.name
942
936
  }' ('${
943
937
  file.key
944
938
  }')`
945
- )
946
- }
939
+ )
947
940
  }
948
- })
949
- )
941
+ }
942
+ })
950
943
  }
951
944
  return modifiedFiles
952
945
  }
953
946
 
954
- async releaseUnusedAssets(timeThreshold = 0, trx = null) {
947
+ async releaseUnusedAssets(timeThreshold = null, trx = null) {
955
948
  const AssetModel = this.getModel('Asset')
956
949
  if (AssetModel) {
950
+ const { assets } = this.config
951
+ const cleanupTimeThreshold =
952
+ getDuration(timeThreshold ?? assets.cleanupTimeThreshold)
953
+ const danglingTimeThreshold =
954
+ getDuration(timeThreshold ?? assets.danglingTimeThreshold)
957
955
  return AssetModel.transaction(trx, async trx => {
958
- // Determine the time threshold in JS instead of SQL, as there is no
959
- // easy cross-SQL way to do `now() - interval X hours`:
960
- const date = new Date()
961
- date.setMilliseconds(date.getMilliseconds() - timeThreshold)
956
+ // Calculate the date math in JS instead of SQL, as there is no easy
957
+ // cross-SQL way to do `now() - interval X hours`:
958
+ const now = new Date()
959
+ const cleanupDate = subtractDuration(now, cleanupTimeThreshold)
960
+ const danglingDate = subtractDuration(now, danglingTimeThreshold)
962
961
  const orphanedAssets = await AssetModel
963
962
  .query(trx)
964
963
  .where('count', 0)
965
- .andWhere('updatedAt', '<=', date)
966
- // Protect freshly created assets from being deleted again right away,
967
- // .e.g. when `config.assets.cleanupTimeThreshold = 0`
968
- .andWhere('updatedAt', '>', ref('createdAt'))
964
+ .andWhere(
965
+ query => query
966
+ .where('updatedAt', '<=', cleanupDate)
967
+ .orWhere(
968
+ // Protect freshly created assets from being deleted again right
969
+ // away, .e.g. when `config.assets.cleanupTimeThreshold = 0`
970
+ query => query
971
+ .where('updatedAt', '=', ref('createdAt'))
972
+ .andWhere('updatedAt', '<=', danglingDate)
973
+ )
974
+ )
969
975
  if (orphanedAssets.length > 0) {
970
- const orphanedKeys = await Promise.all(
971
- orphanedAssets.map(async asset => {
976
+ const orphanedKeys = await mapConcurrently(
977
+ orphanedAssets,
978
+ async asset => {
972
979
  try {
973
980
  await this.getStorage(asset.storage).removeFile(asset.file)
974
981
  } catch (error) {
@@ -976,7 +983,7 @@ export class Application extends Koa {
976
983
  asset.error = error
977
984
  }
978
985
  return asset.key
979
- })
986
+ }
980
987
  )
981
988
  await AssetModel
982
989
  .query(trx)
@@ -997,6 +1004,17 @@ function getOptions(options) {
997
1004
  return isObject(options) ? options : {}
998
1005
  }
999
1006
 
1007
+ const defaultAssetOptions = {
1008
+ // Only remove unused or dangling assets that haven't seen changes for
1009
+ // these given time frames. Set to `0` to clean up instantly.
1010
+ cleanupTimeThreshold: '24h',
1011
+ // Dangling assets are those that got uploaded but never actually persisted in
1012
+ // the model. This can happen when the admin uploads a file but doesn't store
1013
+ // the associated form. This cannot be set to 0 or else the the file would be
1014
+ // deleted immediately after upload.
1015
+ danglingTimeThreshold: '24h'
1016
+ }
1017
+
1000
1018
  const { err, req, res } = pino.stdSerializers
1001
1019
  const defaultLoggerOptions = {
1002
1020
  level: 'info',
@@ -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) {
@@ -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: {
@@ -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 {
@@ -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
  }
@@ -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)
@@ -58,16 +59,13 @@ export class S3Storage extends Storage {
58
59
  // 2. Try reading the mimetype from the first chunk.
59
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
- done(
68
- getFileTypeFromBuffer(data) || 'application/octet-stream'
69
- )
70
- })
66
+ stream.once('end', () => done(
67
+ getFileTypeFromBuffer(data) || 'application/octet-stream'
68
+ ))
71
69
  }
72
70
  }
73
71
  data = data ? Buffer.concat([data, chunk]) : chunk
@@ -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
  }
@@ -0,0 +1,15 @@
1
+ import { isNumber } from '@ditojs/utils'
2
+ import parseDuration from 'parse-duration'
3
+
4
+ export function getDuration(duration) {
5
+ return isNumber(duration) ? duration : parseDuration(duration)
6
+ }
7
+
8
+ export function addDuration(date, duration) {
9
+ date.setMilliseconds(date.getMilliseconds() + getDuration(duration))
10
+ return date
11
+ }
12
+
13
+ export function subtractDuration(date, duration) {
14
+ return addDuration(date, -getDuration(duration))
15
+ }
@@ -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
  }
@@ -1,3 +1,4 @@
1
+ export * from './date.js'
1
2
  export * from './decorator.js'
2
3
  export * from './deprecate.js'
3
4
  export * from './emitter.js'