@directus/api 18.1.0 → 18.2.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.
@@ -66,7 +66,7 @@ export function generateApiExtensionsSandboxEntrypoint(type, name, endpointRoute
66
66
 
67
67
  const registerOperation = ${generateHostFunctionReference(index, ['id', 'handler'], { async: false })}
68
68
 
69
- const operationConfig = extensionExport();
69
+ const operationConfig = extensionExport;
70
70
 
71
71
  registerOperation(operationConfig.id, operationConfig.handler);
72
72
  `;
@@ -1,6 +1,6 @@
1
1
  import type { PromiseCallback } from '@directus/types';
2
2
  import type { Reference } from 'isolated-vm';
3
3
  export declare function registerOperationGenerator(): {
4
- register: (id: Reference<string>, cb: Reference<(data: Record<string, unknown>) => unknown | Promise<unknown> | void>) => void;
4
+ register: (id: Reference<string>, cb: Reference<(options: Record<string, unknown>) => unknown | Promise<unknown> | void>) => void;
5
5
  unregisterFunctions: PromiseCallback[];
6
6
  };
@@ -9,7 +9,10 @@ export function registerOperationGenerator() {
9
9
  if (cb.typeof !== 'function')
10
10
  throw new TypeError('Operation config handler has to be of type function');
11
11
  const idCopied = id.copySync();
12
- const handler = async (data) => callReference(cb, [data]);
12
+ const handler = async (options) => {
13
+ const response = await callReference(cb, [options]);
14
+ return response.copy();
15
+ };
13
16
  flowManager.addOperation(idCopied, handler);
14
17
  unregisterFunctions.push(() => {
15
18
  flowManager.removeOperation(idCopied);
@@ -28,8 +28,8 @@ export function requestGenerator(requestedScopes) {
28
28
  if (body !== undefined && body.typeof !== 'undefined' && body.typeof !== 'string' && body.typeof !== 'object') {
29
29
  throw new TypeError('Request body has to be of type string or object');
30
30
  }
31
- if (headers !== undefined && headers.typeof !== 'undefined' && headers.typeof !== 'array') {
32
- throw new TypeError('Request headers has to be of type array');
31
+ if (headers !== undefined && headers.typeof !== 'undefined' && headers.typeof !== 'object') {
32
+ throw new TypeError('Request headers has to be of type object');
33
33
  }
34
34
  const methodCopied = await method?.copy();
35
35
  const bodyCopied = await body?.copy();
@@ -18,7 +18,7 @@ export async function instantiateSandboxSdk(isolate, requestedScopes) {
18
18
  const handlerCode = sdk
19
19
  .map(({ name, args, async }) => `sdk.${name} = ${generateHostFunctionReference(index, args, { async })}`)
20
20
  .join('\n');
21
- await apiContext.evalClosure(handlerCode, sdk.map(({ generator, async }) => (async ? wrap(generator(requestedScopes)) : generator(requestedScopes))), { filename: '<extensions-sdk>', arguments: { reference: true } });
21
+ await apiContext.evalClosure(handlerCode, sdk.map(({ name, generator, async }) => async ? wrap(name, generator(requestedScopes)) : generator(requestedScopes)), { filename: '<extensions-sdk>', arguments: { reference: true } });
22
22
  const exportCode = sdk.map(({ name }) => `export const ${name} = sdk.${name};`).join('\n');
23
23
  const apiModule = await isolate.compileModule(exportCode);
24
24
  await apiModule.instantiate(apiContext, () => {
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * This is needed as isolated-vm doesn't allow the isolate to catch errors that are thrown in the
5
5
  * host. Instead, we'll wrap the output in a known shape which allows the isolated sdk context to
6
- * re-throw the error in the correct context
6
+ * re-throw the error in the correct context.
7
+ *
8
+ * @see https://github.com/laverdet/isolated-vm/issues/417
7
9
  */
8
- export declare function wrap(util: (...args: any[]) => any): (...args: any[]) => Promise<{
10
+ export declare function wrap(name: string, util: (...args: any[]) => any): (...args: any[]) => Promise<{
9
11
  result: any;
10
12
  error: boolean;
11
13
  }>;
@@ -3,15 +3,49 @@
3
3
  *
4
4
  * This is needed as isolated-vm doesn't allow the isolate to catch errors that are thrown in the
5
5
  * host. Instead, we'll wrap the output in a known shape which allows the isolated sdk context to
6
- * re-throw the error in the correct context
6
+ * re-throw the error in the correct context.
7
+ *
8
+ * @see https://github.com/laverdet/isolated-vm/issues/417
7
9
  */
8
- export function wrap(util) {
10
+ export function wrap(name, util) {
9
11
  return async (...args) => {
10
12
  try {
11
13
  return { result: await util(...args), error: false };
12
14
  }
13
- catch (e) {
14
- return { result: e, error: true };
15
+ catch (error) {
16
+ // isolated-vm expects objects thrown from within the vm to be an instance of `Error`
17
+ let result;
18
+ if (error instanceof Error) {
19
+ // Don't expose the stack trace to the vm
20
+ delete error.stack;
21
+ // Serialize the remaining error properties
22
+ for (const key of Object.getOwnPropertyNames(error)) {
23
+ const value = error[key];
24
+ if (!value || typeof value !== 'object')
25
+ continue;
26
+ error[key] = JSON.stringify(value, getCircularReplacer());
27
+ }
28
+ result = error;
29
+ }
30
+ else if (error && typeof error !== 'object') {
31
+ result = error;
32
+ }
33
+ else {
34
+ result = new Error(`Unknown error in "${name}" Sandbox SDK function`);
35
+ }
36
+ return { result, error: true };
37
+ }
38
+ };
39
+ }
40
+ function getCircularReplacer() {
41
+ const seen = new WeakSet();
42
+ return (_key, value) => {
43
+ if (value !== null && typeof value === 'object') {
44
+ if (seen.has(value)) {
45
+ return '[Circular]';
46
+ }
47
+ seen.add(value);
15
48
  }
49
+ return value;
16
50
  };
17
51
  }
@@ -1 +1,3 @@
1
- export declare const syncExtensions: () => Promise<void>;
1
+ export declare const syncExtensions: (options?: {
2
+ force: boolean;
3
+ }) => Promise<void>;
@@ -7,56 +7,64 @@ import { dirname, join, relative, resolve, sep } from 'node:path';
7
7
  import { pipeline } from 'node:stream/promises';
8
8
  import Queue from 'p-queue';
9
9
  import { useBus } from '../../bus/index.js';
10
+ import { useLock } from '../../lock/index.js';
10
11
  import { useLogger } from '../../logger.js';
11
12
  import { getStorage } from '../../storage/index.js';
12
13
  import { getExtensionsPath } from './get-extensions-path.js';
13
14
  import { SyncStatus, getSyncStatus, setSyncStatus } from './sync-status.js';
14
- export const syncExtensions = async () => {
15
+ export const syncExtensions = async (options) => {
16
+ const lock = useLock();
17
+ const messenger = useBus();
15
18
  const env = useEnv();
16
19
  const logger = useLogger();
17
- const extensionsPath = getExtensionsPath();
18
- const storageExtensionsPath = env['EXTENSIONS_PATH'];
19
- const messenger = useBus();
20
- const isPrimaryProcess = String(process.env['NODE_APP_INSTANCE']) === '0' || process.env['NODE_APP_INSTANCE'] === undefined;
21
- const id = await mid.machineId();
22
- const message = `extensions-sync/${id}`;
23
- if (isPrimaryProcess === false) {
20
+ if (!options?.force) {
24
21
  const isDone = (await getSyncStatus()) === SyncStatus.DONE;
25
22
  if (isDone)
26
23
  return;
24
+ }
25
+ const machineId = await mid.machineId();
26
+ const machineKey = `extensions-sync/${machineId}`;
27
+ const processId = await lock.increment(machineKey);
28
+ const currentProcessShouldHandleSync = processId === 1;
29
+ if (currentProcessShouldHandleSync === false) {
27
30
  logger.trace('Extensions already being synced to this machine from another process.');
28
- /**
29
- * Wait until the process that called the lock publishes a message that the syncing is complete
30
- */
31
+ // Wait until the process that called the lock publishes a message that the syncing is complete
31
32
  return new Promise((resolve) => {
32
- messenger.subscribe(message, () => resolve());
33
+ messenger.subscribe(machineKey, () => resolve());
33
34
  });
34
35
  }
35
- if (await exists(extensionsPath)) {
36
- // In case the FS still contains the cached extensions from a previous invocation. We have to
37
- // clear them out to ensure the remote extensions folder remains the source of truth for all
38
- // extensions that are loaded.
39
- await rm(extensionsPath, { recursive: true, force: true });
36
+ try {
37
+ const extensionsPath = getExtensionsPath();
38
+ const storageExtensionsPath = env['EXTENSIONS_PATH'];
39
+ if (await exists(extensionsPath)) {
40
+ // In case the FS still contains the cached extensions from a previous invocation. We have to
41
+ // clear them out to ensure the remote extensions folder remains the source of truth for all
42
+ // extensions that are loaded.
43
+ await rm(extensionsPath, { recursive: true, force: true });
44
+ }
45
+ // Ensure that the local extensions cache path exists
46
+ await mkdir(extensionsPath, { recursive: true });
47
+ await setSyncStatus(SyncStatus.SYNCING);
48
+ logger.trace('Syncing extensions from configured storage location...');
49
+ const storage = await getStorage();
50
+ const disk = storage.location(env['EXTENSIONS_LOCATION']);
51
+ // Make sure we don't overload the file handles
52
+ const queue = new Queue({ concurrency: 1000 });
53
+ for await (const filepath of disk.list(storageExtensionsPath)) {
54
+ const readStream = await disk.read(filepath);
55
+ // We want files to be stored in the root of `$TEMP_PATH/extensions`, so gotta remove the
56
+ // extensions path on disk from the start of the file path
57
+ const destPath = join(extensionsPath, relative(resolve(sep, storageExtensionsPath), resolve(sep, filepath)));
58
+ // Ensure that the directory path exists
59
+ await mkdir(dirname(destPath), { recursive: true });
60
+ const writeStream = createWriteStream(destPath);
61
+ queue.add(() => pipeline(readStream, writeStream));
62
+ }
63
+ await queue.onIdle();
64
+ await setSyncStatus(SyncStatus.DONE);
65
+ messenger.publish(machineKey, { ready: true });
40
66
  }
41
- // Ensure that the local extensions cache path exists
42
- await mkdir(extensionsPath, { recursive: true });
43
- await setSyncStatus(SyncStatus.SYNCING);
44
- logger.trace('Syncing extensions from configured storage location...');
45
- const storage = await getStorage();
46
- const disk = storage.location(env['EXTENSIONS_LOCATION']);
47
- // Make sure we don't overload the file handles
48
- const queue = new Queue({ concurrency: 1000 });
49
- for await (const filepath of disk.list(storageExtensionsPath)) {
50
- const readStream = await disk.read(filepath);
51
- // We want files to be stored in the root of `$TEMP_PATH/extensions`, so gotta remove the
52
- // extensions path on disk from the start of the file path
53
- const destPath = join(extensionsPath, relative(resolve(sep, storageExtensionsPath), resolve(sep, filepath)));
54
- // Ensure that the directory path exists
55
- await mkdir(dirname(destPath), { recursive: true });
56
- const writeStream = createWriteStream(destPath);
57
- queue.add(() => pipeline(readStream, writeStream));
67
+ finally {
68
+ await lock.delete(machineKey);
58
69
  }
59
- await queue.onIdle();
60
- await setSyncStatus(SyncStatus.DONE);
61
- messenger.publish(message, { ready: true });
62
70
  };
@@ -92,7 +92,9 @@ export declare class ExtensionManager {
92
92
  /**
93
93
  * Reload all the extensions. Will unload if extensions have already been loaded
94
94
  */
95
- reload(): Promise<unknown>;
95
+ reload(options?: {
96
+ forceSync: boolean;
97
+ }): Promise<unknown>;
96
98
  /**
97
99
  * Return the previously generated app extensions bundle
98
100
  */
@@ -2,7 +2,7 @@ import { useEnv } from '@directus/env';
2
2
  import { APP_SHARED_DEPS, HYBRID_EXTENSION_TYPES } from '@directus/extensions';
3
3
  import { generateExtensionsEntrypoint } from '@directus/extensions/node';
4
4
  import { isTypeIn, toBoolean } from '@directus/utils';
5
- import { getNodeEnv, pathToRelativeUrl, processId } from '@directus/utils/node';
5
+ import { pathToRelativeUrl, processId } from '@directus/utils/node';
6
6
  import aliasDefault from '@rollup/plugin-alias';
7
7
  import nodeResolveDefault from '@rollup/plugin-node-resolve';
8
8
  import virtualDefault from '@rollup/plugin-virtual';
@@ -45,7 +45,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
45
45
  const env = useEnv();
46
46
  const defaultOptions = {
47
47
  schedule: true,
48
- watch: env['EXTENSIONS_AUTO_RELOAD'] && getNodeEnv() !== 'development',
48
+ watch: env['EXTENSIONS_AUTO_RELOAD'],
49
49
  };
50
50
  export class ExtensionManager {
51
51
  options = defaultOptions;
@@ -170,22 +170,22 @@ export class ExtensionManager {
170
170
  */
171
171
  async install(versionId) {
172
172
  await this.installationManager.install(versionId);
173
- await this.reload();
173
+ await this.reload({ forceSync: true });
174
174
  await this.messenger.publish(this.reloadChannel, { origin: this.processId });
175
175
  }
176
176
  async uninstall(folder) {
177
177
  await this.installationManager.uninstall(folder);
178
- await this.reload();
178
+ await this.reload({ forceSync: true });
179
179
  await this.messenger.publish(this.reloadChannel, { origin: this.processId });
180
180
  }
181
181
  /**
182
182
  * Load all extensions from disk and register them in their respective places
183
183
  */
184
- async load() {
184
+ async load(options) {
185
185
  const logger = useLogger();
186
186
  if (env['EXTENSIONS_LOCATION']) {
187
187
  try {
188
- await syncExtensions();
188
+ await syncExtensions({ force: options?.forceSync ?? false });
189
189
  }
190
190
  catch (error) {
191
191
  logger.error(`Failed to sync extensions`);
@@ -221,7 +221,7 @@ export class ExtensionManager {
221
221
  /**
222
222
  * Reload all the extensions. Will unload if extensions have already been loaded
223
223
  */
224
- reload() {
224
+ reload(options) {
225
225
  if (this.reloadQueue.size > 0) {
226
226
  // The pending job in the queue will already handle the additional changes
227
227
  return Promise.resolve();
@@ -237,7 +237,7 @@ export class ExtensionManager {
237
237
  if (this.isLoaded) {
238
238
  const prevExtensions = clone(this.extensions);
239
239
  await this.unload();
240
- await this.load();
240
+ await this.load(options);
241
241
  logger.info('Extensions reloaded');
242
242
  const added = this.extensions.filter((extension) => !prevExtensions.some((prevExtension) => extension.path === prevExtension.path));
243
243
  const removed = prevExtensions.filter((prevExtension) => !this.extensions.some((extension) => prevExtension.path === extension.path));
@@ -294,7 +294,8 @@ export class ExtensionManager {
294
294
  logger.info('Watching extensions for changes...');
295
295
  const extensionDirUrl = pathToRelativeUrl(getExtensionsPath());
296
296
  this.watcher = chokidar.watch([path.resolve('package.json'), path.posix.join(extensionDirUrl, '*', 'package.json')], {
297
- ignoreInitial: true, // dotdirs are watched by default and frequently found in 'node_modules'
297
+ ignoreInitial: true,
298
+ // dotdirs are watched by default and frequently found in 'node_modules'
298
299
  ignored: `${extensionDirUrl}/**/node_modules/**`,
299
300
  // on macOS dotdirs in linked extensions are watched too
300
301
  followSymlinks: os.platform() === 'darwin' ? false : true,
@@ -318,20 +319,20 @@ export class ExtensionManager {
318
319
  * removed
319
320
  */
320
321
  updateWatchedExtensions(added, removed = []) {
321
- if (this.watcher) {
322
- const toPackageExtensionPaths = (extensions) => extensions
323
- .filter((extension) => !extension.local || extension.type === 'bundle')
324
- .flatMap((extension) => isTypeIn(extension, HYBRID_EXTENSION_TYPES) || extension.type === 'bundle'
325
- ? [
326
- path.resolve(extension.path, extension.entrypoint.app),
327
- path.resolve(extension.path, extension.entrypoint.api),
328
- ]
329
- : path.resolve(extension.path, extension.entrypoint));
330
- const addedPackageExtensionPaths = toPackageExtensionPaths(added);
331
- const removedPackageExtensionPaths = toPackageExtensionPaths(removed);
332
- this.watcher.add(addedPackageExtensionPaths);
333
- this.watcher.unwatch(removedPackageExtensionPaths);
334
- }
322
+ if (!this.watcher)
323
+ return;
324
+ const extensionDir = path.resolve(getExtensionsPath());
325
+ const registryDir = path.join(extensionDir, '.registry');
326
+ const toPackageExtensionPaths = (extensions) => extensions
327
+ .filter((extension) => extension.local && extension.path.startsWith(extensionDir) && !extension.path.startsWith(registryDir))
328
+ .flatMap((extension) => isTypeIn(extension, HYBRID_EXTENSION_TYPES) || extension.type === 'bundle'
329
+ ? [
330
+ path.resolve(extension.path, extension.entrypoint.app),
331
+ path.resolve(extension.path, extension.entrypoint.api),
332
+ ]
333
+ : path.resolve(extension.path, extension.entrypoint));
334
+ this.watcher.add(toPackageExtensionPaths(added));
335
+ this.watcher.unwatch(toPackageExtensionPaths(removed));
335
336
  }
336
337
  /**
337
338
  * Uses rollup to bundle the app extensions together into a single file the app can download and
@@ -399,7 +400,8 @@ export class ExtensionManager {
399
400
  });
400
401
  this.unregisterFunctionMap.set(extension.name, async () => {
401
402
  await unregisterFunction();
402
- isolate.dispose();
403
+ if (!isolate.isDisposed)
404
+ isolate.dispose();
403
405
  });
404
406
  }
405
407
  async registerApiExtensions() {
package/dist/flows.js CHANGED
@@ -17,7 +17,6 @@ import { getSchema } from './utils/get-schema.js';
17
17
  import { JobQueue } from './utils/job-queue.js';
18
18
  import { mapValuesDeep } from './utils/map-values-deep.js';
19
19
  import { redactObject } from './utils/redact-object.js';
20
- import { sanitizeError } from './utils/sanitize-error.js';
21
20
  import { scheduleSynchronizedJob, validateCron } from './utils/schedule.js';
22
21
  import { isSystemCollection } from '@directus/system-data';
23
22
  let flowManager;
@@ -341,8 +340,9 @@ class FlowManager {
341
340
  catch (error) {
342
341
  let data;
343
342
  if (error instanceof Error) {
344
- // make sure we don't expose the stack trace
345
- data = sanitizeError(error);
343
+ // Don't expose the stack trace to the next operation
344
+ delete error.stack;
345
+ data = error;
346
346
  }
347
347
  else if (typeof error === 'string') {
348
348
  // If the error is a JSON string, parse it and use that as the error data
@@ -0,0 +1 @@
1
+ export * from './lib/use-lock.js';
@@ -0,0 +1 @@
1
+ export * from './lib/use-lock.js';
@@ -0,0 +1,8 @@
1
+ import { type Kv } from '@directus/memory';
2
+ export declare const _cache: {
3
+ lock: Kv | undefined;
4
+ };
5
+ /**
6
+ * Returns globally shared lock kv instance.
7
+ */
8
+ export declare const useLock: () => Kv;
@@ -0,0 +1,20 @@
1
+ import { createKv } from '@directus/memory';
2
+ import { redisConfigAvailable, useRedis } from '../../redis/index.js';
3
+ export const _cache = {
4
+ lock: undefined,
5
+ };
6
+ /**
7
+ * Returns globally shared lock kv instance.
8
+ */
9
+ export const useLock = () => {
10
+ if (_cache.lock) {
11
+ return _cache.lock;
12
+ }
13
+ if (redisConfigAvailable()) {
14
+ _cache.lock = createKv({ type: 'redis', redis: useRedis(), namespace: 'directus:lock' });
15
+ }
16
+ else {
17
+ _cache.lock = createKv({ type: 'local' });
18
+ }
19
+ return _cache.lock;
20
+ };
@@ -1,6 +1,8 @@
1
1
  import { defineOperationApi } from '@directus/extensions';
2
2
  import { MailService } from '../../services/mail/index.js';
3
3
  import { md } from '../../utils/md.js';
4
+ import { useLogger } from '../../logger.js';
5
+ const logger = useLogger();
4
6
  export default defineOperationApi({
5
7
  id: 'mail',
6
8
  handler: async ({ body, template, data, to, type, subject }, { accountability, database, getSchema }) => {
@@ -16,6 +18,8 @@ export default defineOperationApi({
16
18
  else {
17
19
  mailObject.html = type === 'wysiwyg' ? safeBody : md(safeBody);
18
20
  }
19
- await mailService.send(mailObject);
21
+ mailService.send(mailObject).catch((error) => {
22
+ logger.error(error, 'Could not send mail in "mail" operation');
23
+ });
20
24
  },
21
25
  });
@@ -4,5 +4,8 @@ import { useEnv } from '@directus/env';
4
4
  */
5
5
  export const redisConfigAvailable = () => {
6
6
  const env = useEnv();
7
+ if ('REDIS_ENABLED' in env) {
8
+ return env['REDIS_ENABLED'] === true;
9
+ }
7
10
  return 'REDIS' in env || Object.keys(env).some((key) => key.startsWith('REDIS_'));
8
11
  };
@@ -1,6 +1,7 @@
1
1
  import { Action, FUNCTIONS } from '@directus/constants';
2
2
  import { useEnv } from '@directus/env';
3
3
  import { ErrorCode, ForbiddenError, InvalidPayloadError, isDirectusError } from '@directus/errors';
4
+ import { isSystemCollection } from '@directus/system-data';
4
5
  import { parseFilterFunctionPath, toBoolean } from '@directus/utils';
5
6
  import argon2 from 'argon2';
6
7
  import { GraphQLBoolean, GraphQLEnumType, GraphQLError, GraphQLFloat, GraphQLID, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, GraphQLSchema, GraphQLString, GraphQLUnionType, NoSchemaIntrospectionCustomRule, execute, specifiedRules, validate, } from 'graphql';
@@ -12,6 +13,8 @@ import getDatabase from '../../database/index.js';
12
13
  import { generateHash } from '../../utils/generate-hash.js';
13
14
  import { getGraphQLType } from '../../utils/get-graphql-type.js';
14
15
  import { getService } from '../../utils/get-service.js';
16
+ import isDirectusJWT from '../../utils/is-directus-jwt.js';
17
+ import { verifyAccessJWT } from '../../utils/jwt.js';
15
18
  import { mergeVersionsRaw, mergeVersionsRecursive } from '../../utils/merge-version-data.js';
16
19
  import { reduceSchema } from '../../utils/reduce-schema.js';
17
20
  import { sanitizeQuery } from '../../utils/sanitize-query.js';
@@ -31,6 +34,7 @@ import { UsersService } from '../users.js';
31
34
  import { UtilsService } from '../utils.js';
32
35
  import { VersionsService } from '../versions.js';
33
36
  import { GraphQLExecutionError, GraphQLValidationError } from './errors/index.js';
37
+ import { cache } from './schema-cache.js';
34
38
  import { createSubscriptionGenerator } from './subscription.js';
35
39
  import { GraphQLBigInt } from './types/bigint.js';
36
40
  import { GraphQLDate } from './types/date.js';
@@ -40,9 +44,6 @@ import { GraphQLStringOrFloat } from './types/string-or-float.js';
40
44
  import { GraphQLVoid } from './types/void.js';
41
45
  import { addPathToValidationError } from './utils/add-path-to-validation-error.js';
42
46
  import processError from './utils/process-error.js';
43
- import { isSystemCollection } from '@directus/system-data';
44
- import isDirectusJWT from '../../utils/is-directus-jwt.js';
45
- import { verifyAccessJWT } from '../../utils/jwt.js';
46
47
  const env = useEnv();
47
48
  const validationRules = Array.from(specifiedRules);
48
49
  if (env['GRAPHQL_INTROSPECTION'] === false) {
@@ -104,6 +105,10 @@ export class GraphQLService {
104
105
  return formattedResult;
105
106
  }
106
107
  getSchema(type = 'schema') {
108
+ const key = `${type}_${this.accountability?.role}_${this.accountability?.user}`;
109
+ const cachedSchema = cache.get(key);
110
+ if (cachedSchema)
111
+ return cachedSchema;
107
112
  // eslint-disable-next-line @typescript-eslint/no-this-alias
108
113
  const self = this;
109
114
  const schemaComposer = new SchemaComposer();
@@ -218,9 +223,13 @@ export class GraphQLService {
218
223
  }, {}));
219
224
  }
220
225
  if (type === 'sdl') {
221
- return schemaComposer.toSDL();
226
+ const sdl = schemaComposer.toSDL();
227
+ cache.set(key, sdl);
228
+ return sdl;
222
229
  }
223
- return schemaComposer.buildSchema();
230
+ const gqlSchema = schemaComposer.buildSchema();
231
+ cache.set(key, gqlSchema);
232
+ return gqlSchema;
224
233
  /**
225
234
  * Construct an object of types for every collection, using the permitted fields per action type
226
235
  * as it's fields.
@@ -0,0 +1,3 @@
1
+ import { GraphQLSchema } from 'graphql';
2
+ import LRUMapDefault from 'mnemonist/lru-map.js';
3
+ export declare const cache: LRUMapDefault.default<string, string | GraphQLSchema>;
@@ -0,0 +1,12 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { GraphQLSchema } from 'graphql';
3
+ import LRUMapDefault from 'mnemonist/lru-map.js';
4
+ import { useBus } from '../../bus/index.js';
5
+ // Workaround for misaligned types in mnemonist package exports
6
+ const LRUMap = LRUMapDefault;
7
+ const env = useEnv();
8
+ const bus = useBus();
9
+ export const cache = new LRUMap(Number(env['GRAPHQL_SCHEMA_CACHE_CAPACITY'] ?? 100));
10
+ bus.subscribe('schemaChanged', () => {
11
+ cache.clear();
12
+ });
@@ -14,7 +14,7 @@ export declare class MailService {
14
14
  knex: Knex;
15
15
  mailer: Transporter;
16
16
  constructor(opts: AbstractServiceOptions);
17
- send(options: EmailOptions): Promise<void>;
17
+ send<T>(options: EmailOptions): Promise<T>;
18
18
  private renderTemplate;
19
19
  private getDefaultTemplateData;
20
20
  }
@@ -54,10 +54,8 @@ export class MailService {
54
54
  .map((line) => line.trim())
55
55
  .join('\n');
56
56
  }
57
- this.mailer.sendMail({ ...emailOptions, from, html }).catch((error) => {
58
- logger.warn(`Email send failed:`);
59
- logger.warn(error);
60
- });
57
+ const info = await this.mailer.sendMail({ ...emailOptions, from, html });
58
+ return info;
61
59
  }
62
60
  async renderTemplate(template, variables) {
63
61
  const customTemplatePath = path.resolve(env['EMAIL_TEMPLATES_PATH'], template + '.liquid');
@@ -37,19 +37,18 @@ export class NotificationsService extends ItemsService {
37
37
  .toString();
38
38
  const html = data.message ? md(data.message) : '';
39
39
  if (user['email'] && user['email_notifications'] === true) {
40
- try {
41
- await this.mailService.send({
42
- template: {
43
- name: 'base',
44
- data: user['role']?.app_access ? { url: manageUserAccountUrl, html } : { html },
45
- },
46
- to: user['email'],
47
- subject: data.subject,
48
- });
49
- }
50
- catch (error) {
51
- logger.error(error.message);
52
- }
40
+ this.mailService
41
+ .send({
42
+ template: {
43
+ name: 'base',
44
+ data: user['role']?.app_access ? { url: manageUserAccountUrl, html } : { html },
45
+ },
46
+ to: user['email'],
47
+ subject: data.subject,
48
+ })
49
+ .catch((error) => {
50
+ logger.error(error, `Could not send notification via mail`);
51
+ });
53
52
  }
54
53
  }
55
54
  }
@@ -10,7 +10,9 @@ import { AuthorizationService } from './authorization.js';
10
10
  import { ItemsService } from './items.js';
11
11
  import { MailService } from './mail/index.js';
12
12
  import { UsersService } from './users.js';
13
+ import { useLogger } from '../logger.js';
13
14
  const env = useEnv();
15
+ const logger = useLogger();
14
16
  export class SharesService extends ItemsService {
15
17
  authorizationService;
16
18
  constructor(options) {
@@ -119,7 +121,8 @@ ${userName(userInfo)} has invited you to view an item in ${share['collection']}.
119
121
  [Open](${new Url(env['PUBLIC_URL']).addPath('admin', 'shared', payload.share).toString()})
120
122
  `;
121
123
  for (const email of payload.emails) {
122
- await mailService.send({
124
+ mailService
125
+ .send({
123
126
  template: {
124
127
  name: 'base',
125
128
  data: {
@@ -128,6 +131,9 @@ ${userName(userInfo)} has invited you to view an item in ${share['collection']}.
128
131
  },
129
132
  to: email,
130
133
  subject: `${userName(userInfo)} has shared an item with you`,
134
+ })
135
+ .catch((error) => {
136
+ logger.error(error, `Could not send share notification mail`);
131
137
  });
132
138
  }
133
139
  }
@@ -14,7 +14,9 @@ import { Url } from '../utils/url.js';
14
14
  import { ItemsService } from './items.js';
15
15
  import { MailService } from './mail/index.js';
16
16
  import { SettingsService } from './settings.js';
17
+ import { useLogger } from '../logger.js';
17
18
  const env = useEnv();
19
+ const logger = useLogger();
18
20
  export class UsersService extends ItemsService {
19
21
  constructor(options) {
20
22
  super('directus_users', options);
@@ -335,7 +337,8 @@ export class UsersService extends ItemsService {
335
337
  // Send invite for new and already invited users
336
338
  if (isEmpty(user) || user.status === 'invited') {
337
339
  const subjectLine = subject ?? "You've been invited";
338
- await mailService.send({
340
+ mailService
341
+ .send({
339
342
  to: user?.email ?? email,
340
343
  subject: subjectLine,
341
344
  template: {
@@ -345,6 +348,9 @@ export class UsersService extends ItemsService {
345
348
  email: user?.email ?? email,
346
349
  },
347
350
  },
351
+ })
352
+ .catch((error) => {
353
+ logger.error(error, `Could not send user invitation mail`);
348
354
  });
349
355
  }
350
356
  }
@@ -386,7 +392,8 @@ export class UsersService extends ItemsService {
386
392
  ? new Url(url).setQuery('token', token).toString()
387
393
  : new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password').setQuery('token', token).toString();
388
394
  const subjectLine = subject ? subject : 'Password Reset Request';
389
- await mailService.send({
395
+ mailService
396
+ .send({
390
397
  to: user.email,
391
398
  subject: subjectLine,
392
399
  template: {
@@ -396,6 +403,9 @@ export class UsersService extends ItemsService {
396
403
  email: user.email,
397
404
  },
398
405
  },
406
+ })
407
+ .catch((error) => {
408
+ logger.error(error, `Could not send password reset mail`);
399
409
  });
400
410
  await stall(STALL_TIME, timeStart);
401
411
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directus/api",
3
- "version": "18.1.0",
3
+ "version": "18.2.0",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "keywords": [
6
6
  "directus",
@@ -59,7 +59,7 @@
59
59
  ],
60
60
  "dependencies": {
61
61
  "@authenio/samlify-node-xmllint": "2.0.0",
62
- "@aws-sdk/client-ses": "3.525.0",
62
+ "@aws-sdk/client-ses": "3.529.0",
63
63
  "@directus/format-title": "10.1.0",
64
64
  "@godaddy/terminus": "4.12.1",
65
65
  "@rollup/plugin-alias": "5.1.0",
@@ -113,6 +113,7 @@
113
113
  "micromustache": "8.0.3",
114
114
  "mime-types": "2.1.35",
115
115
  "minimatch": "9.0.3",
116
+ "mnemonist": "0.39.8",
116
117
  "ms": "2.1.3",
117
118
  "nanoid": "5.0.6",
118
119
  "node-machine-id": "1.1.12",
@@ -120,7 +121,7 @@
120
121
  "nodemailer": "6.9.11",
121
122
  "object-hash": "3.0.0",
122
123
  "openapi3-ts": "4.2.2",
123
- "openid-client": "5.6.4",
124
+ "openid-client": "5.6.5",
124
125
  "ora": "8.0.1",
125
126
  "otplib": "12.0.1",
126
127
  "p-limit": "5.0.0",
@@ -130,7 +131,7 @@
130
131
  "pino-http": "9.0.0",
131
132
  "pino-http-print": "3.1.0",
132
133
  "pino-pretty": "10.3.1",
133
- "qs": "6.11.2",
134
+ "qs": "6.12.0",
134
135
  "rate-limiter-flexible": "5.0.0",
135
136
  "rollup": "4.12.0",
136
137
  "samlify": "2.8.11",
@@ -144,28 +145,28 @@
144
145
  "ws": "8.16.0",
145
146
  "zod": "3.22.4",
146
147
  "zod-validation-error": "3.0.3",
147
- "@directus/app": "11.0.1",
148
- "@directus/errors": "0.2.4",
149
- "@directus/env": "1.0.4",
148
+ "@directus/app": "11.0.3",
149
+ "@directus/env": "1.1.0",
150
150
  "@directus/constants": "11.0.3",
151
- "@directus/extensions": "1.0.1",
152
- "@directus/memory": "1.0.4",
151
+ "@directus/errors": "0.2.4",
153
152
  "@directus/extensions-registry": "1.0.1",
153
+ "@directus/extensions": "1.0.1",
154
154
  "@directus/extensions-sdk": "11.0.1",
155
- "@directus/schema": "11.0.1",
155
+ "@directus/memory": "1.0.5",
156
156
  "@directus/pressure": "1.0.17",
157
- "@directus/storage": "10.0.11",
158
157
  "@directus/specs": "10.2.7",
158
+ "@directus/schema": "11.0.1",
159
+ "@directus/storage": "10.0.11",
159
160
  "@directus/storage-driver-azure": "10.0.18",
160
161
  "@directus/storage-driver-cloudinary": "10.0.18",
161
162
  "@directus/storage-driver-gcs": "10.0.18",
162
163
  "@directus/storage-driver-s3": "10.0.19",
163
164
  "@directus/storage-driver-local": "10.0.18",
165
+ "@directus/storage-driver-supabase": "1.0.10",
164
166
  "@directus/system-data": "1.0.1",
165
167
  "@directus/utils": "11.0.6",
166
- "@directus/storage-driver-supabase": "1.0.10",
167
- "@directus/validation": "0.0.13",
168
- "directus": "10.10.1"
168
+ "directus": "10.10.3",
169
+ "@directus/validation": "0.0.13"
169
170
  },
170
171
  "devDependencies": {
171
172
  "@ngneat/falso": "7.2.0",
@@ -227,7 +228,7 @@
227
228
  "scripts": {
228
229
  "build": "tsc --project tsconfig.prod.json && copyfiles \"src/**/*.{yaml,liquid}\" -u 1 dist",
229
230
  "cli": "NODE_ENV=development SERVE_APP=false tsx src/cli/run.ts",
230
- "dev": "NODE_ENV=development SERVE_APP=true tsx watch --clear-screen=false src/start.ts",
231
+ "dev": "NODE_ENV=development SERVE_APP=true tsx watch --ignore extensions --clear-screen=false src/start.ts",
231
232
  "test": "vitest --watch=false"
232
233
  }
233
234
  }
@@ -1 +0,0 @@
1
- export declare function sanitizeError<T extends Error>(error: T): T;
@@ -1,7 +0,0 @@
1
- export function sanitizeError(error) {
2
- // clear the stack
3
- if (error.stack !== undefined) {
4
- delete error.stack;
5
- }
6
- return error;
7
- }