@directus/api 31.0.0 → 32.0.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 (135) hide show
  1. package/dist/app.js +2 -0
  2. package/dist/auth/auth.d.ts +2 -1
  3. package/dist/auth/auth.js +7 -2
  4. package/dist/auth/drivers/ldap.d.ts +0 -2
  5. package/dist/auth/drivers/ldap.js +9 -7
  6. package/dist/auth/drivers/oauth2.d.ts +0 -2
  7. package/dist/auth/drivers/oauth2.js +11 -8
  8. package/dist/auth/drivers/openid.d.ts +0 -2
  9. package/dist/auth/drivers/openid.js +11 -8
  10. package/dist/auth/drivers/saml.d.ts +0 -2
  11. package/dist/auth/drivers/saml.js +5 -5
  12. package/dist/auth.js +1 -2
  13. package/dist/cli/commands/bootstrap/index.js +12 -33
  14. package/dist/cli/commands/init/index.js +1 -1
  15. package/dist/cli/commands/schema/apply.d.ts +4 -0
  16. package/dist/cli/commands/schema/apply.js +26 -3
  17. package/dist/controllers/collections.js +7 -2
  18. package/dist/controllers/fields.js +31 -8
  19. package/dist/controllers/server.js +26 -1
  20. package/dist/controllers/settings.js +9 -2
  21. package/dist/controllers/users.js +2 -2
  22. package/dist/database/helpers/fn/types.js +3 -3
  23. package/dist/database/helpers/schema/dialects/cockroachdb.d.ts +2 -1
  24. package/dist/database/helpers/schema/dialects/cockroachdb.js +13 -0
  25. package/dist/database/helpers/schema/dialects/mssql.d.ts +2 -1
  26. package/dist/database/helpers/schema/dialects/mssql.js +23 -0
  27. package/dist/database/helpers/schema/dialects/mysql.d.ts +2 -1
  28. package/dist/database/helpers/schema/dialects/mysql.js +25 -0
  29. package/dist/database/helpers/schema/dialects/oracle.d.ts +2 -1
  30. package/dist/database/helpers/schema/dialects/oracle.js +13 -0
  31. package/dist/database/helpers/schema/dialects/postgres.d.ts +2 -1
  32. package/dist/database/helpers/schema/dialects/postgres.js +13 -0
  33. package/dist/database/helpers/schema/types.d.ts +5 -0
  34. package/dist/database/helpers/schema/types.js +6 -0
  35. package/dist/database/migrations/20251012A-add-field-searchable.d.ts +3 -0
  36. package/dist/database/migrations/20251012A-add-field-searchable.js +10 -0
  37. package/dist/database/migrations/20251014A-add-project-owner.d.ts +3 -0
  38. package/dist/database/migrations/20251014A-add-project-owner.js +37 -0
  39. package/dist/database/migrations/20251028A-add-retention-indexes.d.ts +3 -0
  40. package/dist/database/migrations/20251028A-add-retention-indexes.js +42 -0
  41. package/dist/database/run-ast/lib/apply-query/add-join.js +2 -2
  42. package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.d.ts +2 -2
  43. package/dist/database/run-ast/lib/apply-query/index.d.ts +0 -1
  44. package/dist/database/run-ast/lib/apply-query/index.js +4 -6
  45. package/dist/database/run-ast/lib/apply-query/search.js +2 -0
  46. package/dist/database/run-ast/lib/get-db-query.js +7 -6
  47. package/dist/database/run-ast/utils/generate-alias.d.ts +6 -0
  48. package/dist/database/run-ast/utils/generate-alias.js +57 -0
  49. package/dist/flows.js +1 -0
  50. package/dist/mcp/schema.d.ts +14 -14
  51. package/dist/mcp/schema.js +6 -6
  52. package/dist/mcp/server.d.ts +9 -3
  53. package/dist/mcp/server.js +1 -1
  54. package/dist/mcp/tools/collections.d.ts +1 -1
  55. package/dist/mcp/tools/fields.d.ts +1 -1
  56. package/dist/mcp/tools/files.d.ts +25 -25
  57. package/dist/mcp/tools/flows.d.ts +36 -36
  58. package/dist/mcp/tools/folders.d.ts +18 -18
  59. package/dist/mcp/tools/items.d.ts +18 -18
  60. package/dist/mcp/tools/operations.d.ts +19 -19
  61. package/dist/mcp/tools/prompts/items.md +1 -1
  62. package/dist/metrics/lib/create-metrics.js +16 -25
  63. package/dist/middleware/collection-exists.js +2 -2
  64. package/dist/operations/mail/index.js +3 -1
  65. package/dist/operations/mail/rate-limiter.d.ts +1 -0
  66. package/dist/operations/mail/rate-limiter.js +29 -0
  67. package/dist/permissions/modules/process-payload/process-payload.js +3 -10
  68. package/dist/permissions/modules/validate-access/validate-access.js +2 -3
  69. package/dist/schedules/metrics.js +6 -2
  70. package/dist/schedules/project.d.ts +4 -0
  71. package/dist/schedules/project.js +27 -0
  72. package/dist/services/collections.d.ts +3 -3
  73. package/dist/services/collections.js +16 -1
  74. package/dist/services/fields.d.ts +21 -5
  75. package/dist/services/fields.js +105 -28
  76. package/dist/services/graphql/resolvers/query.js +1 -1
  77. package/dist/services/graphql/resolvers/system-admin.js +49 -5
  78. package/dist/services/graphql/schema/parse-query.js +8 -8
  79. package/dist/services/graphql/utils/aggregate-query.d.ts +1 -1
  80. package/dist/services/graphql/utils/aggregate-query.js +5 -1
  81. package/dist/services/graphql/utils/filter-replace-m2a.js +2 -1
  82. package/dist/services/import-export.d.ts +9 -1
  83. package/dist/services/import-export.js +287 -101
  84. package/dist/services/items.d.ts +1 -1
  85. package/dist/services/items.js +36 -20
  86. package/dist/services/mail/index.js +2 -0
  87. package/dist/services/mail/rate-limiter.d.ts +1 -0
  88. package/dist/services/mail/rate-limiter.js +29 -0
  89. package/dist/services/meta.js +28 -24
  90. package/dist/services/schema.js +4 -1
  91. package/dist/services/server.d.ts +1 -0
  92. package/dist/services/server.js +14 -18
  93. package/dist/services/settings.d.ts +2 -1
  94. package/dist/services/settings.js +15 -0
  95. package/dist/services/tus/server.js +14 -9
  96. package/dist/telemetry/lib/get-report.js +4 -4
  97. package/dist/telemetry/lib/send-report.d.ts +6 -1
  98. package/dist/telemetry/lib/send-report.js +3 -1
  99. package/dist/telemetry/types/report.d.ts +17 -1
  100. package/dist/telemetry/utils/get-settings.d.ts +9 -0
  101. package/dist/telemetry/utils/get-settings.js +14 -0
  102. package/dist/test-utils/README.md +760 -0
  103. package/dist/test-utils/cache.d.ts +51 -0
  104. package/dist/test-utils/cache.js +59 -0
  105. package/dist/test-utils/database.d.ts +48 -0
  106. package/dist/test-utils/database.js +52 -0
  107. package/dist/test-utils/emitter.d.ts +35 -0
  108. package/dist/test-utils/emitter.js +38 -0
  109. package/dist/test-utils/fields-service.d.ts +28 -0
  110. package/dist/test-utils/fields-service.js +36 -0
  111. package/dist/test-utils/items-service.d.ts +23 -0
  112. package/dist/test-utils/items-service.js +37 -0
  113. package/dist/test-utils/knex.d.ts +164 -0
  114. package/dist/test-utils/knex.js +268 -0
  115. package/dist/test-utils/schema.d.ts +26 -0
  116. package/dist/test-utils/schema.js +35 -0
  117. package/dist/types/auth.d.ts +0 -2
  118. package/dist/utils/apply-diff.js +15 -0
  119. package/dist/utils/create-admin.d.ts +11 -0
  120. package/dist/utils/create-admin.js +50 -0
  121. package/dist/utils/get-schema.js +5 -3
  122. package/dist/utils/get-snapshot-diff.js +49 -5
  123. package/dist/utils/get-snapshot.js +13 -7
  124. package/dist/utils/sanitize-schema.d.ts +11 -4
  125. package/dist/utils/sanitize-schema.js +9 -6
  126. package/dist/utils/schedule.js +15 -19
  127. package/dist/utils/validate-diff.js +31 -0
  128. package/dist/utils/validate-snapshot.js +7 -0
  129. package/dist/websocket/controllers/hooks.js +12 -20
  130. package/dist/websocket/messages.d.ts +3 -3
  131. package/package.json +63 -65
  132. package/dist/cli/utils/defaults.d.ts +0 -4
  133. package/dist/cli/utils/defaults.js +0 -17
  134. package/dist/telemetry/utils/get-project-id.d.ts +0 -2
  135. package/dist/telemetry/utils/get-project-id.js +0 -4
@@ -1,5 +1,5 @@
1
1
  import { useEnv } from '@directus/env';
2
- import { ForbiddenError, InvalidPayloadError, ServiceUnavailableError, UnsupportedMediaTypeError, } from '@directus/errors';
2
+ import { createError, ErrorCode, ForbiddenError, InvalidPayloadError, ServiceUnavailableError, UnsupportedMediaTypeError, } from '@directus/errors';
3
3
  import { isSystemCollection } from '@directus/system-data';
4
4
  import { parseJSON, toArray } from '@directus/utils';
5
5
  import { createTmpFile } from '@directus/utils/node';
@@ -28,6 +28,126 @@ import { parseFields } from '../database/get-ast-from-query/lib/parse-fields.js'
28
28
  import { set } from 'lodash-es';
29
29
  const env = useEnv();
30
30
  const logger = useLogger();
31
+ const MAX_IMPORT_ERRORS = env['MAX_IMPORT_ERRORS'];
32
+ export function createErrorTracker() {
33
+ let genericError;
34
+ // For errors with field / type (joi validation or DB with field)
35
+ const fieldErrors = new Map();
36
+ let capturedErrorCount = 0;
37
+ let isLimitReached = false;
38
+ function convertToRanges(rows, minRangeSize = 4) {
39
+ const sorted = Array.from(new Set(rows)).sort((a, b) => a - b);
40
+ const result = [];
41
+ if (sorted.length === 0)
42
+ return [];
43
+ let start = sorted[0];
44
+ let prev = sorted[0];
45
+ let count = 1;
46
+ const nonConsecutive = [];
47
+ const flush = () => {
48
+ if (count >= minRangeSize) {
49
+ result.push({ type: 'range', start, end: prev });
50
+ }
51
+ else {
52
+ for (let i = start; i <= prev; i++) {
53
+ nonConsecutive.push(i);
54
+ }
55
+ }
56
+ };
57
+ for (let i = 1; i < sorted.length; i++) {
58
+ const current = sorted[i];
59
+ if (current === prev + 1) {
60
+ prev = current;
61
+ count++;
62
+ }
63
+ else {
64
+ flush();
65
+ start = prev = current;
66
+ count = 1;
67
+ }
68
+ }
69
+ flush();
70
+ // Add non-consecutive rows as a single "lines" entry
71
+ if (nonConsecutive.length > 0) {
72
+ result.push({ type: 'lines', rows: nonConsecutive });
73
+ }
74
+ return result;
75
+ }
76
+ function addCapturedError(err, rowNumber) {
77
+ const field = err.extensions?.field;
78
+ if (field) {
79
+ const type = err.extensions?.type;
80
+ const substring = err.extensions?.substring;
81
+ const valid = err.extensions?.valid;
82
+ const invalid = err.extensions?.invalid;
83
+ let key = type ? `${field}|${type}` : field;
84
+ if (substring !== undefined)
85
+ key += `|substring:${substring}`;
86
+ if (valid !== undefined)
87
+ key += `|valid:${JSON.stringify(valid)}`;
88
+ if (invalid !== undefined)
89
+ key += `|invalid:${JSON.stringify(invalid)}`;
90
+ if (!fieldErrors.has(err.code)) {
91
+ fieldErrors.set(err.code, new Map());
92
+ }
93
+ const errorsByCode = fieldErrors.get(err.code);
94
+ if (!errorsByCode.has(key)) {
95
+ errorsByCode.set(key, {
96
+ message: err.message,
97
+ rowNumbers: [],
98
+ });
99
+ }
100
+ errorsByCode.get(key).rowNumbers.push(rowNumber);
101
+ }
102
+ else {
103
+ genericError = err;
104
+ }
105
+ capturedErrorCount++;
106
+ if (capturedErrorCount >= MAX_IMPORT_ERRORS) {
107
+ isLimitReached = true;
108
+ }
109
+ }
110
+ function hasGenericError() {
111
+ return genericError !== undefined;
112
+ }
113
+ function buildFinalErrors() {
114
+ if (genericError) {
115
+ return [genericError];
116
+ }
117
+ return Array.from(fieldErrors.entries()).flatMap(([code, fieldMap]) => Array.from(fieldMap.entries()).map(([compositeKey, errorData]) => {
118
+ const parts = compositeKey.split('|');
119
+ const field = parts[0];
120
+ const type = parts[1];
121
+ const extensions = {};
122
+ for (let i = 2; i < parts.length; i++) {
123
+ const [paramType, paramValue] = parts[i]?.split(':', 2) ?? [];
124
+ if (!paramType || paramValue === undefined)
125
+ continue;
126
+ try {
127
+ extensions[paramType] = JSON.parse(paramValue);
128
+ }
129
+ catch {
130
+ extensions[paramType] = paramValue;
131
+ }
132
+ }
133
+ const ErrorClass = createError(code, errorData.message, 400);
134
+ return new ErrorClass({
135
+ field,
136
+ type,
137
+ ...extensions,
138
+ rows: convertToRanges(errorData.rowNumbers),
139
+ });
140
+ }));
141
+ }
142
+ return {
143
+ addCapturedError,
144
+ buildFinalErrors,
145
+ getCount: () => capturedErrorCount,
146
+ hasErrors: () => capturedErrorCount > 0 || hasGenericError(),
147
+ shouldStop: () => isLimitReached || hasGenericError(),
148
+ hasGenericError,
149
+ };
150
+ }
31
151
  export class ImportService {
32
152
  knex;
33
153
  accountability;
@@ -71,37 +191,72 @@ export class ImportService {
71
191
  async importJSON(collection, stream) {
72
192
  const extractJSON = StreamArray.withParser();
73
193
  const nestedActionEvents = [];
74
- return transaction(this.knex, (trx) => {
194
+ const errorTracker = createErrorTracker();
195
+ return transaction(this.knex, async (trx) => {
75
196
  const service = getService(collection, {
76
197
  knex: trx,
77
198
  schema: this.schema,
78
199
  accountability: this.accountability,
79
200
  });
80
- const saveQueue = queue(async (value) => {
81
- return await service.upsertOne(value, { bypassEmitAction: (params) => nestedActionEvents.push(params) });
82
- });
83
- return new Promise((resolve, reject) => {
84
- stream.pipe(extractJSON);
85
- extractJSON.on('data', ({ value }) => {
86
- saveQueue.push(value);
87
- });
88
- extractJSON.on('error', (err) => {
89
- destroyStream(stream);
90
- destroyStream(extractJSON);
91
- reject(new InvalidPayloadError({ reason: err.message }));
92
- });
93
- saveQueue.error((err) => {
94
- reject(err);
95
- });
96
- extractJSON.on('end', () => {
97
- saveQueue.drain(() => {
98
- for (const nestedActionEvent of nestedActionEvents) {
99
- emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
201
+ try {
202
+ await new Promise((resolve, reject) => {
203
+ let rowNumber = 1;
204
+ const saveQueue = queue(async (task) => {
205
+ if (errorTracker.shouldStop())
206
+ return;
207
+ try {
208
+ const result = await service.upsertOne(task.data, {
209
+ bypassEmitAction: (params) => nestedActionEvents.push(params),
210
+ });
211
+ return result;
212
+ }
213
+ catch (error) {
214
+ for (const err of toArray(error)) {
215
+ errorTracker.addCapturedError(err, task.rowNumber);
216
+ if (errorTracker.shouldStop()) {
217
+ break;
218
+ }
219
+ }
220
+ if (errorTracker.shouldStop()) {
221
+ saveQueue.kill();
222
+ destroyStream(stream);
223
+ destroyStream(extractJSON);
224
+ reject();
225
+ }
226
+ return;
100
227
  }
101
- return resolve();
228
+ });
229
+ stream.pipe(extractJSON);
230
+ extractJSON.on('data', ({ value }) => {
231
+ saveQueue.push({ data: value, rowNumber: rowNumber++ });
232
+ });
233
+ extractJSON.on('error', (err) => {
234
+ destroyStream(stream);
235
+ destroyStream(extractJSON);
236
+ reject(new InvalidPayloadError({ reason: err.message }));
237
+ });
238
+ extractJSON.on('end', () => {
239
+ // In case of empty JSON file
240
+ if (!saveQueue.started)
241
+ return resolve();
242
+ saveQueue.drain(() => {
243
+ if (errorTracker.hasErrors()) {
244
+ return reject();
245
+ }
246
+ for (const nestedActionEvent of nestedActionEvents) {
247
+ emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
248
+ }
249
+ return resolve();
250
+ });
102
251
  });
103
252
  });
104
- });
253
+ }
254
+ catch (error) {
255
+ if (!error && errorTracker.hasErrors()) {
256
+ throw errorTracker.buildFinalErrors();
257
+ }
258
+ throw error;
259
+ }
105
260
  });
106
261
  }
107
262
  async importCSV(collection, stream) {
@@ -109,98 +264,129 @@ export class ImportService {
109
264
  if (!tmpFile)
110
265
  throw new Error('Failed to create temporary file for import');
111
266
  const nestedActionEvents = [];
112
- return transaction(this.knex, (trx) => {
267
+ const errorTracker = createErrorTracker();
268
+ return transaction(this.knex, async (trx) => {
113
269
  const service = getService(collection, {
114
270
  knex: trx,
115
271
  schema: this.schema,
116
272
  accountability: this.accountability,
117
273
  });
118
- const saveQueue = queue(async (value) => {
119
- return await service.upsertOne(value, { bypassEmitAction: (action) => nestedActionEvents.push(action) });
120
- });
121
- const transform = (value) => {
122
- if (value.length === 0)
123
- return;
124
- try {
125
- const parsedJson = parseJSON(value);
126
- if (typeof parsedJson === 'number') {
127
- return value;
128
- }
129
- return parsedJson;
130
- }
131
- catch {
132
- return value;
133
- }
134
- };
135
- const PapaOptions = {
136
- header: true,
137
- // Trim whitespaces in headers, including the byte order mark (BOM) zero-width no-break space
138
- transformHeader: (header) => header.trim(),
139
- transform,
140
- };
141
- return new Promise((resolve, reject) => {
142
- const streams = [stream];
143
- const cleanup = (destroy = true) => {
144
- if (destroy) {
145
- for (const stream of streams) {
146
- destroyStream(stream);
274
+ try {
275
+ await new Promise((resolve, reject) => {
276
+ const streams = [stream];
277
+ let rowNumber = 0;
278
+ const cleanup = (destroy = true) => {
279
+ if (destroy) {
280
+ for (const stream of streams) {
281
+ destroyStream(stream);
282
+ }
147
283
  }
148
- }
149
- tmpFile.cleanup().catch(() => {
150
- logger.warn(`Failed to cleanup temporary import file (${tmpFile.path})`);
151
- });
152
- };
153
- saveQueue.error((error) => {
154
- reject(error);
155
- });
156
- const fileWriteStream = createWriteStream(tmpFile.path)
157
- .on('error', (error) => {
158
- cleanup();
159
- reject(new Error('Error while writing import data to temporary file', { cause: error }));
160
- })
161
- .on('finish', () => {
162
- const fileReadStream = createReadStream(tmpFile.path).on('error', (error) => {
163
- cleanup();
164
- reject(new Error('Error while reading import data from temporary file', { cause: error }));
165
- });
166
- streams.push(fileReadStream);
167
- fileReadStream
168
- .pipe(Papa.parse(Papa.NODE_STREAM_INPUT, PapaOptions))
169
- .on('data', (obj) => {
170
- const result = {};
171
- // Filter out all undefined fields
172
- for (const field in obj) {
173
- if (obj[field] !== undefined) {
174
- set(result, field, obj[field]);
284
+ tmpFile.cleanup().catch(() => {
285
+ logger.warn(`Failed to cleanup temporary import file (${tmpFile.path})`);
286
+ });
287
+ };
288
+ const saveQueue = queue(async (task) => {
289
+ if (errorTracker.shouldStop())
290
+ return;
291
+ try {
292
+ const result = await service.upsertOne(task.data, {
293
+ bypassEmitAction: (action) => nestedActionEvents.push(action),
294
+ });
295
+ return result;
296
+ }
297
+ catch (error) {
298
+ for (const err of toArray(error)) {
299
+ errorTracker.addCapturedError(err, task.rowNumber);
300
+ if (errorTracker.shouldStop()) {
301
+ break;
302
+ }
303
+ }
304
+ if (errorTracker.shouldStop()) {
305
+ saveQueue.kill();
306
+ cleanup(true);
307
+ reject();
175
308
  }
309
+ return;
176
310
  }
177
- saveQueue.push(result);
178
- })
311
+ });
312
+ const fileWriteStream = createWriteStream(tmpFile.path)
179
313
  .on('error', (error) => {
180
314
  cleanup();
181
- reject(new InvalidPayloadError({ reason: error.message }));
315
+ reject(new Error('Error while writing import data to temporary file', { cause: error }));
182
316
  })
183
- .on('end', () => {
184
- cleanup(false);
185
- // In case of empty CSV file
186
- if (!saveQueue.started)
187
- return resolve();
188
- saveQueue.drain(() => {
189
- for (const nestedActionEvent of nestedActionEvents) {
190
- emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
317
+ .on('finish', () => {
318
+ const fileReadStream = createReadStream(tmpFile.path).on('error', (error) => {
319
+ cleanup();
320
+ reject(new Error('Error while reading import data from temporary file', { cause: error }));
321
+ });
322
+ streams.push(fileReadStream);
323
+ fileReadStream
324
+ .pipe(Papa.parse(Papa.NODE_STREAM_INPUT, {
325
+ header: true,
326
+ transformHeader: (header) => header.trim(),
327
+ transform: (value) => {
328
+ if (value.length === 0)
329
+ return;
330
+ try {
331
+ const parsedJson = parseJSON(value);
332
+ if (typeof parsedJson === 'number') {
333
+ return value;
334
+ }
335
+ return parsedJson;
336
+ }
337
+ catch {
338
+ return value;
339
+ }
340
+ },
341
+ }))
342
+ .on('data', (obj) => {
343
+ rowNumber++;
344
+ const result = {};
345
+ for (const field in obj) {
346
+ if (obj[field] !== undefined) {
347
+ set(result, field, obj[field]);
348
+ }
191
349
  }
192
- return resolve();
350
+ saveQueue.push({ data: result, rowNumber });
351
+ })
352
+ .on('error', (error) => {
353
+ cleanup();
354
+ reject(new InvalidPayloadError({ reason: error.message }));
355
+ })
356
+ .on('end', () => {
357
+ // In case of empty CSV file
358
+ if (!saveQueue.started) {
359
+ cleanup(false);
360
+ return resolve();
361
+ }
362
+ saveQueue.drain(() => {
363
+ if (!errorTracker.shouldStop())
364
+ cleanup(false);
365
+ if (errorTracker.hasErrors()) {
366
+ return reject();
367
+ }
368
+ for (const nestedActionEvent of nestedActionEvents) {
369
+ emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
370
+ }
371
+ return resolve();
372
+ });
193
373
  });
194
374
  });
375
+ streams.push(fileWriteStream);
376
+ stream
377
+ .on('error', (error) => {
378
+ cleanup();
379
+ reject(new Error('Error while retrieving import data', { cause: error }));
380
+ })
381
+ .pipe(fileWriteStream);
195
382
  });
196
- streams.push(fileWriteStream);
197
- stream
198
- .on('error', (error) => {
199
- cleanup();
200
- reject(new Error('Error while retrieving import data', { cause: error }));
201
- })
202
- .pipe(fileWriteStream);
203
- });
383
+ }
384
+ catch (error) {
385
+ if (!error && errorTracker.hasErrors()) {
386
+ throw errorTracker.buildFinalErrors();
387
+ }
388
+ throw error;
389
+ }
204
390
  });
205
391
  }
206
392
  }
@@ -1,4 +1,4 @@
1
- import type { AbstractService, AbstractServiceOptions, Accountability, Item as AnyItem, MutationTracker, MutationOptions, PrimaryKey, Query, QueryOptions, SchemaOverview } from '@directus/types';
1
+ import type { AbstractService, AbstractServiceOptions, Accountability, Item as AnyItem, MutationOptions, MutationTracker, PrimaryKey, Query, QueryOptions, SchemaOverview } from '@directus/types';
2
2
  import type Keyv from 'keyv';
3
3
  import type { Knex } from 'knex';
4
4
  export declare class ItemsService<Item extends AnyItem = AnyItem, Collection extends string = string> implements AbstractService<Item> {
@@ -8,7 +8,7 @@ import { getCache } from '../cache.js';
8
8
  import { translateDatabaseError } from '../database/errors/translate.js';
9
9
  import { getAstFromQuery } from '../database/get-ast-from-query/get-ast-from-query.js';
10
10
  import { getHelpers } from '../database/helpers/index.js';
11
- import getDatabase from '../database/index.js';
11
+ import getDatabase, { getDatabaseClient } from '../database/index.js';
12
12
  import { runAst } from '../database/run-ast/run-ast.js';
13
13
  import emitter from '../emitter.js';
14
14
  import { processAst } from '../permissions/modules/process-ast/process-ast.js';
@@ -173,10 +173,15 @@ export class ItemsService {
173
173
  autoIncrementSequenceNeedsToBeReset = true;
174
174
  }
175
175
  try {
176
+ let returningOptions = undefined;
177
+ // Support MSSQL tables that have triggers.
178
+ if (getDatabaseClient(trx) === 'mssql') {
179
+ returningOptions = { includeTriggerModifications: true };
180
+ }
176
181
  const result = await trx
177
182
  .insert(payloadWithoutAliases)
178
183
  .into(this.collection)
179
- .returning(primaryKeyField)
184
+ .returning(primaryKeyField, returningOptions)
180
185
  .then((result) => result[0]);
181
186
  const returnedKey = typeof result === 'object' ? result[primaryKeyField] : result;
182
187
  if (pkField.type === 'uuid') {
@@ -436,7 +441,7 @@ export class ItemsService {
436
441
  const filterWithKey = assign({}, query.filter, { [primaryKeyField]: { _eq: key } });
437
442
  const queryWithKey = assign({}, query, { filter: filterWithKey });
438
443
  let results = [];
439
- if (query.version) {
444
+ if (query.version && query.version !== 'main') {
440
445
  results = [await handleVersion(this, key, queryWithKey, opts)];
441
446
  }
442
447
  else {
@@ -806,12 +811,23 @@ export class ItemsService {
806
811
  }
807
812
  const primaryKeyField = this.schema.collections[this.collection].primary;
808
813
  validateKeys(this.schema, this.collection, primaryKeyField, keys);
814
+ const keysAfterHooks = opts.emitEvents !== false
815
+ ? await emitter.emitFilter(this.eventScope === 'items'
816
+ ? ['items.delete', `${this.collection}.items.delete`]
817
+ : `${this.eventScope}.delete`, keys, {
818
+ collection: this.collection,
819
+ }, {
820
+ database: this.knex,
821
+ schema: this.schema,
822
+ accountability: this.accountability,
823
+ })
824
+ : keys;
809
825
  if (this.accountability) {
810
826
  await validateAccess({
811
827
  accountability: this.accountability,
812
828
  action: 'delete',
813
829
  collection: this.collection,
814
- primaryKeys: keys,
830
+ primaryKeys: keysAfterHooks,
815
831
  }, {
816
832
  knex: this.knex,
817
833
  schema: this.schema,
@@ -820,17 +836,8 @@ export class ItemsService {
820
836
  if (opts.preMutationError) {
821
837
  throw opts.preMutationError;
822
838
  }
823
- if (opts.emitEvents !== false) {
824
- await emitter.emitFilter(this.eventScope === 'items' ? ['items.delete', `${this.collection}.items.delete`] : `${this.eventScope}.delete`, keys, {
825
- collection: this.collection,
826
- }, {
827
- database: this.knex,
828
- schema: this.schema,
829
- accountability: this.accountability,
830
- });
831
- }
832
839
  await transaction(this.knex, async (trx) => {
833
- await trx(this.collection).whereIn(primaryKeyField, keys).delete();
840
+ await trx(this.collection).whereIn(primaryKeyField, keysAfterHooks).delete();
834
841
  if (opts.userIntegrityCheckFlags) {
835
842
  if (opts.onRequireUserIntegrityCheck) {
836
843
  opts.onRequireUserIntegrityCheck(opts.userIntegrityCheckFlags);
@@ -847,7 +854,7 @@ export class ItemsService {
847
854
  knex: trx,
848
855
  schema: this.schema,
849
856
  });
850
- await activityService.createMany(keys.map((key) => ({
857
+ await activityService.createMany(keysAfterHooks.map((key) => ({
851
858
  action: Action.DELETE,
852
859
  user: this.accountability.user,
853
860
  collection: this.collection,
@@ -867,8 +874,8 @@ export class ItemsService {
867
874
  ? ['items.delete', `${this.collection}.items.delete`]
868
875
  : `${this.eventScope}.delete`,
869
876
  meta: {
870
- payload: keys,
871
- keys: keys,
877
+ payload: keysAfterHooks,
878
+ keys: keysAfterHooks,
872
879
  collection: this.collection,
873
880
  },
874
881
  context: {
@@ -884,7 +891,7 @@ export class ItemsService {
884
891
  emitter.emitAction(actionEvent.event, actionEvent.meta, actionEvent.context);
885
892
  }
886
893
  }
887
- return keys;
894
+ return keysAfterHooks;
888
895
  }
889
896
  /**
890
897
  * Read/treat collection as singleton.
@@ -892,8 +899,17 @@ export class ItemsService {
892
899
  async readSingleton(query, opts) {
893
900
  query = clone(query);
894
901
  query.limit = 1;
895
- const records = await this.readByQuery(query, opts);
896
- const record = records[0];
902
+ let record;
903
+ if (query.version && query.version !== 'main') {
904
+ const primaryKeyField = this.schema.collections[this.collection].primary;
905
+ const key = (await this.knex.select(primaryKeyField).from(this.collection).first())?.[primaryKeyField];
906
+ if (key) {
907
+ record = await handleVersion(this, key, query, opts);
908
+ }
909
+ }
910
+ else {
911
+ record = (await this.readByQuery(query, opts))[0];
912
+ }
897
913
  if (!record) {
898
914
  let fields = Object.entries(this.schema.collections[this.collection].fields);
899
915
  const defaults = {};
@@ -10,6 +10,7 @@ import emitter from '../../emitter.js';
10
10
  import { useLogger } from '../../logger/index.js';
11
11
  import getMailer from '../../mailer.js';
12
12
  import { Url } from '../../utils/url.js';
13
+ import { useEmailRateLimiterQueue } from './rate-limiter.js';
13
14
  const env = useEnv();
14
15
  const logger = useLogger();
15
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -37,6 +38,7 @@ export class MailService {
37
38
  }
38
39
  }
39
40
  async send(options) {
41
+ await useEmailRateLimiterQueue();
40
42
  const payload = await emitter.emitFilter(`email.send`, options, {});
41
43
  if (!payload)
42
44
  return null;
@@ -0,0 +1 @@
1
+ export declare function useEmailRateLimiterQueue(): Promise<void>;
@@ -0,0 +1,29 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { RateLimiterQueue } from 'rate-limiter-flexible';
3
+ import { createRateLimiter } from '../../rate-limiter.js';
4
+ import { toBoolean } from '@directus/utils';
5
+ import { EmailLimitExceededError } from '@directus/errors';
6
+ let emailRateLimiterQueue;
7
+ const env = useEnv();
8
+ if (toBoolean(env['RATE_LIMITER_EMAIL_ENABLED']) === true) {
9
+ emailRateLimiterQueue = new RateLimiterQueue(createRateLimiter('RATE_LIMITER_EMAIL'), {
10
+ maxQueueSize: Number(env['RATE_LIMITER_EMAIL_QUEUE_SIZE']),
11
+ });
12
+ }
13
+ export async function useEmailRateLimiterQueue() {
14
+ if (!emailRateLimiterQueue)
15
+ return;
16
+ try {
17
+ await emailRateLimiterQueue.removeTokens(1);
18
+ }
19
+ catch (err) {
20
+ if (err instanceof Error) {
21
+ throw new EmailLimitExceededError({
22
+ points: 'RATE_LIMITER_EMAIL_POINTS' in env ? Number(env['RATE_LIMITER_EMAIL_POINTS']) : undefined,
23
+ duration: 'RATE_LIMITER_EMAIL_DURATION' in env ? Number(env['RATE_LIMITER_EMAIL_DURATION']) : undefined,
24
+ message: 'RATE_LIMITER_EMAIL_ERROR_MESSAGE' in env ? String(env['RATE_LIMITER_EMAIL_ERROR_MESSAGE']) : undefined,
25
+ });
26
+ }
27
+ throw err;
28
+ }
29
+ }