@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 +9 -9
- package/src/app/Application.js +115 -97
- package/src/controllers/AdminController.js +1 -1
- package/src/controllers/CollectionController.js +1 -1
- package/src/controllers/Controller.js +14 -9
- package/src/controllers/ControllerAction.js +2 -2
- package/src/controllers/MemberAction.js +2 -2
- package/src/controllers/ModelController.js +1 -1
- package/src/graph/graph.js +5 -4
- package/src/middleware/handleConnectMiddleware.js +1 -1
- package/src/mixins/AssetMixin.js +1 -1
- package/src/models/Model.js +30 -15
- package/src/query/QueryBuilder.js +11 -5
- package/src/schema/properties.js +2 -2
- package/src/schema/properties.test.js +2 -2
- package/src/storage/DiskStorage.js +9 -6
- package/src/storage/S3Storage.js +4 -6
- package/src/storage/Storage.js +1 -0
- package/src/utils/date.js +15 -0
- package/src/utils/emitter.js +5 -4
- package/src/utils/index.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ditojs/server",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
26
|
-
"@ditojs/build": "^1.
|
|
27
|
-
"@ditojs/router": "^1.
|
|
28
|
-
"@ditojs/utils": "^1.
|
|
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.
|
|
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.
|
|
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.
|
|
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": "
|
|
93
|
+
"gitHead": "bebbf44ddf15a565e50ed8b2cbe0bf8a463c276d",
|
|
94
94
|
"scripts": {
|
|
95
95
|
"types": "tsc --noEmit ./src/index.d.ts"
|
|
96
96
|
},
|
package/src/app/Application.js
CHANGED
|
@@ -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,
|
|
25
|
-
hyphenate, clone, merge, parseDataPath, normalizeDataPath,
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
820
|
-
|
|
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
|
-
|
|
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(
|
|
841
|
-
|
|
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(
|
|
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
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
if (
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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 =
|
|
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
|
-
//
|
|
959
|
-
//
|
|
960
|
-
const
|
|
961
|
-
|
|
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(
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
|
971
|
-
orphanedAssets
|
|
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
|
|
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
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
`^${
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
47
|
+
// managed by their parent controller instead.
|
|
48
48
|
relation.configure()
|
|
49
49
|
relation.setup()
|
|
50
50
|
return relation
|
package/src/graph/graph.js
CHANGED
|
@@ -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
|
|
216
|
-
groups
|
|
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]
|
|
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)
|
package/src/mixins/AssetMixin.js
CHANGED
|
@@ -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
|
|
39
|
+
// Use for storages configured for files to be publicly accessible:
|
|
40
40
|
url: {
|
|
41
41
|
type: 'string'
|
|
42
42
|
},
|
package/src/models/Model.js
CHANGED
|
@@ -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 = (
|
|
621
|
-
|
|
622
|
-
|
|
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(
|
|
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
|
|
962
|
-
importedFiles
|
|
963
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
378
|
+
if (name && !relation) {
|
|
373
379
|
this.select(name)
|
|
374
380
|
} else {
|
|
375
381
|
this.withGraph(expression, options)
|
package/src/schema/properties.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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'
|
|
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
|
|
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
|
|
86
|
-
list1
|
|
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
|
|
90
|
-
list2
|
|
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
|
}
|
package/src/storage/S3Storage.js
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
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
|
package/src/storage/Storage.js
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/emitter.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { mapConcurrently } from '@ditojs/utils'
|
|
2
|
+
|
|
1
3
|
export function emitAsync(emitter, event, ...args) {
|
|
2
|
-
return
|
|
3
|
-
emitter.listeners(event)
|
|
4
|
-
|
|
5
|
-
)
|
|
4
|
+
return mapConcurrently(
|
|
5
|
+
emitter.listeners(event),
|
|
6
|
+
listener => listener.call(emitter, ...args)
|
|
6
7
|
)
|
|
7
8
|
}
|
package/src/utils/index.js
CHANGED