@ditojs/server 1.25.0 → 1.26.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.25.0",
3
+ "version": "1.26.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.25.0",
25
+ "@ditojs/admin": "^1.26.0",
26
26
  "@ditojs/build": "^1.25.0",
27
- "@ditojs/router": "^1.25.0",
28
- "@ditojs/utils": "^1.25.0",
27
+ "@ditojs/router": "^1.26.0",
28
+ "@ditojs/utils": "^1.26.0",
29
29
  "@koa/cors": "^4.0.0",
30
30
  "@koa/multer": "^3.0.2",
31
31
  "@originjs/vite-plugin-commonjs": "^1.0.3",
@@ -90,7 +90,7 @@
90
90
  "typescript": "^4.9.5"
91
91
  },
92
92
  "types": "types",
93
- "gitHead": "29e7f8d8c613c48a856d90d1ee876e85c8d84c08",
93
+ "gitHead": "198c9832c863003c8c8fcd147bf61a5f99a39c91",
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,9 +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,
26
- mapConcurrently
23
+ isArray, isObject, asArray, isPlainObject, isModule,
24
+ hyphenate, clone, merge, parseDataPath, normalizeDataPath,
25
+ toPromiseCallback, mapConcurrently
27
26
  } from '@ditojs/utils'
28
27
  import SessionStore from './SessionStore.js'
29
28
  import { Validator } from './Validator.js'
@@ -32,7 +31,7 @@ import { Controller, AdminController } from '../controllers/index.js'
32
31
  import { Service } from '../services/index.js'
33
32
  import { Storage } from '../storage/index.js'
34
33
  import { convertSchema } from '../schema/index.js'
35
- import { deprecate } from '../utils/index.js'
34
+ import { getDuration, subtractDuration, deprecate } from '../utils/index.js'
36
35
  import {
37
36
  ResponseError,
38
37
  ValidationError,
@@ -66,6 +65,7 @@ export class Application extends Koa {
66
65
  // Pluck keys out of `config.app` to keep them secret
67
66
  app: { keys, ...app } = {},
68
67
  log,
68
+ assets,
69
69
  logger,
70
70
  ...rest
71
71
  } = config
@@ -74,6 +74,7 @@ export class Application extends Koa {
74
74
  log: log === false || log?.silent || process.env.DITO_SILENT
75
75
  ? {}
76
76
  : getOptions(log),
77
+ assets: merge(defaultAssetOptions, getOptions(assets)),
77
78
  logger: merge(defaultLoggerOptions, getOptions(logger)),
78
79
  ...rest
79
80
  }
@@ -805,16 +806,6 @@ export class Application extends Koa {
805
806
  removedFiles,
806
807
  trx = null
807
808
  ) {
808
- const {
809
- assets: {
810
- cleanupTimeThreshold = 0
811
- } = {}
812
- } = this.config
813
- // Only remove unused assets that haven't seen changes for given time frame.
814
- const timeThreshold = isString(cleanupTimeThreshold)
815
- ? parseDuration(cleanupTimeThreshold)
816
- : cleanupTimeThreshold
817
-
818
809
  let importedFiles = []
819
810
  const AssetModel = this.getModel('Asset')
820
811
  if (AssetModel) {
@@ -837,18 +828,20 @@ export class Application extends Koa {
837
828
  changeCount(addedFiles, 1),
838
829
  changeCount(removedFiles, -1)
839
830
  ])
840
- if (timeThreshold > 0) {
831
+ const cleanupTimeThreshold =
832
+ getDuration(this.config.assets.cleanupTimeThreshold)
833
+ if (cleanupTimeThreshold > 0) {
841
834
  setTimeout(
842
835
  // Don't pass `trx` here, as we want this delayed execution to
843
836
  // create its own transaction.
844
- () => this.releaseUnusedAssets(timeThreshold),
845
- timeThreshold
837
+ () => this.releaseUnusedAssets(),
838
+ cleanupTimeThreshold
846
839
  )
847
840
  }
848
841
  }
849
842
  // Also execute releaseUnusedAssets() immediately in the same
850
843
  // transaction, to potentially clean up other pending assets.
851
- await this.releaseUnusedAssets(timeThreshold, trx)
844
+ await this.releaseUnusedAssets(null, trx)
852
845
  return importedFiles
853
846
  }
854
847
  }
@@ -867,31 +860,28 @@ export class Application extends Koa {
867
860
  const { url } = file
868
861
  if (!storage.isImportSourceAllowed(url)) {
869
862
  throw new AssetError(
870
- `Unable to import asset from foreign source: '${
871
- file.name
872
- }' ('${
873
- url
874
- }'): The source needs to be explicitly allowed.`
863
+ `Unable to import asset from foreign source: '${
864
+ file.name
865
+ }' ('${
866
+ url
867
+ }'): The source needs to be explicitly allowed.`
875
868
  )
876
869
  }
877
- console.info(
878
- `${
879
- pico.red('INFO:')
880
- } Asset ${
881
- pico.green(`'${file.name}'`)
882
- } is from a foreign source, fetching from ${
883
- pico.green(`'${url}'`)
884
- } and adding to storage ${
885
- pico.green(`'${storage.name}'`)
886
- }...`
870
+ this.logger.info(
871
+ `Asset ${
872
+ pico.green(`'${file.name}'`)
873
+ } is from a foreign source, fetching from ${
874
+ pico.green(`'${url}'`)
875
+ } and adding to storage ${
876
+ pico.green(`'${storage.name}'`)
877
+ }...`
887
878
  )
888
879
  if (url.startsWith('file://')) {
889
880
  const filepath = path.resolve(url.substring(7))
890
881
  data = await fs.readFile(filepath)
891
882
  } else {
892
883
  const response = await fetch(url)
893
- const buffer = await response.arrayBuffer()
894
- data = new DataView(buffer)
884
+ data = await response.arrayBuffer()
895
885
  }
896
886
  }
897
887
  const importedFile = await storage.addFile(file, data)
@@ -903,11 +893,11 @@ export class Application extends Koa {
903
893
  importedFiles.push(importedFile)
904
894
  } else {
905
895
  throw new AssetError(
906
- `Unable to import asset from foreign source: '${
907
- file.name
908
- }' ('${
909
- file.key
910
- }')`
896
+ `Unable to import asset from foreign source: '${
897
+ file.name
898
+ }' ('${
899
+ file.key
900
+ }')`
911
901
  )
912
902
  }
913
903
  } else {
@@ -951,21 +941,34 @@ export class Application extends Koa {
951
941
  return modifiedFiles
952
942
  }
953
943
 
954
- async releaseUnusedAssets(timeThreshold = 0, trx = null) {
944
+ async releaseUnusedAssets(timeThreshold = null, trx = null) {
955
945
  const AssetModel = this.getModel('Asset')
956
946
  if (AssetModel) {
947
+ const { assets } = this.config
948
+ const cleanupTimeThreshold =
949
+ getDuration(timeThreshold ?? assets.cleanupTimeThreshold)
950
+ const danglingTimeThreshold =
951
+ getDuration(timeThreshold ?? assets.danglingTimeThreshold)
957
952
  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)
953
+ // Calculate the date math in JS instead of SQL, as there is no easy
954
+ // cross-SQL way to do `now() - interval X hours`:
955
+ const now = new Date()
956
+ const cleanupDate = subtractDuration(now, cleanupTimeThreshold)
957
+ const danglingDate = subtractDuration(now, danglingTimeThreshold)
962
958
  const orphanedAssets = await AssetModel
963
959
  .query(trx)
964
960
  .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'))
961
+ .andWhere(
962
+ query => query
963
+ .where('updatedAt', '<=', cleanupDate)
964
+ .orWhere(
965
+ // Protect freshly created assets from being deleted again right
966
+ // away, .e.g. when `config.assets.cleanupTimeThreshold = 0`
967
+ query => query
968
+ .where('updatedAt', '=', ref('createdAt'))
969
+ .andWhere('updatedAt', '<=', danglingDate)
970
+ )
971
+ )
969
972
  if (orphanedAssets.length > 0) {
970
973
  const orphanedKeys = await mapConcurrently(
971
974
  orphanedAssets,
@@ -998,6 +1001,17 @@ function getOptions(options) {
998
1001
  return isObject(options) ? options : {}
999
1002
  }
1000
1003
 
1004
+ const defaultAssetOptions = {
1005
+ // Only remove unused or dangling assets that haven't seen changes for
1006
+ // these given time frames. Set to `0` to clean up instantly.
1007
+ cleanupTimeThreshold: '24h',
1008
+ // Dangling assets are those that got uploaded but never actually persisted in
1009
+ // the model. This can happen when the admin uploads a file but doesn't store
1010
+ // the associated form. This cannot be set to 0 or else the the file would be
1011
+ // deleted immediately after upload.
1012
+ danglingTimeThreshold: '24h'
1013
+ }
1014
+
1001
1015
  const { err, req, res } = pino.stdSerializers
1002
1016
  const defaultLoggerOptions = {
1003
1017
  level: 'info',
@@ -24,6 +24,7 @@ export class CollectionController extends Controller {
24
24
 
25
25
  // @override
26
26
  setup() {
27
+ this.logController()
27
28
  this.collection = this.setupActions('collection')
28
29
  this.member = this.isOneToOne ? {} : this.setupActions('member')
29
30
  this.assets = this.setupAssets()
@@ -26,7 +26,7 @@ export class Controller {
26
26
  constructor(app, namespace) {
27
27
  this.app = app
28
28
  this.namespace = namespace
29
- this.logging = this.app.config.log.routes
29
+ this.logRoutes = this.app.config.log.routes
30
30
  this.level = 0
31
31
  }
32
32
 
@@ -60,21 +60,12 @@ export class Controller {
60
60
  // mapped parameters or wildcards. Consider `path` / `route` instead?
61
61
  const url = path ? `/${path}` : ''
62
62
  this.url = namespace ? `/${namespace}${url}` : url
63
- this.log(
64
- `${
65
- namespace ? pico.green(`/${namespace}/`) : ''
66
- }${
67
- pico.cyan(path)
68
- }${
69
- pico.white(':')
70
- }`,
71
- this.level
72
- )
73
63
  }
74
64
  }
75
65
 
76
66
  // @overridable
77
67
  setup() {
68
+ this.logController()
78
69
  this.actions ||= this.reflectActionsObject()
79
70
  // Now that the instance fields are reflected in the `controller` object
80
71
  // we can use the normal inheritance mechanism through `setupActions()`:
@@ -94,6 +85,40 @@ export class Controller {
94
85
  // middleware. For normal routes, use `this.app.addRoute()` instead.
95
86
  }
96
87
 
88
+ // @overridable
89
+ logController() {
90
+ const { path, namespace } = this
91
+ this.logRoute(
92
+ `${
93
+ namespace ? pico.green(`/${namespace}/`) : ''
94
+ }${
95
+ pico.cyan(path)
96
+ }${
97
+ pico.white(':')
98
+ }`,
99
+ this.level
100
+ )
101
+ }
102
+
103
+ logRoute(str, indent = 0) {
104
+ if (this.logRoutes) {
105
+ console.info(`${' '.repeat(indent)}${str}`)
106
+ }
107
+ }
108
+
109
+ // Only use this method to get a logger instance that is bound to the context,
110
+ // otherwise use the cached getter.
111
+ getLogger(ctx) {
112
+ const logger = ctx?.logger ?? this.app.logger
113
+ return logger.child({ name: this.name })
114
+ }
115
+
116
+ get logger() {
117
+ const value = this.getLogger()
118
+ Object.defineProperty(this, 'logger', { value })
119
+ return value
120
+ }
121
+
97
122
  reflectActionsObject() {
98
123
  // On base controllers, the actions can be defined directly in the class
99
124
  // instead of inside an actions object, as is done with model and relation
@@ -122,7 +147,7 @@ export class Controller {
122
147
  }
123
148
 
124
149
  setupRoute(method, url, transacted, authorize, action, middlewares) {
125
- this.log(
150
+ this.logRoute(
126
151
  `${
127
152
  pico.magenta(method.toUpperCase())
128
153
  } ${
@@ -382,7 +407,6 @@ export class Controller {
382
407
  // so add its own keys to the already allowed inherited keys so far.
383
408
  Object.assign(allowMap, getFilteredMap(getOwnKeys(current)))
384
409
  }
385
- // console.log('allow', Object.keys(allowMap))
386
410
  }
387
411
 
388
412
  const handleAuthorize = authorize => {
@@ -538,12 +562,6 @@ export class Controller {
538
562
  throw new AuthorizationError()
539
563
  }
540
564
  }
541
-
542
- log(str, indent = 0) {
543
- if (this.logging) {
544
- console.info(`${' '.repeat(indent)}${str}`)
545
- }
546
- }
547
565
  }
548
566
 
549
567
  EventEmitter.mixin(Controller.prototype)
@@ -48,6 +48,11 @@ export class RelationController extends CollectionController {
48
48
  super.configure()
49
49
  }
50
50
 
51
+ // @override
52
+ logController() {
53
+ // The parent controller logs itself already, that's enough.
54
+ }
55
+
51
56
  // @override
52
57
  inheritValues(type) {
53
58
  // Since RelationController are mapped to nested `relations` objects in
@@ -32,7 +32,7 @@ export class Service {
32
32
  }
33
33
 
34
34
  // Only use this method to get a logger instance that is bound to the context,
35
- // otherwise use the getter.
35
+ // otherwise use the cached getter.
36
36
  getLogger(ctx) {
37
37
  const logger = ctx?.logger ?? this.app.logger
38
38
  return logger.child({ name: this.#loggerName })
@@ -44,7 +44,6 @@ export class DiskStorage extends Storage {
44
44
  const dir = path.dirname(filePath)
45
45
  await fs.mkdir(dir, { recursive: true })
46
46
  await fs.writeFile(filePath, data)
47
- return file
48
47
  }
49
48
 
50
49
  // @override
@@ -111,12 +111,10 @@ export class S3Storage extends Storage {
111
111
  ContentType: file.type,
112
112
  Body: data
113
113
  })
114
- // "Convert" `file` to something looking more like a S3 `storageFile`.
115
- // For now, only the `location` property is of interest:
116
- return {
117
- ...file,
118
- location: result.Location
119
- }
114
+ // In `Storage.addFile()` this will get overridden with the result of
115
+ // `_getUrl()` if it exists, but is used as a fallback otherwise,
116
+ // see `_getFileUrl()`.
117
+ file.url = result.Location
120
118
  }
121
119
 
122
120
  // @override
@@ -18,7 +18,9 @@ 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
+ // Use a default concurrency of 8 for storage IO, e.g. the importing of
22
+ // foreign assets.
23
+ this.concurrency = config.concurrency ?? 8
22
24
  // The actual multer storage object.
23
25
  this.storage = null
24
26
  }
@@ -96,10 +98,10 @@ export class Storage {
96
98
  }
97
99
 
98
100
  async addFile(file, data) {
99
- const storageFile = await this._addFile(file, data)
101
+ await this._addFile(file, data)
100
102
  file.size = Buffer.byteLength(data)
101
- file.url = this._getFileUrl(storageFile)
102
- // TODO: Support `config.readImageSize`, but this can only be done onces
103
+ file.url = this._getFileUrl(file)
104
+ // TODO: Support `config.readImageSize`, but this can only be done once
103
105
  // there are separate storage instances per model assets config!
104
106
  return this.convertAssetFile(file)
105
107
  }
@@ -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,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'