@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.
- package/dist/app.js +7 -0
- package/dist/auth/auth.d.ts +2 -1
- package/dist/auth/auth.js +7 -2
- package/dist/auth/drivers/ldap.d.ts +0 -2
- package/dist/auth/drivers/ldap.js +9 -7
- package/dist/auth/drivers/oauth2.d.ts +0 -2
- package/dist/auth/drivers/oauth2.js +28 -11
- package/dist/auth/drivers/openid.d.ts +0 -2
- package/dist/auth/drivers/openid.js +28 -11
- package/dist/auth/drivers/saml.d.ts +0 -2
- package/dist/auth/drivers/saml.js +5 -5
- package/dist/auth.js +1 -2
- package/dist/cli/commands/bootstrap/index.js +12 -33
- package/dist/cli/commands/init/index.js +1 -1
- package/dist/cli/commands/schema/apply.d.ts +4 -0
- package/dist/cli/commands/schema/apply.js +26 -3
- package/dist/controllers/collections.js +7 -2
- package/dist/controllers/fields.js +31 -8
- package/dist/controllers/mcp.d.ts +2 -0
- package/dist/controllers/mcp.js +33 -0
- package/dist/controllers/server.js +26 -1
- package/dist/controllers/settings.js +9 -2
- package/dist/controllers/users.js +17 -7
- package/dist/controllers/versions.js +3 -2
- package/dist/database/errors/dialects/mssql.d.ts +1 -1
- package/dist/database/errors/dialects/mssql.js +18 -10
- package/dist/database/helpers/fn/types.js +3 -3
- package/dist/database/helpers/schema/dialects/cockroachdb.d.ts +2 -1
- package/dist/database/helpers/schema/dialects/cockroachdb.js +13 -0
- package/dist/database/helpers/schema/dialects/mssql.d.ts +2 -1
- package/dist/database/helpers/schema/dialects/mssql.js +23 -0
- package/dist/database/helpers/schema/dialects/mysql.d.ts +2 -1
- package/dist/database/helpers/schema/dialects/mysql.js +25 -0
- package/dist/database/helpers/schema/dialects/oracle.d.ts +2 -1
- package/dist/database/helpers/schema/dialects/oracle.js +13 -0
- package/dist/database/helpers/schema/dialects/postgres.d.ts +2 -1
- package/dist/database/helpers/schema/dialects/postgres.js +13 -0
- package/dist/database/helpers/schema/types.d.ts +5 -0
- package/dist/database/helpers/schema/types.js +6 -0
- package/dist/database/migrations/20250813A-add-mcp.d.ts +3 -0
- package/dist/database/migrations/20250813A-add-mcp.js +18 -0
- package/dist/database/migrations/20251012A-add-field-searchable.d.ts +3 -0
- package/dist/database/migrations/20251012A-add-field-searchable.js +10 -0
- package/dist/database/migrations/20251014A-add-project-owner.d.ts +3 -0
- package/dist/database/migrations/20251014A-add-project-owner.js +37 -0
- package/dist/database/migrations/20251028A-add-retention-indexes.d.ts +3 -0
- package/dist/database/migrations/20251028A-add-retention-indexes.js +42 -0
- package/dist/database/run-ast/README.md +46 -0
- package/dist/database/run-ast/lib/apply-query/add-join.js +2 -2
- package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.d.ts +2 -2
- package/dist/database/run-ast/lib/apply-query/index.d.ts +0 -1
- package/dist/database/run-ast/lib/apply-query/index.js +4 -6
- package/dist/database/run-ast/lib/apply-query/search.js +2 -0
- package/dist/database/run-ast/lib/get-db-query.js +7 -6
- package/dist/database/run-ast/utils/generate-alias.d.ts +6 -0
- package/dist/database/run-ast/utils/generate-alias.js +57 -0
- package/dist/flows.js +1 -0
- package/dist/mcp/define.d.ts +2 -0
- package/dist/mcp/define.js +3 -0
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/schema.d.ts +485 -0
- package/dist/mcp/schema.js +219 -0
- package/dist/mcp/server.d.ts +103 -0
- package/dist/mcp/server.js +310 -0
- package/dist/mcp/tools/assets.d.ts +3 -0
- package/dist/mcp/tools/assets.js +54 -0
- package/dist/mcp/tools/collections.d.ts +84 -0
- package/dist/mcp/tools/collections.js +90 -0
- package/dist/mcp/tools/fields.d.ts +101 -0
- package/dist/mcp/tools/fields.js +157 -0
- package/dist/mcp/tools/files.d.ts +235 -0
- package/dist/mcp/tools/files.js +103 -0
- package/dist/mcp/tools/flows.d.ts +323 -0
- package/dist/mcp/tools/flows.js +85 -0
- package/dist/mcp/tools/folders.d.ts +95 -0
- package/dist/mcp/tools/folders.js +96 -0
- package/dist/mcp/tools/index.d.ts +15 -0
- package/dist/mcp/tools/index.js +29 -0
- package/dist/mcp/tools/items.d.ts +87 -0
- package/dist/mcp/tools/items.js +141 -0
- package/dist/mcp/tools/operations.d.ts +171 -0
- package/dist/mcp/tools/operations.js +77 -0
- package/dist/mcp/tools/prompts/assets.md +8 -0
- package/dist/mcp/tools/prompts/collections.md +336 -0
- package/dist/mcp/tools/prompts/fields.md +521 -0
- package/dist/mcp/tools/prompts/files.md +180 -0
- package/dist/mcp/tools/prompts/flows.md +495 -0
- package/dist/mcp/tools/prompts/folders.md +34 -0
- package/dist/mcp/tools/prompts/index.d.ts +16 -0
- package/dist/mcp/tools/prompts/index.js +19 -0
- package/dist/mcp/tools/prompts/items.md +317 -0
- package/dist/mcp/tools/prompts/operations.md +721 -0
- package/dist/mcp/tools/prompts/relations.md +386 -0
- package/dist/mcp/tools/prompts/schema.md +130 -0
- package/dist/mcp/tools/prompts/system-prompt-description.md +1 -0
- package/dist/mcp/tools/prompts/system-prompt.md +44 -0
- package/dist/mcp/tools/prompts/trigger-flow.md +214 -0
- package/dist/mcp/tools/relations.d.ts +73 -0
- package/dist/mcp/tools/relations.js +93 -0
- package/dist/mcp/tools/schema.d.ts +54 -0
- package/dist/mcp/tools/schema.js +317 -0
- package/dist/mcp/tools/system.d.ts +3 -0
- package/dist/mcp/tools/system.js +22 -0
- package/dist/mcp/tools/trigger-flow.d.ts +8 -0
- package/dist/mcp/tools/trigger-flow.js +48 -0
- package/dist/mcp/transport.d.ts +13 -0
- package/dist/mcp/transport.js +18 -0
- package/dist/mcp/types.d.ts +56 -0
- package/dist/mcp/types.js +1 -0
- package/dist/metrics/lib/create-metrics.js +16 -25
- package/dist/middleware/collection-exists.js +2 -2
- package/dist/operations/mail/index.js +3 -1
- package/dist/operations/mail/rate-limiter.d.ts +1 -0
- package/dist/operations/mail/rate-limiter.js +29 -0
- package/dist/permissions/modules/process-payload/process-payload.js +3 -10
- package/dist/permissions/modules/validate-access/validate-access.js +2 -3
- package/dist/schedules/metrics.js +6 -2
- package/dist/schedules/project.d.ts +4 -0
- package/dist/schedules/project.js +27 -0
- package/dist/services/authentication.js +36 -0
- package/dist/services/collections.d.ts +3 -3
- package/dist/services/collections.js +16 -1
- package/dist/services/fields.d.ts +21 -5
- package/dist/services/fields.js +109 -32
- package/dist/services/graphql/resolvers/query.js +1 -1
- package/dist/services/graphql/resolvers/system-admin.js +49 -5
- package/dist/services/graphql/schema/parse-query.js +8 -8
- package/dist/services/graphql/utils/aggregate-query.d.ts +1 -1
- package/dist/services/graphql/utils/aggregate-query.js +5 -1
- package/dist/services/graphql/utils/filter-replace-m2a.js +2 -1
- package/dist/services/import-export.d.ts +9 -1
- package/dist/services/import-export.js +287 -101
- package/dist/services/items.d.ts +1 -1
- package/dist/services/items.js +50 -24
- package/dist/services/mail/index.js +2 -0
- package/dist/services/mail/rate-limiter.d.ts +1 -0
- package/dist/services/mail/rate-limiter.js +29 -0
- package/dist/services/meta.js +28 -24
- package/dist/services/payload.d.ts +7 -3
- package/dist/services/payload.js +26 -12
- package/dist/services/schema.js +4 -1
- package/dist/services/server.d.ts +1 -0
- package/dist/services/server.js +15 -18
- package/dist/services/settings.d.ts +2 -1
- package/dist/services/settings.js +15 -0
- package/dist/services/tfa.d.ts +1 -1
- package/dist/services/tfa.js +20 -5
- package/dist/services/tus/server.js +14 -9
- package/dist/services/versions.d.ts +6 -4
- package/dist/services/versions.js +84 -25
- package/dist/telemetry/lib/get-report.js +4 -4
- package/dist/telemetry/lib/send-report.d.ts +6 -1
- package/dist/telemetry/lib/send-report.js +3 -1
- package/dist/telemetry/types/report.d.ts +17 -1
- package/dist/telemetry/utils/get-settings.d.ts +9 -0
- package/dist/telemetry/utils/get-settings.js +14 -0
- package/dist/test-utils/README.md +760 -0
- package/dist/test-utils/cache.d.ts +51 -0
- package/dist/test-utils/cache.js +59 -0
- package/dist/test-utils/database.d.ts +48 -0
- package/dist/test-utils/database.js +52 -0
- package/dist/test-utils/emitter.d.ts +35 -0
- package/dist/test-utils/emitter.js +38 -0
- package/dist/test-utils/fields-service.d.ts +28 -0
- package/dist/test-utils/fields-service.js +36 -0
- package/dist/test-utils/items-service.d.ts +23 -0
- package/dist/test-utils/items-service.js +37 -0
- package/dist/test-utils/knex.d.ts +164 -0
- package/dist/test-utils/knex.js +268 -0
- package/dist/test-utils/schema.d.ts +26 -0
- package/dist/test-utils/schema.js +35 -0
- package/dist/types/auth.d.ts +2 -3
- package/dist/utils/apply-diff.js +15 -0
- package/dist/utils/create-admin.d.ts +11 -0
- package/dist/utils/create-admin.js +50 -0
- package/dist/utils/get-schema.js +5 -3
- package/dist/utils/get-snapshot-diff.js +49 -5
- package/dist/utils/get-snapshot.js +13 -7
- package/dist/utils/sanitize-schema.d.ts +11 -4
- package/dist/utils/sanitize-schema.js +9 -6
- package/dist/utils/schedule.js +15 -19
- package/dist/utils/validate-diff.js +31 -0
- package/dist/utils/validate-snapshot.js +7 -0
- package/dist/utils/versioning/deep-map-with-schema.d.ts +23 -0
- package/dist/utils/versioning/deep-map-with-schema.js +81 -0
- package/dist/utils/versioning/handle-version.d.ts +2 -2
- package/dist/utils/versioning/handle-version.js +47 -43
- package/dist/utils/versioning/split-recursive.d.ts +4 -0
- package/dist/utils/versioning/split-recursive.js +27 -0
- package/dist/websocket/controllers/hooks.js +12 -20
- package/dist/websocket/messages.d.ts +3 -3
- package/package.json +65 -66
- package/dist/cli/utils/defaults.d.ts +0 -4
- package/dist/cli/utils/defaults.js +0 -17
- package/dist/telemetry/utils/get-project-id.d.ts +0 -2
- 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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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,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,
|
|
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?:
|
|
19
|
+
createOne(payload: RawCollection, opts?: FieldMutationOptions): Promise<string>;
|
|
20
20
|
/**
|
|
21
21
|
* Create multiple new collections
|
|
22
22
|
*/
|
|
23
|
-
createMany(payloads: RawCollection[], opts?:
|
|
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?:
|
|
29
|
-
updateField(collection: string, field: RawField, opts?:
|
|
30
|
-
updateFields(collection: string, fields: RawField[], opts?:
|
|
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,
|
|
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
|
}
|
package/dist/services/fields.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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,
|
|
421
|
+
await trx.schema.alterTable(collection, (table) => {
|
|
395
422
|
if (!hookAdjustedField.schema)
|
|
396
423
|
return;
|
|
397
|
-
this.addColumnToTable(table, collection, field,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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);
|