@ditojs/server 2.0.5 → 2.1.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.
Files changed (53) hide show
  1. package/package.json +11 -13
  2. package/src/app/Application.js +226 -179
  3. package/src/app/Validator.js +53 -43
  4. package/src/cli/console.js +6 -4
  5. package/src/cli/db/createMigration.js +59 -30
  6. package/src/cli/db/migrate.js +6 -4
  7. package/src/cli/db/reset.js +8 -5
  8. package/src/cli/db/rollback.js +6 -4
  9. package/src/cli/db/seed.js +2 -1
  10. package/src/cli/index.js +1 -1
  11. package/src/controllers/AdminController.js +98 -84
  12. package/src/controllers/CollectionController.js +37 -30
  13. package/src/controllers/Controller.js +83 -43
  14. package/src/controllers/ControllerAction.js +27 -15
  15. package/src/controllers/ModelController.js +4 -1
  16. package/src/controllers/RelationController.js +19 -21
  17. package/src/controllers/UsersController.js +3 -4
  18. package/src/decorators/parameters.js +3 -1
  19. package/src/decorators/scope.js +1 -1
  20. package/src/errors/ControllerError.js +2 -1
  21. package/src/errors/DatabaseError.js +20 -11
  22. package/src/graph/DitoGraphProcessor.js +48 -40
  23. package/src/graph/expression.js +6 -8
  24. package/src/graph/graph.js +20 -11
  25. package/src/lib/EventEmitter.js +12 -12
  26. package/src/middleware/handleConnectMiddleware.js +16 -10
  27. package/src/middleware/handleError.js +6 -5
  28. package/src/middleware/handleSession.js +33 -29
  29. package/src/middleware/handleUser.js +2 -2
  30. package/src/middleware/index.js +1 -0
  31. package/src/middleware/logRequests.js +3 -3
  32. package/src/middleware/setupRequestStorage.js +14 -0
  33. package/src/mixins/AssetMixin.js +62 -58
  34. package/src/mixins/SessionMixin.js +13 -10
  35. package/src/mixins/TimeStampedMixin.js +33 -29
  36. package/src/mixins/UserMixin.js +130 -116
  37. package/src/models/Model.js +245 -194
  38. package/src/models/definitions/filters.js +14 -13
  39. package/src/query/QueryBuilder.js +252 -195
  40. package/src/query/QueryFilters.js +3 -3
  41. package/src/query/QueryParameters.js +2 -2
  42. package/src/query/Registry.js +8 -10
  43. package/src/schema/keywords/_validate.js +10 -8
  44. package/src/schema/properties.test.js +247 -206
  45. package/src/schema/relations.js +42 -20
  46. package/src/schema/relations.test.js +36 -19
  47. package/src/services/Service.js +8 -14
  48. package/src/storage/S3Storage.js +5 -3
  49. package/src/storage/Storage.js +16 -14
  50. package/src/utils/function.js +7 -4
  51. package/src/utils/function.test.js +30 -6
  52. package/src/utils/object.test.js +5 -1
  53. package/types/index.d.ts +244 -257
@@ -76,9 +76,11 @@ export class DitoGraphProcessor {
76
76
  // since `relation.graphOptions` is across insert / upsert & co.,
77
77
  // but not all of them use all options (insert defines less).
78
78
  for (const key in this.options) {
79
- if (key in graphOptions &&
80
- graphOptions[key] !== this.options[key] &&
81
- !this.overrides[key]) {
79
+ if (
80
+ key in graphOptions &&
81
+ graphOptions[key] !== this.options[key] &&
82
+ !this.overrides[key]
83
+ ) {
82
84
  this.numOverrides++
83
85
  this.overrides[key] = []
84
86
  }
@@ -104,41 +106,45 @@ export class DitoGraphProcessor {
104
106
  processOverrides() {
105
107
  const expr = modelGraphToExpression(this.data)
106
108
 
107
- const processExpression =
108
- (expr, modelClass, relation, relationPath = '') => {
109
- if (relation) {
110
- const graphOptions = this.getGraphOptions(relation)
111
- // Loop through all override options, figure out their settings for
112
- // the current relation and build relation expression arrays for each
113
- // override reflecting their nested settings in arrays of expressions.
114
- for (const key in this.overrides) {
115
- const option = graphOptions[key] ?? this.options[key]
116
- if (option) {
117
- this.overrides[key].push(relationPath)
118
- }
119
- }
120
-
121
- // Also collect any many-to-many pivot table extra properties.
122
- const extra = relation.through?.extra
123
- if (extra?.length > 0) {
124
- this.extras[relationPath] = extra
109
+ const processExpression = (
110
+ expr,
111
+ modelClass,
112
+ relation,
113
+ relationPath = ''
114
+ ) => {
115
+ if (relation) {
116
+ const graphOptions = this.getGraphOptions(relation)
117
+ // Loop through all override options, figure out their settings for
118
+ // the current relation and build relation expression arrays for each
119
+ // override reflecting their nested settings in arrays of expressions.
120
+ for (const key in this.overrides) {
121
+ const option = graphOptions[key] ?? this.options[key]
122
+ if (option) {
123
+ this.overrides[key].push(relationPath)
125
124
  }
126
125
  }
127
126
 
128
- const { relations } = modelClass.definition
129
- const relationInstances = modelClass.getRelations()
130
- for (const key in expr) {
131
- const childExpr = expr[key]
132
- const { relatedModelClass } = relationInstances[key]
133
- processExpression(
134
- childExpr,
135
- relatedModelClass,
136
- relations[key],
137
- appendPath(relationPath, '.', key)
138
- )
127
+ // Also collect any many-to-many pivot table extra properties.
128
+ const extra = relation.through?.extra
129
+ if (extra?.length > 0) {
130
+ this.extras[relationPath] = extra
139
131
  }
140
132
  }
141
133
 
134
+ const { relations } = modelClass.definition
135
+ const relationInstances = modelClass.getRelations()
136
+ for (const key in expr) {
137
+ const childExpr = expr[key]
138
+ const { relatedModelClass } = relationInstances[key]
139
+ processExpression(
140
+ childExpr,
141
+ relatedModelClass,
142
+ relations[key],
143
+ appendPath(relationPath, '.', key)
144
+ )
145
+ }
146
+ }
147
+
142
148
  processExpression(expr, this.rootModelClass)
143
149
  }
144
150
 
@@ -147,9 +153,9 @@ export class DitoGraphProcessor {
147
153
  if (relationPath !== '') {
148
154
  const { relate } = this.overrides
149
155
  return relate
150
- // See if the relate overrides contain this particular relation-Path
151
- // and only remove and restore relation data if relate is to be used
152
- ? relate.includes(relationPath)
156
+ ? // See if the relate overrides contain this particular relation-Path
157
+ // and only remove and restore relation data if relate is to be used
158
+ relate.includes(relationPath)
153
159
  : this.options.relate
154
160
  }
155
161
  }
@@ -189,11 +195,13 @@ export class DitoGraphProcessor {
189
195
  return copy
190
196
  } else if (isArray(data)) {
191
197
  // Potentially a has-many relation, so keep processing relates:
192
- return data.map((entry, index) => this.processRelates(
193
- entry,
194
- relationPath,
195
- appendPath(dataPath, '/', index)
196
- ))
198
+ return data.map((entry, index) =>
199
+ this.processRelates(
200
+ entry,
201
+ relationPath,
202
+ appendPath(dataPath, '/', index)
203
+ )
204
+ )
197
205
  }
198
206
  }
199
207
  return data
@@ -26,14 +26,12 @@ export function collectExpressionPaths(expr) {
26
26
 
27
27
  export function expressionPathToString(path, start = 0) {
28
28
  return (start ? path.slice(start) : path)
29
- .map(
30
- ({ relation, alias, modify }) => {
31
- const expr = alias ? `${relation} as ${alias}` : relation
32
- return modify.length > 0
33
- ? `${expr}(${modify.join(', ')})`
34
- : expr
35
- }
36
- )
29
+ .map(({ relation, alias, modify }) => {
30
+ const expr = alias ? `${relation} as ${alias}` : relation
31
+ return modify.length > 0
32
+ ? `${expr}(${modify.join(', ')})`
33
+ : expr
34
+ })
37
35
  .join('.')
38
36
  }
39
37
 
@@ -127,13 +127,21 @@ export async function populateGraph(rootModelClass, graph, expr, trx) {
127
127
  // contain path entries with relation names and modify settings.
128
128
 
129
129
  const grouped = {}
130
- const addToGroup =
131
- (item, modelClass, isReference, modify, relation, expr) => {
132
- const id = item.$id()
133
- if (id != null) {
134
- // Group models by model-name + modify + expr, for faster loading:
135
- const key = `${modelClass.name}_${modify}_${expr || ''}`
136
- const group = grouped[key] || (grouped[key] = {
130
+ const addToGroup = (
131
+ item,
132
+ modelClass,
133
+ isReference,
134
+ modify,
135
+ relation,
136
+ expr
137
+ ) => {
138
+ const id = item.$id()
139
+ if (id != null) {
140
+ // Group models by model-name + modify + expr, for faster loading:
141
+ const key = `${modelClass.name}_${modify}_${expr || ''}`
142
+ const group = (
143
+ grouped[key] ||
144
+ (grouped[key] = {
137
145
  modelClass,
138
146
  modify,
139
147
  relation,
@@ -142,11 +150,12 @@ export async function populateGraph(rootModelClass, graph, expr, trx) {
142
150
  ids: [],
143
151
  modelsById: {}
144
152
  })
145
- group.targets.push({ item, isReference })
146
- // Collect ids to be loaded for the targets.
147
- group.ids.push(id)
148
- }
153
+ )
154
+ group.targets.push({ item, isReference })
155
+ // Collect ids to be loaded for the targets.
156
+ group.ids.push(id)
149
157
  }
158
+ }
150
159
 
151
160
  for (const path of collectExpressionPaths(expr)) {
152
161
  let modelClass = rootModelClass
@@ -25,6 +25,18 @@ export class EventEmitter extends EventEmitter2 {
25
25
  return this.emitAsync(event, ...args)
26
26
  }
27
27
 
28
+ on(event, callback) {
29
+ return this._handle('on', event, callback)
30
+ }
31
+
32
+ off(event, callback) {
33
+ return this._handle('off', event, callback)
34
+ }
35
+
36
+ once(event, callback) {
37
+ return this._handle('once', event, callback)
38
+ }
39
+
28
40
  _handle(method, event, callback) {
29
41
  if (isString(event)) {
30
42
  super[method](event, callback)
@@ -40,18 +52,6 @@ export class EventEmitter extends EventEmitter2 {
40
52
  return this
41
53
  }
42
54
 
43
- on(event, callback) {
44
- return this._handle('on', event, callback)
45
- }
46
-
47
- off(event, callback) {
48
- return this._handle('off', event, callback)
49
- }
50
-
51
- once(event, callback) {
52
- return this._handle('once', event, callback)
53
- }
54
-
55
55
  static mixin(target) {
56
56
  Object.defineProperties(target, properties)
57
57
  }
@@ -39,16 +39,22 @@ export function handleConnectMiddleware(middleware, {
39
39
  }
40
40
  if (isArray(headers)) {
41
41
  // Convert raw headers array to object.
42
- headers = Object.fromEntries(headers.reduce(
43
- // Translate raw array to [field, value] tuples.
44
- (entries, value, index) => {
45
- if (index & 1) { // Odd: value
46
- entries[entries.length - 1].push(value)
47
- } else { // Even: field
48
- entries.push([value])
49
- }
50
- return entries
51
- }, []))
42
+ headers = Object.fromEntries(
43
+ headers.reduce(
44
+ // Translate raw array to [field, value] tuples.
45
+ (entries, value, index) => {
46
+ if (index & 1) {
47
+ // Odd: value
48
+ entries[entries.length - 1].push(value)
49
+ } else {
50
+ // Even: field
51
+ entries.push([value])
52
+ }
53
+ return entries
54
+ },
55
+ []
56
+ )
57
+ )
52
58
  }
53
59
  if (isObject(headers)) {
54
60
  ctx.set(headers)
@@ -11,11 +11,12 @@ export function handleError() {
11
11
  // error. But don't do so if the request actually went to js file.
12
12
  if (ctx.accepts('json') && !ctx.request.path.endsWith('.js')) {
13
13
  // Format error as JSON
14
- ctx.body = err instanceof ResponseError
15
- ? err.toJSON()
16
- : {
17
- message: err.message || 'An error has occurred.'
18
- }
14
+ ctx.body =
15
+ err instanceof ResponseError
16
+ ? err.toJSON()
17
+ : {
18
+ message: err.message || 'An error has occurred.'
19
+ }
19
20
  } else {
20
21
  // TODO: Consider handling html and xml responses also, see:
21
22
  // https://github.com/strongloop/strong-error-handler/blob/master/lib/send-html.js
@@ -12,7 +12,7 @@ export function handleSession(app, {
12
12
  // uses it to persist and retrieve the session, and automatically
13
13
  // binds all db operations to `ctx.transaction`, if it is set.
14
14
  // eslint-disable-next-line new-cap
15
- options.ContextStore = SessionStore(modelClass)
15
+ options.ContextStore = createSessionStore(modelClass)
16
16
  }
17
17
  options.autoCommit = false
18
18
  return compose([
@@ -20,7 +20,10 @@ export function handleSession(app, {
20
20
  async (ctx, next) => {
21
21
  // Get hold of `session` now, since it may not be available from the
22
22
  // `ctx` if the session is destroyed.
23
- const { session, route: { transacted } } = ctx
23
+ const {
24
+ session,
25
+ route: { transacted }
26
+ } = ctx
24
27
  try {
25
28
  await next()
26
29
  if (autoCommit && transacted) {
@@ -39,36 +42,37 @@ export function handleSession(app, {
39
42
  ])
40
43
  }
41
44
 
42
- const SessionStore = modelClass => class SessionStore {
43
- constructor(ctx) {
44
- this.ctx = ctx
45
- this.modelClass = isString(modelClass)
46
- ? ctx.app.models[modelClass]
47
- : modelClass
48
- if (!this.modelClass) {
49
- throw new Error(`Unable to find model class: '${modelClass}'`)
45
+ const createSessionStore = modelClass =>
46
+ class SessionStore {
47
+ constructor(ctx) {
48
+ this.ctx = ctx
49
+ this.modelClass = isString(modelClass)
50
+ ? ctx.app.models[modelClass]
51
+ : modelClass
52
+ if (!this.modelClass) {
53
+ throw new Error(`Unable to find model class: '${modelClass}'`)
54
+ }
50
55
  }
51
- }
52
56
 
53
- query() {
54
- return this.modelClass.query(this.ctx.transaction)
55
- }
57
+ query() {
58
+ return this.modelClass.query(this.ctx.transaction)
59
+ }
56
60
 
57
- async get(id) {
58
- const session = await this.query().findById(id)
59
- return session?.value || {}
60
- }
61
+ async get(id) {
62
+ const session = await this.query().findById(id)
63
+ return session?.value || {}
64
+ }
61
65
 
62
- async set(id, value) {
63
- await this.query()
64
- .findById(id)
65
- .upsert({
66
- ...this.modelClass.getReference(id),
67
- value
68
- })
69
- }
66
+ async set(id, value) {
67
+ await this.query()
68
+ .findById(id)
69
+ .upsert({
70
+ ...this.modelClass.getReference(id),
71
+ value
72
+ })
73
+ }
70
74
 
71
- async destroy(key) {
72
- return this.query().deleteById(key)
75
+ async destroy(key) {
76
+ await this.query().deleteById(key)
77
+ }
73
78
  }
74
- }
@@ -7,13 +7,13 @@ export function handleUser() {
7
7
  // on the user model:
8
8
  const { login, logout } = ctx
9
9
 
10
- ctx.login = ctx.logIn = async function(user, options = {}) {
10
+ ctx.login = ctx.logIn = async function (user, options = {}) {
11
11
  await user.$emit('before:login', options)
12
12
  await login.call(this, user, options)
13
13
  await user.$emit('after:login', options)
14
14
  }
15
15
 
16
- ctx.logout = ctx.logOut = async function(options = {}) {
16
+ ctx.logout = ctx.logOut = async function (options = {}) {
17
17
  const { user } = ctx.state
18
18
  await user?.$emit('before:logout', options)
19
19
  await logout.call(this, options)
@@ -7,3 +7,4 @@ export * from './handleRoute.js'
7
7
  export * from './handleSession.js'
8
8
  export * from './handleUser.js'
9
9
  export * from './logRequests.js'
10
+ export * from './setupRequestStorage.js'
@@ -5,9 +5,9 @@ import bytes from 'bytes'
5
5
  import pico from 'picocolors'
6
6
  import Counter from 'passthrough-counter'
7
7
 
8
- export function logRequests({ ignoreUrls } = {}) {
8
+ export function logRequests({ ignoreUrlPattern } = {}) {
9
9
  return async (ctx, next) => {
10
- if (ignoreUrls && ctx.req.url.match(ignoreUrls)) {
10
+ if (ignoreUrlPattern && ctx.req.url.match(ignoreUrlPattern)) {
11
11
  return next()
12
12
  }
13
13
  // request
@@ -72,7 +72,7 @@ function logResponse({ ctx, start, length, err }) {
72
72
  const logger = ctx.logger?.child({ name: 'http' })
73
73
  const level = err ? 'warn' : 'info'
74
74
  if (logger?.isLevelEnabled(level)) {
75
- // Get the status code of the response
75
+ // Get the status code of the response
76
76
  const status = err
77
77
  ? err.status || 500
78
78
  : ctx.status || 404
@@ -0,0 +1,14 @@
1
+ export function setupRequestStorage(requestStorage) {
2
+ return (ctx, next) =>
3
+ requestStorage.run(
4
+ {
5
+ get transaction() {
6
+ return ctx.transaction
7
+ },
8
+ get logger() {
9
+ return ctx.logger
10
+ }
11
+ },
12
+ next
13
+ )
14
+ }
@@ -2,73 +2,77 @@ import { mixin } from '@ditojs/utils'
2
2
  import { TimeStampedMixin } from './TimeStampedMixin.js'
3
3
 
4
4
  // Asset models are always to be time-stamped:
5
- export const AssetMixin = mixin(Model => class extends TimeStampedMixin(Model) {
6
- static properties = {
7
- key: {
8
- type: 'string',
9
- required: true,
10
- unique: true,
11
- index: true
12
- },
13
-
14
- file: {
15
- type: 'object',
16
- // TODO: Support this on 'object':
17
- // required: true
18
- properties: {
19
- // The unique key within the storage (uuid/v4 + file extension)
5
+ export const AssetMixin = mixin(
6
+ Model =>
7
+ class extends TimeStampedMixin(Model) {
8
+ static properties = {
20
9
  key: {
21
10
  type: 'string',
22
- required: true
11
+ required: true,
12
+ unique: true,
13
+ index: true
23
14
  },
24
- // The original filename, and display name when file is shown
25
- name: {
26
- type: 'string',
27
- required: true
15
+
16
+ file: {
17
+ type: 'object',
18
+ // TODO: Support this on 'object':
19
+ // required: true
20
+ properties: {
21
+ // The unique key within the storage (uuid/v4 + file extension)
22
+ key: {
23
+ type: 'string',
24
+ required: true
25
+ },
26
+ // The original filename, and display name when file is shown
27
+ name: {
28
+ type: 'string',
29
+ required: true
30
+ },
31
+ // The file's mime-type
32
+ type: {
33
+ type: 'string',
34
+ required: true
35
+ },
36
+ // The amount of bytes consumed by the file
37
+ size: {
38
+ type: 'integer',
39
+ required: true
40
+ },
41
+ // Use for storages configured for files to be publicly accessible:
42
+ url: {
43
+ type: 'string'
44
+ },
45
+ // These are only used when the storage defines
46
+ // `config.readDimensions`:
47
+ width: {
48
+ type: 'integer'
49
+ },
50
+ height: {
51
+ type: 'integer'
52
+ }
53
+ }
28
54
  },
29
- // The file's mime-type
30
- type: {
55
+
56
+ storage: {
31
57
  type: 'string',
32
58
  required: true
33
59
  },
34
- // The amount of bytes consumed by the file
35
- size: {
60
+
61
+ count: {
36
62
  type: 'integer',
37
- required: true
38
- },
39
- // Use for storages configured for files to be publicly accessible:
40
- url: {
41
- type: 'string'
42
- },
43
- // These are only used when the storage defines `config.readDimensions`:
44
- width: {
45
- type: 'integer'
46
- },
47
- height: {
48
- type: 'integer'
63
+ unsigned: true,
64
+ default: 0
49
65
  }
50
66
  }
51
- },
52
-
53
- storage: {
54
- type: 'string',
55
- required: true
56
- },
57
67
 
58
- count: {
59
- type: 'integer',
60
- unsigned: true,
61
- default: 0
62
- }
63
- }
64
-
65
- // @override
66
- $parseJson(json) {
67
- const { file, storage } = json
68
- // Convert `AssetMixin#file` to an `AssetFile` instance:
69
- if (file && storage) {
70
- this.constructor.app.getStorage(storage)?.convertAssetFile(file)
68
+ // @override
69
+ $parseJson(json) {
70
+ const { file, storage } = json
71
+ // Convert `AssetMixin#file` to an `AssetFile` instance:
72
+ if (file && storage) {
73
+ this.constructor.app.getStorage(storage)?.convertAssetFile(file)
74
+ }
75
+ return json
76
+ }
71
77
  }
72
- return json
73
- }
74
- })
78
+ )
@@ -1,14 +1,17 @@
1
1
  import { mixin } from '@ditojs/utils'
2
2
 
3
- export const SessionMixin = mixin(Model => class extends Model {
4
- static properties = {
5
- id: {
6
- type: 'string',
7
- primary: true
8
- },
3
+ export const SessionMixin = mixin(
4
+ Model =>
5
+ class extends Model {
6
+ static properties = {
7
+ id: {
8
+ type: 'string',
9
+ primary: true
10
+ },
9
11
 
10
- value: {
11
- type: 'object'
12
+ value: {
13
+ type: 'object'
14
+ }
15
+ }
12
16
  }
13
- }
14
- })
17
+ )
@@ -1,37 +1,41 @@
1
1
  import { mixin } from '@ditojs/utils'
2
2
 
3
- export const TimeStampedMixin = mixin(Model => class extends Model {
4
- static properties = {
5
- createdAt: {
6
- type: 'timestamp',
7
- default: 'now()'
8
- },
3
+ export const TimeStampedMixin = mixin(
4
+ Model =>
5
+ class extends Model {
6
+ static properties = {
7
+ createdAt: {
8
+ type: 'timestamp',
9
+ default: 'now()'
10
+ },
9
11
 
10
- updatedAt: {
11
- type: 'timestamp',
12
- default: 'now()'
13
- }
14
- }
15
-
16
- static scopes = {
17
- timeStamped: query => query
18
- .select('createdAt', 'updatedAt')
19
- }
12
+ updatedAt: {
13
+ type: 'timestamp',
14
+ default: 'now()'
15
+ }
16
+ }
20
17
 
21
- static hooks = {
22
- 'before:insert'({ inputItems }) {
23
- const now = new Date()
24
- for (const item of inputItems) {
25
- item.createdAt = now
26
- item.updatedAt = now
18
+ static scopes = {
19
+ timeStamped: query =>
20
+ query
21
+ .select('createdAt', 'updatedAt')
27
22
  }
28
- },
29
23
 
30
- 'before:update'({ inputItems }) {
31
- const now = new Date()
32
- for (const item of inputItems) {
33
- item.updatedAt = now
24
+ static hooks = {
25
+ 'before:insert'({ inputItems }) {
26
+ const now = new Date()
27
+ for (const item of inputItems) {
28
+ item.createdAt = now
29
+ item.updatedAt = now
30
+ }
31
+ },
32
+
33
+ 'before:update'({ inputItems }) {
34
+ const now = new Date()
35
+ for (const item of inputItems) {
36
+ item.updatedAt = now
37
+ }
38
+ }
34
39
  }
35
40
  }
36
- }
37
- })
41
+ )