@directus/api 30.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 (197) hide show
  1. package/dist/app.js +7 -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 +28 -11
  8. package/dist/auth/drivers/openid.d.ts +0 -2
  9. package/dist/auth/drivers/openid.js +28 -11
  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/mcp.d.ts +2 -0
  20. package/dist/controllers/mcp.js +33 -0
  21. package/dist/controllers/server.js +26 -1
  22. package/dist/controllers/settings.js +9 -2
  23. package/dist/controllers/users.js +17 -7
  24. package/dist/controllers/versions.js +3 -2
  25. package/dist/database/errors/dialects/mssql.d.ts +1 -1
  26. package/dist/database/errors/dialects/mssql.js +18 -10
  27. package/dist/database/helpers/fn/types.js +3 -3
  28. package/dist/database/helpers/schema/dialects/cockroachdb.d.ts +2 -1
  29. package/dist/database/helpers/schema/dialects/cockroachdb.js +13 -0
  30. package/dist/database/helpers/schema/dialects/mssql.d.ts +2 -1
  31. package/dist/database/helpers/schema/dialects/mssql.js +23 -0
  32. package/dist/database/helpers/schema/dialects/mysql.d.ts +2 -1
  33. package/dist/database/helpers/schema/dialects/mysql.js +25 -0
  34. package/dist/database/helpers/schema/dialects/oracle.d.ts +2 -1
  35. package/dist/database/helpers/schema/dialects/oracle.js +13 -0
  36. package/dist/database/helpers/schema/dialects/postgres.d.ts +2 -1
  37. package/dist/database/helpers/schema/dialects/postgres.js +13 -0
  38. package/dist/database/helpers/schema/types.d.ts +5 -0
  39. package/dist/database/helpers/schema/types.js +6 -0
  40. package/dist/database/migrations/20250813A-add-mcp.d.ts +3 -0
  41. package/dist/database/migrations/20250813A-add-mcp.js +18 -0
  42. package/dist/database/migrations/20251012A-add-field-searchable.d.ts +3 -0
  43. package/dist/database/migrations/20251012A-add-field-searchable.js +10 -0
  44. package/dist/database/migrations/20251014A-add-project-owner.d.ts +3 -0
  45. package/dist/database/migrations/20251014A-add-project-owner.js +37 -0
  46. package/dist/database/migrations/20251028A-add-retention-indexes.d.ts +3 -0
  47. package/dist/database/migrations/20251028A-add-retention-indexes.js +42 -0
  48. package/dist/database/run-ast/README.md +46 -0
  49. package/dist/database/run-ast/lib/apply-query/add-join.js +2 -2
  50. package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.d.ts +2 -2
  51. package/dist/database/run-ast/lib/apply-query/index.d.ts +0 -1
  52. package/dist/database/run-ast/lib/apply-query/index.js +4 -6
  53. package/dist/database/run-ast/lib/apply-query/search.js +2 -0
  54. package/dist/database/run-ast/lib/get-db-query.js +7 -6
  55. package/dist/database/run-ast/utils/generate-alias.d.ts +6 -0
  56. package/dist/database/run-ast/utils/generate-alias.js +57 -0
  57. package/dist/flows.js +1 -0
  58. package/dist/mcp/define.d.ts +2 -0
  59. package/dist/mcp/define.js +3 -0
  60. package/dist/mcp/index.d.ts +1 -0
  61. package/dist/mcp/index.js +1 -0
  62. package/dist/mcp/schema.d.ts +485 -0
  63. package/dist/mcp/schema.js +219 -0
  64. package/dist/mcp/server.d.ts +103 -0
  65. package/dist/mcp/server.js +310 -0
  66. package/dist/mcp/tools/assets.d.ts +3 -0
  67. package/dist/mcp/tools/assets.js +54 -0
  68. package/dist/mcp/tools/collections.d.ts +84 -0
  69. package/dist/mcp/tools/collections.js +90 -0
  70. package/dist/mcp/tools/fields.d.ts +101 -0
  71. package/dist/mcp/tools/fields.js +157 -0
  72. package/dist/mcp/tools/files.d.ts +235 -0
  73. package/dist/mcp/tools/files.js +103 -0
  74. package/dist/mcp/tools/flows.d.ts +323 -0
  75. package/dist/mcp/tools/flows.js +85 -0
  76. package/dist/mcp/tools/folders.d.ts +95 -0
  77. package/dist/mcp/tools/folders.js +96 -0
  78. package/dist/mcp/tools/index.d.ts +15 -0
  79. package/dist/mcp/tools/index.js +29 -0
  80. package/dist/mcp/tools/items.d.ts +87 -0
  81. package/dist/mcp/tools/items.js +141 -0
  82. package/dist/mcp/tools/operations.d.ts +171 -0
  83. package/dist/mcp/tools/operations.js +77 -0
  84. package/dist/mcp/tools/prompts/assets.md +8 -0
  85. package/dist/mcp/tools/prompts/collections.md +336 -0
  86. package/dist/mcp/tools/prompts/fields.md +521 -0
  87. package/dist/mcp/tools/prompts/files.md +180 -0
  88. package/dist/mcp/tools/prompts/flows.md +495 -0
  89. package/dist/mcp/tools/prompts/folders.md +34 -0
  90. package/dist/mcp/tools/prompts/index.d.ts +16 -0
  91. package/dist/mcp/tools/prompts/index.js +19 -0
  92. package/dist/mcp/tools/prompts/items.md +317 -0
  93. package/dist/mcp/tools/prompts/operations.md +721 -0
  94. package/dist/mcp/tools/prompts/relations.md +386 -0
  95. package/dist/mcp/tools/prompts/schema.md +130 -0
  96. package/dist/mcp/tools/prompts/system-prompt-description.md +1 -0
  97. package/dist/mcp/tools/prompts/system-prompt.md +44 -0
  98. package/dist/mcp/tools/prompts/trigger-flow.md +214 -0
  99. package/dist/mcp/tools/relations.d.ts +73 -0
  100. package/dist/mcp/tools/relations.js +93 -0
  101. package/dist/mcp/tools/schema.d.ts +54 -0
  102. package/dist/mcp/tools/schema.js +317 -0
  103. package/dist/mcp/tools/system.d.ts +3 -0
  104. package/dist/mcp/tools/system.js +22 -0
  105. package/dist/mcp/tools/trigger-flow.d.ts +8 -0
  106. package/dist/mcp/tools/trigger-flow.js +48 -0
  107. package/dist/mcp/transport.d.ts +13 -0
  108. package/dist/mcp/transport.js +18 -0
  109. package/dist/mcp/types.d.ts +56 -0
  110. package/dist/mcp/types.js +1 -0
  111. package/dist/metrics/lib/create-metrics.js +16 -25
  112. package/dist/middleware/collection-exists.js +2 -2
  113. package/dist/operations/mail/index.js +3 -1
  114. package/dist/operations/mail/rate-limiter.d.ts +1 -0
  115. package/dist/operations/mail/rate-limiter.js +29 -0
  116. package/dist/permissions/modules/process-payload/process-payload.js +3 -10
  117. package/dist/permissions/modules/validate-access/validate-access.js +2 -3
  118. package/dist/schedules/metrics.js +6 -2
  119. package/dist/schedules/project.d.ts +4 -0
  120. package/dist/schedules/project.js +27 -0
  121. package/dist/services/authentication.js +36 -0
  122. package/dist/services/collections.d.ts +3 -3
  123. package/dist/services/collections.js +16 -1
  124. package/dist/services/fields.d.ts +21 -5
  125. package/dist/services/fields.js +109 -32
  126. package/dist/services/graphql/resolvers/query.js +1 -1
  127. package/dist/services/graphql/resolvers/system-admin.js +49 -5
  128. package/dist/services/graphql/schema/parse-query.js +8 -8
  129. package/dist/services/graphql/utils/aggregate-query.d.ts +1 -1
  130. package/dist/services/graphql/utils/aggregate-query.js +5 -1
  131. package/dist/services/graphql/utils/filter-replace-m2a.js +2 -1
  132. package/dist/services/import-export.d.ts +9 -1
  133. package/dist/services/import-export.js +287 -101
  134. package/dist/services/items.d.ts +1 -1
  135. package/dist/services/items.js +50 -24
  136. package/dist/services/mail/index.js +2 -0
  137. package/dist/services/mail/rate-limiter.d.ts +1 -0
  138. package/dist/services/mail/rate-limiter.js +29 -0
  139. package/dist/services/meta.js +28 -24
  140. package/dist/services/payload.d.ts +7 -3
  141. package/dist/services/payload.js +26 -12
  142. package/dist/services/schema.js +4 -1
  143. package/dist/services/server.d.ts +1 -0
  144. package/dist/services/server.js +15 -18
  145. package/dist/services/settings.d.ts +2 -1
  146. package/dist/services/settings.js +15 -0
  147. package/dist/services/tfa.d.ts +1 -1
  148. package/dist/services/tfa.js +20 -5
  149. package/dist/services/tus/server.js +14 -9
  150. package/dist/services/versions.d.ts +6 -4
  151. package/dist/services/versions.js +84 -25
  152. package/dist/telemetry/lib/get-report.js +4 -4
  153. package/dist/telemetry/lib/send-report.d.ts +6 -1
  154. package/dist/telemetry/lib/send-report.js +3 -1
  155. package/dist/telemetry/types/report.d.ts +17 -1
  156. package/dist/telemetry/utils/get-settings.d.ts +9 -0
  157. package/dist/telemetry/utils/get-settings.js +14 -0
  158. package/dist/test-utils/README.md +760 -0
  159. package/dist/test-utils/cache.d.ts +51 -0
  160. package/dist/test-utils/cache.js +59 -0
  161. package/dist/test-utils/database.d.ts +48 -0
  162. package/dist/test-utils/database.js +52 -0
  163. package/dist/test-utils/emitter.d.ts +35 -0
  164. package/dist/test-utils/emitter.js +38 -0
  165. package/dist/test-utils/fields-service.d.ts +28 -0
  166. package/dist/test-utils/fields-service.js +36 -0
  167. package/dist/test-utils/items-service.d.ts +23 -0
  168. package/dist/test-utils/items-service.js +37 -0
  169. package/dist/test-utils/knex.d.ts +164 -0
  170. package/dist/test-utils/knex.js +268 -0
  171. package/dist/test-utils/schema.d.ts +26 -0
  172. package/dist/test-utils/schema.js +35 -0
  173. package/dist/types/auth.d.ts +2 -3
  174. package/dist/utils/apply-diff.js +15 -0
  175. package/dist/utils/create-admin.d.ts +11 -0
  176. package/dist/utils/create-admin.js +50 -0
  177. package/dist/utils/get-schema.js +5 -3
  178. package/dist/utils/get-snapshot-diff.js +49 -5
  179. package/dist/utils/get-snapshot.js +13 -7
  180. package/dist/utils/sanitize-schema.d.ts +11 -4
  181. package/dist/utils/sanitize-schema.js +9 -6
  182. package/dist/utils/schedule.js +15 -19
  183. package/dist/utils/validate-diff.js +31 -0
  184. package/dist/utils/validate-snapshot.js +7 -0
  185. package/dist/utils/versioning/deep-map-with-schema.d.ts +23 -0
  186. package/dist/utils/versioning/deep-map-with-schema.js +81 -0
  187. package/dist/utils/versioning/handle-version.d.ts +2 -2
  188. package/dist/utils/versioning/handle-version.js +47 -43
  189. package/dist/utils/versioning/split-recursive.d.ts +4 -0
  190. package/dist/utils/versioning/split-recursive.js +27 -0
  191. package/dist/websocket/controllers/hooks.js +12 -20
  192. package/dist/websocket/messages.d.ts +3 -3
  193. package/package.json +65 -66
  194. package/dist/cli/utils/defaults.d.ts +0 -4
  195. package/dist/cli/utils/defaults.js +0 -17
  196. package/dist/telemetry/utils/get-project-id.d.ts +0 -2
  197. package/dist/telemetry/utils/get-project-id.js +0 -4
@@ -1,4 +1,3 @@
1
- import { ForbiddenError } from '@directus/errors';
2
1
  import { parseFilter, validatePayload } from '@directus/utils';
3
2
  import { FailedValidationError, joiValidationErrorItemToErrorExtensions } from '@directus/validation';
4
3
  import { assign, difference, uniq } from 'lodash-es';
@@ -8,6 +7,7 @@ import { extractRequiredDynamicVariableContext } from '../../utils/extract-requi
8
7
  import { fetchDynamicVariableData } from '../../utils/fetch-dynamic-variable-data.js';
9
8
  import { contextHasDynamicVariables } from '../process-ast/utils/context-has-dynamic-variables.js';
10
9
  import { isFieldNullable } from './lib/is-field-nullable.js';
10
+ import { createCollectionForbiddenError, createFieldsForbiddenError, } from '../process-ast/utils/validate-path/create-error.js';
11
11
  /**
12
12
  * @note this only validates the top-level fields. The expectation is that this function is called
13
13
  * for each level of nested insert separately
@@ -20,21 +20,14 @@ export async function processPayload(options, context) {
20
20
  policies = await fetchPolicies(options.accountability, context);
21
21
  permissions = await fetchPermissions({ action: options.action, policies, collections: [options.collection], accountability: options.accountability }, context);
22
22
  if (permissions.length === 0) {
23
- throw new ForbiddenError({
24
- reason: `You don't have permission to "${options.action}" from collection "${options.collection}" or it does not exist.`,
25
- });
23
+ throw createCollectionForbiddenError('', options.collection);
26
24
  }
27
25
  const fieldsAllowed = uniq(permissions.map(({ fields }) => fields ?? []).flat());
28
26
  if (fieldsAllowed.includes('*') === false) {
29
27
  const fieldsUsed = Object.keys(options.payload);
30
28
  const notAllowed = difference(fieldsUsed, fieldsAllowed);
31
29
  if (notAllowed.length > 0) {
32
- const fieldStr = notAllowed.map((field) => `"${field}"`).join(', ');
33
- throw new ForbiddenError({
34
- reason: notAllowed.length === 1
35
- ? `You don't have permission to access field ${fieldStr} in collection "${options.collection}" or it does not exist.`
36
- : `You don't have permission to access fields ${fieldStr} in collection "${options.collection}" or they do not exist.`,
37
- });
30
+ throw createFieldsForbiddenError('', options.collection, notAllowed);
38
31
  }
39
32
  }
40
33
  permissionValidationRules = permissions.map(({ validation }) => validation);
@@ -1,6 +1,7 @@
1
1
  import { ForbiddenError } from '@directus/errors';
2
2
  import { validateCollectionAccess } from './lib/validate-collection-access.js';
3
3
  import { validateItemAccess } from './lib/validate-item-access.js';
4
+ import { createCollectionForbiddenError } from '../process-ast/utils/validate-path/create-error.js';
4
5
  /**
5
6
  * Validate if the current user has access to perform action against the given collection and
6
7
  * optional primary keys. This is done by reading the item from the database using the access
@@ -9,9 +10,7 @@ import { validateItemAccess } from './lib/validate-item-access.js';
9
10
  export async function validateAccess(options, context) {
10
11
  // Skip further validation if the collection does not exist
11
12
  if (!options.skipCollectionExistsCheck && options.collection in context.schema.collections === false) {
12
- throw new ForbiddenError({
13
- reason: `You don't have permission to "${options.action}" from collection "${options.collection}" or it does not exist.`,
14
- });
13
+ throw createCollectionForbiddenError('', options.collection);
15
14
  }
16
15
  if (options.accountability.admin === true) {
17
16
  return;
@@ -1,6 +1,6 @@
1
1
  import { useEnv } from '@directus/env';
2
2
  import { toBoolean } from '@directus/utils';
3
- import { scheduleJob } from 'node-schedule';
3
+ import { CronJob } from 'cron';
4
4
  import { useLogger } from '../logger/index.js';
5
5
  import { useMetrics } from '../metrics/index.js';
6
6
  import { validateCron } from '../utils/schedule.js';
@@ -39,6 +39,10 @@ export default async function schedule() {
39
39
  if (!validateCron(String(env['METRICS_SCHEDULE']))) {
40
40
  return false;
41
41
  }
42
- scheduleJob('metrics', String(env['METRICS_SCHEDULE']), handleMetricsJob);
42
+ CronJob.from({
43
+ cronTime: String(env['METRICS_SCHEDULE']),
44
+ onTick: handleMetricsJob,
45
+ start: true,
46
+ });
43
47
  return true;
44
48
  }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Schedule the project status job
3
+ */
4
+ export default function schedule(): Promise<void>;
@@ -0,0 +1,27 @@
1
+ import { random } from 'lodash-es';
2
+ import getDatabase from '../database/index.js';
3
+ import { sendReport } from '../telemetry/index.js';
4
+ import { scheduleSynchronizedJob } from '../utils/schedule.js';
5
+ import { version } from 'directus/version';
6
+ /**
7
+ * Schedule the project status job
8
+ */
9
+ export default async function schedule() {
10
+ const db = getDatabase();
11
+ // Schedules a job at a random time of the day to avoid overloading the telemetry server
12
+ scheduleSynchronizedJob('project-status', `${random(59)} ${random(23)} * * *`, async () => {
13
+ const { project_status, ...ownerInfo } = await db
14
+ .select('project_status', 'project_owner', 'project_usage', 'org_name', 'product_updates', 'project_id')
15
+ .from('directus_settings')
16
+ .first();
17
+ if (project_status !== 'pending')
18
+ return;
19
+ try {
20
+ await sendReport({ version, ...ownerInfo });
21
+ await db.update('project_status', '').from('directus_settings');
22
+ }
23
+ catch {
24
+ // Empty catch
25
+ }
26
+ });
27
+ }
@@ -15,6 +15,7 @@ import { getMilliseconds } from '../utils/get-milliseconds.js';
15
15
  import { getSecret } from '../utils/get-secret.js';
16
16
  import { stall } from '../utils/stall.js';
17
17
  import { ActivityService } from './activity.js';
18
+ import { RevisionsService } from './revisions.js';
18
19
  import { SettingsService } from './settings.js';
19
20
  import { TFAService } from './tfa.js';
20
21
  const env = useEnv();
@@ -96,6 +97,25 @@ export class AuthenticationService {
96
97
  if (error instanceof RateLimiterRes && error.remainingPoints === 0) {
97
98
  await this.knex('directus_users').update({ status: 'suspended' }).where({ id: user.id });
98
99
  user.status = 'suspended';
100
+ if (this.accountability) {
101
+ const activity = await this.activityService.createOne({
102
+ action: Action.UPDATE,
103
+ user: user.id,
104
+ ip: this.accountability.ip,
105
+ user_agent: this.accountability.userAgent,
106
+ origin: this.accountability.origin,
107
+ collection: 'directus_users',
108
+ item: user.id,
109
+ });
110
+ const revisionsService = new RevisionsService({ knex: this.knex, schema: this.schema });
111
+ await revisionsService.createOne({
112
+ activity: activity,
113
+ collection: 'directus_users',
114
+ item: user.id,
115
+ data: user,
116
+ delta: { status: 'suspended' },
117
+ });
118
+ }
99
119
  // This means that new attempts after the user has been re-activated will be accepted
100
120
  await loginAttemptsLimiter.set(user.id, 0, 0);
101
121
  }
@@ -137,6 +157,22 @@ export class AuthenticationService {
137
157
  app_access: globalAccess.app,
138
158
  admin_access: globalAccess.admin,
139
159
  };
160
+ // Add role-based enforcement to token payload for users who need to set up 2FA
161
+ if (!user.tfa_secret) {
162
+ // Check if user has role-based enforcement
163
+ const roleEnforcement = await this.knex
164
+ .select('directus_policies.enforce_tfa')
165
+ .from('directus_users')
166
+ .leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
167
+ .leftJoin('directus_access', 'directus_roles.id', 'directus_access.role')
168
+ .leftJoin('directus_policies', 'directus_access.policy', 'directus_policies.id')
169
+ .where('directus_users.id', user.id)
170
+ .where('directus_policies.enforce_tfa', true)
171
+ .first();
172
+ if (roleEnforcement) {
173
+ tokenPayload.enforce_tfa = true;
174
+ }
175
+ }
140
176
  const refreshToken = nanoid(64);
141
177
  const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
142
178
  if (options?.session) {
@@ -1,5 +1,5 @@
1
1
  import type { SchemaInspector } from '@directus/schema';
2
- import type { AbstractServiceOptions, Accountability, MutationOptions, SchemaOverview, RawCollection } from '@directus/types';
2
+ import type { AbstractServiceOptions, Accountability, FieldMutationOptions, MutationOptions, RawCollection, SchemaOverview } from '@directus/types';
3
3
  import type Keyv from 'keyv';
4
4
  import type { Knex } from 'knex';
5
5
  import type { Helpers } from '../database/helpers/index.js';
@@ -16,11 +16,11 @@ export declare class CollectionsService {
16
16
  /**
17
17
  * Create a single new collection
18
18
  */
19
- createOne(payload: RawCollection, opts?: MutationOptions): Promise<string>;
19
+ createOne(payload: RawCollection, opts?: FieldMutationOptions): Promise<string>;
20
20
  /**
21
21
  * Create multiple new collections
22
22
  */
23
- createMany(payloads: RawCollection[], opts?: MutationOptions): Promise<string[]>;
23
+ createMany(payloads: RawCollection[], opts?: FieldMutationOptions): Promise<string[]>;
24
24
  /**
25
25
  * Read all collections. Currently doesn't support any query.
26
26
  */
@@ -62,6 +62,7 @@ export class CollectionsService {
62
62
  if (existingCollections.includes(payload.collection)) {
63
63
  throw new InvalidPayloadError({ reason: `Collection "${payload.collection}" already exists` });
64
64
  }
65
+ const attemptConcurrentIndex = Boolean(opts?.attemptConcurrentIndex);
65
66
  // Create the collection/fields in a transaction so it'll be reverted in case of errors or
66
67
  // permission problems. This might not work reliably in MySQL, as it doesn't support DDL in
67
68
  // transactions.
@@ -114,7 +115,9 @@ export class CollectionsService {
114
115
  await trx.schema.createTable(payload.collection, (table) => {
115
116
  for (const field of payload.fields) {
116
117
  if (field.type && ALIAS_TYPES.includes(field.type) === false) {
117
- fieldsService.addColumnToTable(table, payload.collection, field);
118
+ fieldsService.addColumnToTable(table, payload.collection, field, {
119
+ attemptConcurrentIndex,
120
+ });
118
121
  }
119
122
  }
120
123
  });
@@ -159,6 +162,17 @@ export class CollectionsService {
159
162
  }
160
163
  return payload.collection;
161
164
  });
165
+ // concurrent index creation cannot be done inside the transaction
166
+ if (attemptConcurrentIndex && payload.schema && Array.isArray(payload.fields)) {
167
+ const fieldsService = new FieldsService({ schema: this.schema });
168
+ for (const field of payload.fields) {
169
+ if (field.type && ALIAS_TYPES.includes(field.type) === false) {
170
+ await fieldsService.addColumnIndex(payload.collection, field, {
171
+ attemptConcurrentIndex,
172
+ });
173
+ }
174
+ }
175
+ }
162
176
  return payload.collection;
163
177
  }
164
178
  finally {
@@ -195,6 +209,7 @@ export class CollectionsService {
195
209
  autoPurgeCache: false,
196
210
  autoPurgeSystemCache: false,
197
211
  bypassEmitAction: (params) => nestedActionEvents.push(params),
212
+ attemptConcurrentIndex: Boolean(opts?.attemptConcurrentIndex),
198
213
  });
199
214
  collectionNames.push(name);
200
215
  }
@@ -1,10 +1,18 @@
1
1
  import type { Column, SchemaInspector } from '@directus/schema';
2
- import type { AbstractServiceOptions, Accountability, Field, MutationOptions, RawField, SchemaOverview, Type } from '@directus/types';
2
+ import type { AbstractServiceOptions, Accountability, Field, FieldMutationOptions, MutationOptions, RawField, SchemaOverview, Type } from '@directus/types';
3
3
  import type Keyv from 'keyv';
4
4
  import type { Knex } from 'knex';
5
+ import { z } from 'zod';
5
6
  import type { Helpers } from '../database/helpers/index.js';
6
7
  import { ItemsService } from './items.js';
7
8
  import { PayloadService } from './payload.js';
9
+ export declare const systemFieldUpdateSchema: z.ZodObject<{
10
+ collection: z.ZodOptional<z.ZodString>;
11
+ field: z.ZodOptional<z.ZodString>;
12
+ schema: z.ZodObject<{
13
+ is_indexed: z.ZodOptional<z.ZodBoolean>;
14
+ }, z.core.$strict>;
15
+ }, z.core.$strict>;
8
16
  export declare class FieldsService {
9
17
  knex: Knex;
10
18
  helpers: Helpers;
@@ -25,9 +33,17 @@ export declare class FieldsService {
25
33
  field: string;
26
34
  type: Type | null;
27
35
  }, table?: Knex.CreateTableBuilder, // allows collection creation to
28
- opts?: MutationOptions): Promise<void>;
29
- updateField(collection: string, field: RawField, opts?: MutationOptions): Promise<string>;
30
- updateFields(collection: string, fields: RawField[], opts?: MutationOptions): Promise<string[]>;
36
+ opts?: FieldMutationOptions): Promise<void>;
37
+ updateField(collection: string, field: RawField, opts?: FieldMutationOptions): Promise<string>;
38
+ updateFields(collection: string, fields: RawField[], opts?: FieldMutationOptions): Promise<string[]>;
31
39
  deleteField(collection: string, field: string, opts?: MutationOptions): Promise<void>;
32
- addColumnToTable(table: Knex.CreateTableBuilder, collection: string, field: RawField | Field, existing?: Column | null): void;
40
+ addColumnToTable(table: Knex.CreateTableBuilder, collection: string, field: RawField | Field, options?: {
41
+ attemptConcurrentIndex?: boolean;
42
+ existing?: Column | null;
43
+ }): void;
44
+ addColumnIndex(collection: string, field: Field | RawField, options?: {
45
+ attemptConcurrentIndex?: boolean;
46
+ knex?: Knex;
47
+ existing?: Column | null;
48
+ }): Promise<void>;
33
49
  }
@@ -2,8 +2,10 @@ import { DEFAULT_NUMERIC_PRECISION, DEFAULT_NUMERIC_SCALE, KNEX_TYPES, REGEX_BET
2
2
  import { useEnv } from '@directus/env';
3
3
  import { ForbiddenError, InvalidPayloadError } from '@directus/errors';
4
4
  import { createInspector } from '@directus/schema';
5
+ import { isSystemField } from '@directus/system-data';
5
6
  import { addFieldFlag, getRelations, toArray } from '@directus/utils';
6
7
  import { isEqual, isNil, merge } from 'lodash-es';
8
+ import { z } from 'zod';
7
9
  import { clearSystemCache, getCache, getCacheValue, setCacheValue } from '../cache.js';
8
10
  import { ALIAS_TYPES, ALLOWED_DB_DEFAULT_FUNCTIONS } from '../constants.js';
9
11
  import { translateDatabaseError } from '../database/errors/translate.js';
@@ -28,6 +30,17 @@ import { PayloadService } from './payload.js';
28
30
  import { RelationsService } from './relations.js';
29
31
  const systemFieldRows = getSystemFieldRowsWithAuthProviders();
30
32
  const env = useEnv();
33
+ export const systemFieldUpdateSchema = z
34
+ .object({
35
+ collection: z.string().optional(),
36
+ field: z.string().optional(),
37
+ schema: z
38
+ .object({
39
+ is_indexed: z.boolean().optional(),
40
+ })
41
+ .strict(),
42
+ })
43
+ .strict();
31
44
  export class FieldsService {
32
45
  knex;
33
46
  helpers;
@@ -89,14 +102,14 @@ export class FieldsService {
89
102
  schema: this.schema,
90
103
  });
91
104
  if (collection) {
92
- fields = (await nonAuthorizedItemsService.readByQuery({
105
+ fields = await nonAuthorizedItemsService.readByQuery({
93
106
  filter: { collection: { _eq: collection } },
94
107
  limit: -1,
95
- }));
108
+ });
96
109
  fields.push(...systemFieldRows.filter((fieldMeta) => fieldMeta.collection === collection));
97
110
  }
98
111
  else {
99
- fields = (await nonAuthorizedItemsService.readByQuery({ limit: -1 }));
112
+ fields = await nonAuthorizedItemsService.readByQuery({ limit: -1 });
100
113
  fields.push(...systemFieldRows);
101
114
  }
102
115
  const columns = (await this.columnInfo(collection)).map((column) => ({
@@ -269,28 +282,35 @@ export class FieldsService {
269
282
  if (flagToAdd) {
270
283
  addFieldFlag(field, flagToAdd);
271
284
  }
285
+ let hookAdjustedField = field;
286
+ const attemptConcurrentIndex = Boolean(opts?.attemptConcurrentIndex);
272
287
  await transaction(this.knex, async (trx) => {
273
288
  const itemsService = new ItemsService('directus_fields', {
274
289
  knex: trx,
275
290
  accountability: this.accountability,
276
291
  schema: this.schema,
277
292
  });
278
- const hookAdjustedField = opts?.emitEvents !== false
279
- ? await emitter.emitFilter(`fields.create`, field, {
280
- collection: collection,
281
- }, {
282
- database: trx,
283
- schema: this.schema,
284
- accountability: this.accountability,
285
- })
286
- : field;
293
+ hookAdjustedField =
294
+ opts?.emitEvents !== false
295
+ ? await emitter.emitFilter(`fields.create`, field, {
296
+ collection: collection,
297
+ }, {
298
+ database: trx,
299
+ schema: this.schema,
300
+ accountability: this.accountability,
301
+ })
302
+ : field;
287
303
  if (hookAdjustedField.type && ALIAS_TYPES.includes(hookAdjustedField.type) === false) {
288
304
  if (table) {
289
- this.addColumnToTable(table, collection, hookAdjustedField);
305
+ this.addColumnToTable(table, collection, hookAdjustedField, {
306
+ attemptConcurrentIndex,
307
+ });
290
308
  }
291
309
  else {
292
310
  await trx.schema.alterTable(collection, (table) => {
293
- this.addColumnToTable(table, collection, hookAdjustedField);
311
+ this.addColumnToTable(table, collection, hookAdjustedField, {
312
+ attemptConcurrentIndex,
313
+ });
294
314
  });
295
315
  }
296
316
  }
@@ -327,6 +347,12 @@ export class FieldsService {
327
347
  nestedActionEvents.push(actionEvent);
328
348
  }
329
349
  });
350
+ // concurrent index creation cannot be done inside the transaction
351
+ if (attemptConcurrentIndex && hookAdjustedField.type && ALIAS_TYPES.includes(hookAdjustedField.type) === false) {
352
+ await this.addColumnIndex(collection, hookAdjustedField, {
353
+ attemptConcurrentIndex,
354
+ });
355
+ }
330
356
  }
331
357
  finally {
332
358
  if (runPostColumnChange) {
@@ -339,7 +365,7 @@ export class FieldsService {
339
365
  await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
340
366
  }
341
367
  if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
342
- const updatedSchema = await getSchema();
368
+ const updatedSchema = await getSchema({ database: this.knex });
343
369
  for (const nestedActionEvent of nestedActionEvents) {
344
370
  nestedActionEvent.context.schema = updatedSchema;
345
371
  emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
@@ -390,20 +416,32 @@ export class FieldsService {
390
416
  const columnToCompare = opts?.bypassLimits && opts.autoPurgeSystemCache === false ? sanitizeColumn(existingColumn) : existingColumn;
391
417
  if (!isEqual(columnToCompare, hookAdjustedField.schema)) {
392
418
  try {
419
+ const attemptConcurrentIndex = Boolean(opts?.attemptConcurrentIndex);
393
420
  await transaction(this.knex, async (trx) => {
394
- await trx.schema.alterTable(collection, async (table) => {
421
+ await trx.schema.alterTable(collection, (table) => {
395
422
  if (!hookAdjustedField.schema)
396
423
  return;
397
- this.addColumnToTable(table, collection, field, existingColumn);
424
+ this.addColumnToTable(table, collection, field, {
425
+ existing: existingColumn,
426
+ attemptConcurrentIndex,
427
+ });
398
428
  });
399
429
  });
430
+ // concurrent index creation cannot be done inside the transaction
431
+ if (attemptConcurrentIndex) {
432
+ await this.addColumnIndex(collection, field, {
433
+ existing: existingColumn,
434
+ attemptConcurrentIndex,
435
+ });
436
+ }
400
437
  }
401
438
  catch (err) {
402
439
  throw await translateDatabaseError(err, field);
403
440
  }
404
441
  }
405
442
  }
406
- if (hookAdjustedField.meta) {
443
+ // Only create/update a database record if this is not a system field
444
+ if (hookAdjustedField.meta && !isSystemField(collection, hookAdjustedField.field)) {
407
445
  if (record) {
408
446
  await this.itemsService.updateOne(record.id, {
409
447
  ...hookAdjustedField.meta,
@@ -451,7 +489,7 @@ export class FieldsService {
451
489
  await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
452
490
  }
453
491
  if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
454
- const updatedSchema = await getSchema();
492
+ const updatedSchema = await getSchema({ database: this.knex });
455
493
  for (const nestedActionEvent of nestedActionEvents) {
456
494
  nestedActionEvent.context.schema = updatedSchema;
457
495
  emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
@@ -463,11 +501,13 @@ export class FieldsService {
463
501
  const nestedActionEvents = [];
464
502
  try {
465
503
  const fieldNames = [];
504
+ const attemptConcurrentIndex = Boolean(opts?.attemptConcurrentIndex);
466
505
  for (const field of fields) {
467
506
  fieldNames.push(await this.updateField(collection, field, {
468
507
  autoPurgeCache: false,
469
508
  autoPurgeSystemCache: false,
470
509
  bypassEmitAction: (params) => nestedActionEvents.push(params),
510
+ attemptConcurrentIndex,
471
511
  }));
472
512
  }
473
513
  return fieldNames;
@@ -480,7 +520,7 @@ export class FieldsService {
480
520
  await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
481
521
  }
482
522
  if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
483
- const updatedSchema = await getSchema();
523
+ const updatedSchema = await getSchema({ database: this.knex });
484
524
  for (const nestedActionEvent of nestedActionEvents) {
485
525
  nestedActionEvent.context.schema = updatedSchema;
486
526
  emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
@@ -590,6 +630,22 @@ export class FieldsService {
590
630
  field: { _eq: field },
591
631
  },
592
632
  }, { emitEvents: false });
633
+ // cleanup permissions for deleted field
634
+ const permissionRows = await trx
635
+ .select('id', 'collection', 'fields')
636
+ .from('directus_permissions')
637
+ .whereRaw('?? = ? AND ?? LIKE ?', ['collection', collection, 'fields', '%' + field + '%']);
638
+ if (permissionRows.length > 0) {
639
+ for (const permissionRow of permissionRows) {
640
+ const newFields = permissionRow['fields']
641
+ .split(',')
642
+ .filter((v) => v !== field)
643
+ .join(',');
644
+ await trx('directus_permissions')
645
+ .update('fields', newFields.length > 0 ? newFields : null)
646
+ .where('id', '=', permissionRow['id']);
647
+ }
648
+ }
593
649
  });
594
650
  const actionEvent = {
595
651
  event: 'fields.delete',
@@ -621,7 +677,7 @@ export class FieldsService {
621
677
  await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
622
678
  }
623
679
  if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
624
- const updatedSchema = await getSchema();
680
+ const updatedSchema = await getSchema({ database: this.knex });
625
681
  for (const nestedActionEvent of nestedActionEvents) {
626
682
  nestedActionEvent.context.schema = updatedSchema;
627
683
  emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
@@ -629,11 +685,12 @@ export class FieldsService {
629
685
  }
630
686
  }
631
687
  }
632
- addColumnToTable(table, collection, field, existing = null) {
688
+ addColumnToTable(table, collection, field, options) {
633
689
  let column;
634
690
  // Don't attempt to add a DB column for alias / corrupt fields
635
691
  if (field.type === 'alias' || field.type === 'unknown')
636
692
  return;
693
+ const existing = options?.existing ?? null;
637
694
  if (field.schema?.has_auto_increment) {
638
695
  if (field.type === 'bigInteger') {
639
696
  column = table.bigIncrements(field.field);
@@ -707,28 +764,48 @@ export class FieldsService {
707
764
  else if (!existing?.is_primary_key) {
708
765
  // primary key will already have unique/index constraints
709
766
  if (field.schema?.is_unique === true) {
710
- if (!existing || existing.is_unique === false) {
767
+ if ((!existing || existing.is_unique === false) && !options?.attemptConcurrentIndex) {
711
768
  column.unique({ indexName: this.helpers.schema.generateIndexName('unique', collection, field.field) });
712
769
  }
713
770
  }
714
- else if (field.schema?.is_unique === false) {
715
- if (existing?.is_unique === true) {
716
- table.dropUnique([field.field], this.helpers.schema.generateIndexName('unique', collection, field.field));
717
- }
771
+ else if (field.schema?.is_unique === false && existing?.is_unique === true) {
772
+ table.dropUnique([field.field], this.helpers.schema.generateIndexName('unique', collection, field.field));
718
773
  }
719
774
  if (field.schema?.is_indexed === true) {
720
- if (!existing || existing.is_indexed === false) {
775
+ if ((!existing || existing.is_indexed === false) && !options?.attemptConcurrentIndex) {
721
776
  column.index(this.helpers.schema.generateIndexName('index', collection, field.field));
722
777
  }
723
778
  }
724
- else if (field.schema?.is_indexed === false) {
725
- if (existing?.is_indexed === true) {
726
- table.dropIndex([field.field], this.helpers.schema.generateIndexName('index', collection, field.field));
727
- }
779
+ else if (field.schema?.is_indexed === false && existing?.is_indexed === true) {
780
+ table.dropIndex([field.field], this.helpers.schema.generateIndexName('index', collection, field.field));
728
781
  }
729
782
  }
730
783
  if (existing) {
731
784
  column.alter();
732
785
  }
733
786
  }
787
+ async addColumnIndex(collection, field, options) {
788
+ const attemptConcurrentIndex = Boolean(options?.attemptConcurrentIndex);
789
+ const knex = options?.knex ?? this.knex;
790
+ const existing = options?.existing ?? null;
791
+ // Don't attempt to index a DB column for alias / corrupt fields
792
+ if (field.type === 'alias' || field.type === 'unknown')
793
+ return;
794
+ // primary key will already have unique/index constraints
795
+ if (field.schema?.is_primary_key || existing?.is_primary_key)
796
+ return;
797
+ const helpers = getHelpers(knex);
798
+ if (field.schema?.is_unique === true && (!existing || existing.is_unique == false)) {
799
+ await helpers.schema.createIndex(collection, field.field, {
800
+ unique: true,
801
+ attemptConcurrentIndex,
802
+ });
803
+ }
804
+ if (field.schema?.is_indexed === true && (!existing || existing.is_indexed === false)) {
805
+ await helpers.schema.createIndex(collection, field.field, {
806
+ unique: false,
807
+ attemptConcurrentIndex,
808
+ });
809
+ }
810
+ }
734
811
  }
@@ -19,8 +19,8 @@ export async function resolveQuery(gql, info) {
19
19
  let query;
20
20
  const isAggregate = collection.endsWith('_aggregated') && collection in gql.schema.collections === false;
21
21
  if (isAggregate) {
22
- query = await getAggregateQuery(args, selections, gql.schema, gql.accountability);
23
22
  collection = collection.slice(0, -11);
23
+ query = await getAggregateQuery(args, selections, gql.schema, gql.accountability, collection);
24
24
  }
25
25
  else {
26
26
  query = await getQuery(args, gql.schema, selections, info.variableValues, gql.accountability, collection);