@directus/api 29.1.1 → 31.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 +5 -0
- package/dist/auth/drivers/oauth2.js +17 -3
- package/dist/auth/drivers/openid.js +17 -3
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +9 -1
- package/dist/controllers/items.js +3 -4
- package/dist/controllers/mcp.d.ts +2 -0
- package/dist/controllers/mcp.js +33 -0
- 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/migrations/20250813A-add-mcp.d.ts +3 -0
- package/dist/database/migrations/20250813A-add-mcp.js +18 -0
- package/dist/database/run-ast/README.md +46 -0
- package/dist/mailer.js +3 -3
- 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 +97 -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/middleware/respond.js +2 -2
- package/dist/services/authentication.js +36 -0
- package/dist/services/fields.js +4 -4
- package/dist/services/graphql/index.d.ts +2 -2
- package/dist/services/graphql/index.js +6 -5
- package/dist/services/graphql/resolvers/query.js +4 -39
- package/dist/services/items.js +38 -12
- package/dist/services/payload.d.ts +7 -3
- package/dist/services/payload.js +70 -12
- package/dist/services/server.js +1 -0
- package/dist/services/tfa.d.ts +1 -1
- package/dist/services/tfa.js +20 -5
- package/dist/services/versions.d.ts +6 -4
- package/dist/services/versions.js +84 -25
- package/dist/types/auth.d.ts +2 -1
- package/dist/utils/deep-map-response.d.ts +17 -0
- package/dist/utils/deep-map-response.js +61 -0
- package/dist/utils/get-relation-info.d.ts +1 -2
- package/dist/utils/permissions-cacheable.d.ts +8 -0
- package/dist/utils/{permissions-cachable.js → permissions-cacheable.js} +8 -6
- package/dist/utils/transaction.d.ts +1 -1
- package/dist/utils/transaction.js +18 -2
- 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 +3 -0
- package/dist/utils/versioning/handle-version.js +96 -0
- package/dist/utils/versioning/merge-version-data.d.ts +2 -0
- package/dist/utils/versioning/merge-version-data.js +10 -0
- package/dist/utils/versioning/split-recursive.d.ts +4 -0
- package/dist/utils/versioning/split-recursive.js +27 -0
- package/package.json +31 -30
- package/dist/middleware/merge-content-versions.d.ts +0 -2
- package/dist/middleware/merge-content-versions.js +0 -26
- package/dist/utils/merge-version-data.d.ts +0 -3
- package/dist/utils/merge-version-data.js +0 -134
- package/dist/utils/permissions-cachable.d.ts +0 -8
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { Action } from '@directus/constants';
|
|
2
2
|
import { ForbiddenError, InvalidPayloadError, UnprocessableContentError } from '@directus/errors';
|
|
3
3
|
import Joi from 'joi';
|
|
4
|
-
import { assign, pick } from 'lodash-es';
|
|
4
|
+
import { assign, get, isEqual, isPlainObject, pick } from 'lodash-es';
|
|
5
5
|
import objectHash from 'object-hash';
|
|
6
6
|
import { getCache } from '../cache.js';
|
|
7
|
+
import { getHelpers } from '../database/helpers/index.js';
|
|
7
8
|
import emitter from '../emitter.js';
|
|
8
9
|
import { validateAccess } from '../permissions/modules/validate-access/validate-access.js';
|
|
9
10
|
import { shouldClearCache } from '../utils/should-clear-cache.js';
|
|
11
|
+
import { splitRecursive } from '../utils/versioning/split-recursive.js';
|
|
10
12
|
import { ActivityService } from './activity.js';
|
|
11
13
|
import { ItemsService } from './items.js';
|
|
12
14
|
import { PayloadService } from './payload.js';
|
|
13
15
|
import { RevisionsService } from './revisions.js';
|
|
16
|
+
import { deepMapWithSchema } from '../utils/versioning/deep-map-with-schema.js';
|
|
14
17
|
export class VersionsService extends ItemsService {
|
|
15
18
|
constructor(options) {
|
|
16
19
|
super('directus_versions', options);
|
|
@@ -59,10 +62,10 @@ export class VersionsService extends ItemsService {
|
|
|
59
62
|
knex: this.knex,
|
|
60
63
|
schema: this.schema,
|
|
61
64
|
});
|
|
62
|
-
const existingVersions = await sudoService.readByQuery({
|
|
65
|
+
const existingVersions = (await sudoService.readByQuery({
|
|
63
66
|
aggregate: { count: ['*'] },
|
|
64
67
|
filter: { key: { _eq: data['key'] }, collection: { _eq: data['collection'] }, item: { _eq: data['item'] } },
|
|
65
|
-
});
|
|
68
|
+
}));
|
|
66
69
|
if (existingVersions[0]['count'] > 0) {
|
|
67
70
|
throw new UnprocessableContentError({
|
|
68
71
|
reason: `Version "${data['key']}" already exists for item "${data['item']}" in collection "${data['collection']}"`,
|
|
@@ -82,21 +85,17 @@ export class VersionsService extends ItemsService {
|
|
|
82
85
|
const mainHash = objectHash(mainItem);
|
|
83
86
|
return { outdated: hash !== mainHash, mainHash };
|
|
84
87
|
}
|
|
85
|
-
async
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (versions[0]['delta']) {
|
|
97
|
-
return [versions[0]['delta']];
|
|
98
|
-
}
|
|
99
|
-
return null;
|
|
88
|
+
async getVersionSave(key, collection, item, mapDelta = true) {
|
|
89
|
+
const version = (await this.readByQuery({
|
|
90
|
+
filter: {
|
|
91
|
+
key: { _eq: key },
|
|
92
|
+
collection: { _eq: collection },
|
|
93
|
+
item: { _eq: item },
|
|
94
|
+
},
|
|
95
|
+
}))[0];
|
|
96
|
+
if (mapDelta && version?.delta)
|
|
97
|
+
version.delta = this.mapDelta(version);
|
|
98
|
+
return version;
|
|
100
99
|
}
|
|
101
100
|
async createOne(data, opts) {
|
|
102
101
|
await this.validateCreateData(data);
|
|
@@ -104,6 +103,12 @@ export class VersionsService extends ItemsService {
|
|
|
104
103
|
data['hash'] = objectHash(mainItem);
|
|
105
104
|
return super.createOne(data, opts);
|
|
106
105
|
}
|
|
106
|
+
async readOne(key, query = {}, opts) {
|
|
107
|
+
const version = await super.readOne(key, query, opts);
|
|
108
|
+
if (version?.delta)
|
|
109
|
+
version.delta = this.mapDelta(version);
|
|
110
|
+
return version;
|
|
111
|
+
}
|
|
107
112
|
async createMany(data, opts) {
|
|
108
113
|
if (!Array.isArray(data)) {
|
|
109
114
|
throw new InvalidPayloadError({ reason: 'Input should be an array of items' });
|
|
@@ -156,7 +161,7 @@ export class VersionsService extends ItemsService {
|
|
|
156
161
|
}
|
|
157
162
|
return super.updateMany(keys, data, opts);
|
|
158
163
|
}
|
|
159
|
-
async save(key,
|
|
164
|
+
async save(key, delta) {
|
|
160
165
|
const version = await super.readOne(key);
|
|
161
166
|
const payloadService = new PayloadService(this.collection, {
|
|
162
167
|
accountability: this.accountability,
|
|
@@ -171,7 +176,7 @@ export class VersionsService extends ItemsService {
|
|
|
171
176
|
knex: this.knex,
|
|
172
177
|
schema: this.schema,
|
|
173
178
|
});
|
|
174
|
-
const { item, collection } = version;
|
|
179
|
+
const { item, collection, delta: existingDelta } = version;
|
|
175
180
|
const activity = await activityService.createOne({
|
|
176
181
|
action: Action.VERSION_SAVE,
|
|
177
182
|
user: this.accountability?.user ?? null,
|
|
@@ -181,7 +186,8 @@ export class VersionsService extends ItemsService {
|
|
|
181
186
|
origin: this.accountability?.origin ?? null,
|
|
182
187
|
item,
|
|
183
188
|
});
|
|
184
|
-
const
|
|
189
|
+
const helpers = getHelpers(this.knex);
|
|
190
|
+
let revisionDelta = await payloadService.prepareDelta(delta);
|
|
185
191
|
await revisionsService.createOne({
|
|
186
192
|
activity,
|
|
187
193
|
version: key,
|
|
@@ -190,10 +196,23 @@ export class VersionsService extends ItemsService {
|
|
|
190
196
|
data: revisionDelta,
|
|
191
197
|
delta: revisionDelta,
|
|
192
198
|
});
|
|
193
|
-
|
|
199
|
+
revisionDelta = revisionDelta ? JSON.parse(revisionDelta) : null;
|
|
200
|
+
const date = new Date(helpers.date.writeTimestamp(new Date().toISOString()));
|
|
201
|
+
deepMapObjects(revisionDelta, (object, path) => {
|
|
202
|
+
const existing = get(existingDelta, path);
|
|
203
|
+
if (existing && isEqual(existing, object))
|
|
204
|
+
return;
|
|
205
|
+
object['_user'] = this.accountability?.user;
|
|
206
|
+
object['_date'] = date;
|
|
207
|
+
});
|
|
208
|
+
const finalVersionDelta = assign({}, existingDelta, revisionDelta);
|
|
194
209
|
const sudoService = new ItemsService(this.collection, {
|
|
195
210
|
knex: this.knex,
|
|
196
211
|
schema: this.schema,
|
|
212
|
+
accountability: {
|
|
213
|
+
...this.accountability,
|
|
214
|
+
admin: true,
|
|
215
|
+
},
|
|
197
216
|
});
|
|
198
217
|
await sudoService.updateOne(key, { delta: finalVersionDelta });
|
|
199
218
|
const { cache } = getCache();
|
|
@@ -203,7 +222,7 @@ export class VersionsService extends ItemsService {
|
|
|
203
222
|
return finalVersionDelta;
|
|
204
223
|
}
|
|
205
224
|
async promote(version, mainHash, fields) {
|
|
206
|
-
const { collection, item, delta } = (await
|
|
225
|
+
const { collection, item, delta } = (await super.readOne(version));
|
|
207
226
|
// will throw an error if the accountability does not have permission to update the item
|
|
208
227
|
if (this.accountability) {
|
|
209
228
|
await validateAccess({
|
|
@@ -227,7 +246,8 @@ export class VersionsService extends ItemsService {
|
|
|
227
246
|
reason: `Main item has changed since this version was last updated`,
|
|
228
247
|
});
|
|
229
248
|
}
|
|
230
|
-
const
|
|
249
|
+
const { rawDelta, defaultOverwrites } = splitRecursive(delta);
|
|
250
|
+
const payloadToUpdate = fields ? pick(rawDelta, fields) : rawDelta;
|
|
231
251
|
const itemsService = new ItemsService(collection, {
|
|
232
252
|
accountability: this.accountability,
|
|
233
253
|
knex: this.knex,
|
|
@@ -242,7 +262,9 @@ export class VersionsService extends ItemsService {
|
|
|
242
262
|
schema: this.schema,
|
|
243
263
|
accountability: this.accountability,
|
|
244
264
|
});
|
|
245
|
-
const updatedItemKey = await itemsService.updateOne(item, payloadAfterHooks
|
|
265
|
+
const updatedItemKey = await itemsService.updateOne(item, payloadAfterHooks, {
|
|
266
|
+
overwriteDefaults: defaultOverwrites,
|
|
267
|
+
});
|
|
246
268
|
emitter.emitAction(['items.promote', `${collection}.items.promote`], {
|
|
247
269
|
payload: payloadAfterHooks,
|
|
248
270
|
collection,
|
|
@@ -255,4 +277,41 @@ export class VersionsService extends ItemsService {
|
|
|
255
277
|
});
|
|
256
278
|
return updatedItemKey;
|
|
257
279
|
}
|
|
280
|
+
mapDelta(version) {
|
|
281
|
+
const delta = version.delta ?? {};
|
|
282
|
+
delta[this.schema.collections[version.collection].primary] = version.item;
|
|
283
|
+
return deepMapWithSchema(delta, ([key, value], context) => {
|
|
284
|
+
if (key === '_user' || key === '_date')
|
|
285
|
+
return;
|
|
286
|
+
if (context.collection.primary in context.object) {
|
|
287
|
+
if (context.field.special.includes('user-updated')) {
|
|
288
|
+
return [key, context.object['_user']];
|
|
289
|
+
}
|
|
290
|
+
if (context.field.special.includes('date-updated')) {
|
|
291
|
+
return [key, context.object['_date']];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
if (context.field.special.includes('user-created')) {
|
|
296
|
+
return [key, context.object['_user']];
|
|
297
|
+
}
|
|
298
|
+
if (context.field.special.includes('date-created')) {
|
|
299
|
+
return [key, context.object['_date']];
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (key in context.object)
|
|
303
|
+
return [key, value];
|
|
304
|
+
return undefined;
|
|
305
|
+
}, { collection: version.collection, schema: this.schema }, { mapNonExistentFields: true, detailedUpdateSyntax: true });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/** Deeply maps all objects of a structure. Only calls the callback for objects, not for arrays. Objects in arrays will continued to be mapped. */
|
|
309
|
+
function deepMapObjects(object, fn, path = []) {
|
|
310
|
+
if (isPlainObject(object) && typeof object === 'object' && object !== null) {
|
|
311
|
+
fn(object, path);
|
|
312
|
+
Object.entries(object).map(([key, value]) => deepMapObjects(value, fn, [...path, key]));
|
|
313
|
+
}
|
|
314
|
+
else if (Array.isArray(object)) {
|
|
315
|
+
object.map((value, index) => deepMapObjects(value, fn, [...path, String(index)]));
|
|
316
|
+
}
|
|
258
317
|
}
|
package/dist/types/auth.d.ts
CHANGED
|
@@ -27,10 +27,11 @@ export interface Session {
|
|
|
27
27
|
export type DirectusTokenPayload = {
|
|
28
28
|
id?: string;
|
|
29
29
|
role: string | null;
|
|
30
|
-
session?: string;
|
|
31
30
|
app_access: boolean | number;
|
|
32
31
|
admin_access: boolean | number;
|
|
33
32
|
share?: string;
|
|
33
|
+
session?: string;
|
|
34
|
+
enforce_tfa?: boolean;
|
|
34
35
|
};
|
|
35
36
|
export type ShareData = {
|
|
36
37
|
share_id: string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CollectionOverview, FieldOverview, Relation, SchemaOverview } from '@directus/types';
|
|
2
|
+
import { type RelationInfo } from './get-relation-info.js';
|
|
3
|
+
/**
|
|
4
|
+
* Allows to deep map the response from the ItemsService with collection, field and relation context for each entry.
|
|
5
|
+
* Bottom to Top depth first mapping of values.
|
|
6
|
+
*/
|
|
7
|
+
export declare function deepMapResponse(object: Record<string, any>, callback: (entry: [key: string | number, value: unknown], context: {
|
|
8
|
+
collection: CollectionOverview;
|
|
9
|
+
field: FieldOverview;
|
|
10
|
+
relation: Relation | null;
|
|
11
|
+
leaf: boolean;
|
|
12
|
+
relationType: RelationInfo['relationType'] | null;
|
|
13
|
+
}) => [key: string | number, value: unknown], context: {
|
|
14
|
+
schema: SchemaOverview;
|
|
15
|
+
collection: string;
|
|
16
|
+
relationInfo?: RelationInfo;
|
|
17
|
+
}): any;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isPlainObject } from 'lodash-es';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { getRelationInfo } from './get-relation-info.js';
|
|
4
|
+
import { InvalidQueryError } from '@directus/errors';
|
|
5
|
+
/**
|
|
6
|
+
* Allows to deep map the response from the ItemsService with collection, field and relation context for each entry.
|
|
7
|
+
* Bottom to Top depth first mapping of values.
|
|
8
|
+
*/
|
|
9
|
+
export function deepMapResponse(object, callback, context) {
|
|
10
|
+
const collection = context.schema.collections[context.collection];
|
|
11
|
+
assert(isPlainObject(object) && typeof object === 'object' && object !== null, `DeepMapResponse only works on objects, received ${JSON.stringify(object)}`);
|
|
12
|
+
return Object.fromEntries(Object.entries(object).map(([key, value]) => {
|
|
13
|
+
const field = collection?.fields[key];
|
|
14
|
+
if (!field)
|
|
15
|
+
return [key, value];
|
|
16
|
+
const relationInfo = getRelationInfo(context.schema.relations, collection.collection, field.field);
|
|
17
|
+
let leaf = true;
|
|
18
|
+
if (relationInfo.relation && typeof value === 'object' && value !== null && isPlainObject(object)) {
|
|
19
|
+
switch (relationInfo.relationType) {
|
|
20
|
+
case 'm2o':
|
|
21
|
+
value = deepMapResponse(value, callback, {
|
|
22
|
+
schema: context.schema,
|
|
23
|
+
collection: relationInfo.relation.related_collection,
|
|
24
|
+
relationInfo,
|
|
25
|
+
});
|
|
26
|
+
leaf = false;
|
|
27
|
+
break;
|
|
28
|
+
case 'o2m':
|
|
29
|
+
value = value.map((childValue) => {
|
|
30
|
+
if (isPlainObject(childValue) && typeof childValue === 'object' && childValue !== null) {
|
|
31
|
+
leaf = false;
|
|
32
|
+
return deepMapResponse(childValue, callback, {
|
|
33
|
+
schema: context.schema,
|
|
34
|
+
collection: relationInfo.relation.collection,
|
|
35
|
+
relationInfo,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
return childValue;
|
|
40
|
+
});
|
|
41
|
+
break;
|
|
42
|
+
case 'a2o': {
|
|
43
|
+
const related_collection = object[relationInfo.relation.meta.one_collection_field];
|
|
44
|
+
if (!related_collection) {
|
|
45
|
+
throw new InvalidQueryError({
|
|
46
|
+
reason: `When selecting '${collection.collection}.${field.field}', the field '${collection.collection}.${relationInfo.relation.meta.one_collection_field}' has to be selected when using versioning and m2a relations `,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
value = deepMapResponse(value, callback, {
|
|
50
|
+
schema: context.schema,
|
|
51
|
+
collection: related_collection,
|
|
52
|
+
relationInfo,
|
|
53
|
+
});
|
|
54
|
+
leaf = false;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return callback([key, value], { collection, field, ...relationInfo, leaf });
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Relation } from '@directus/types';
|
|
2
|
-
type RelationInfo = {
|
|
2
|
+
export type RelationInfo = {
|
|
3
3
|
relation: Relation | null;
|
|
4
4
|
relationType: 'o2m' | 'm2o' | 'a2o' | 'o2a' | null;
|
|
5
5
|
};
|
|
6
6
|
export declare function getRelationInfo(relations: Relation[], collection: string, field: string): RelationInfo;
|
|
7
|
-
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Accountability, Filter } from '@directus/types';
|
|
2
|
+
import type { Context } from '../permissions/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Check if the read permissions for a collection contain the dynamic variable $NOW.
|
|
5
|
+
* If they do, the permissions are not cacheable.
|
|
6
|
+
*/
|
|
7
|
+
export declare function permissionsCacheable(collection: string | undefined, context: Context, accountability?: Accountability): Promise<boolean>;
|
|
8
|
+
export declare function filterHasNow(filter: Filter): boolean;
|
|
@@ -3,9 +3,9 @@ import { fetchPolicies } from '../permissions/lib/fetch-policies.js';
|
|
|
3
3
|
import { createDefaultAccountability } from '../permissions/utils/create-default-accountability.js';
|
|
4
4
|
/**
|
|
5
5
|
* Check if the read permissions for a collection contain the dynamic variable $NOW.
|
|
6
|
-
* If they do, the permissions are not
|
|
6
|
+
* If they do, the permissions are not cacheable.
|
|
7
7
|
*/
|
|
8
|
-
export async function
|
|
8
|
+
export async function permissionsCacheable(collection, context, accountability) {
|
|
9
9
|
if (!collection) {
|
|
10
10
|
return true;
|
|
11
11
|
}
|
|
@@ -18,17 +18,19 @@ export async function permissionsCachable(collection, context, accountability) {
|
|
|
18
18
|
if (!permission.permissions) {
|
|
19
19
|
return false;
|
|
20
20
|
}
|
|
21
|
-
return
|
|
21
|
+
return filterHasNow(permission.permissions);
|
|
22
22
|
});
|
|
23
23
|
return !has_now;
|
|
24
24
|
}
|
|
25
|
-
export function
|
|
25
|
+
export function filterHasNow(filter) {
|
|
26
|
+
if (filter === null)
|
|
27
|
+
return false;
|
|
26
28
|
return Object.entries(filter).some(([key, value]) => {
|
|
27
29
|
if (key === '_and' || key === '_or') {
|
|
28
|
-
return value.some((sub_filter) =>
|
|
30
|
+
return value.some((sub_filter) => filterHasNow(sub_filter));
|
|
29
31
|
}
|
|
30
32
|
else if (typeof value === 'object') {
|
|
31
|
-
return
|
|
33
|
+
return filterHasNow(value);
|
|
32
34
|
}
|
|
33
35
|
else if (typeof value === 'string') {
|
|
34
36
|
return value.startsWith('$NOW');
|
|
@@ -6,4 +6,4 @@ import { type Knex } from 'knex';
|
|
|
6
6
|
* Can be used to ensure the handler is run within a transaction,
|
|
7
7
|
* while preventing nested transactions.
|
|
8
8
|
*/
|
|
9
|
-
export declare const transaction: <T = unknown>(knex: Knex, handler: (knex: Knex) => Promise<T>) => Promise<T>;
|
|
9
|
+
export declare const transaction: <T = unknown>(knex: Knex, handler: (knex: Knex.Transaction) => Promise<T>) => Promise<T>;
|
|
@@ -63,7 +63,23 @@ function shouldRetryTransaction(client, error) {
|
|
|
63
63
|
* @link https://www.sqlite.org/rescode.html#busy
|
|
64
64
|
*/
|
|
65
65
|
const SQLITE_BUSY_ERROR_CODE = 'SQLITE_BUSY';
|
|
66
|
+
// Both mariadb and mysql
|
|
67
|
+
const MYSQL_DEADLOCK_CODE = 'ER_LOCK_DEADLOCK';
|
|
68
|
+
const POSTGRES_DEADLOCK_CODE = '40P01';
|
|
69
|
+
const ORACLE_DEADLOCK_CODE = 'ORA-00060';
|
|
70
|
+
const MSSQL_DEADLOCK_CODE = 'EREQUEST';
|
|
71
|
+
const MSSQL_DEADLOCK_NUMBER = '1205';
|
|
72
|
+
const codes = {
|
|
73
|
+
cockroachdb: [{ code: COCKROACH_RETRY_ERROR_CODE }],
|
|
74
|
+
sqlite: [{ code: SQLITE_BUSY_ERROR_CODE }],
|
|
75
|
+
mysql: [{ code: MYSQL_DEADLOCK_CODE }],
|
|
76
|
+
mssql: [{ code: MSSQL_DEADLOCK_CODE, number: MSSQL_DEADLOCK_NUMBER }],
|
|
77
|
+
oracle: [{ code: ORACLE_DEADLOCK_CODE }],
|
|
78
|
+
postgres: [{ code: POSTGRES_DEADLOCK_CODE }],
|
|
79
|
+
redshift: [],
|
|
80
|
+
};
|
|
66
81
|
return (isObject(error) &&
|
|
67
|
-
((
|
|
68
|
-
(
|
|
82
|
+
codes[client].some((code) => {
|
|
83
|
+
return Object.entries(code).every(([key, value]) => String(error[key]) === value);
|
|
84
|
+
}));
|
|
69
85
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CollectionOverview, FieldOverview, Relation, SchemaOverview } from '@directus/types';
|
|
2
|
+
import { type RelationInfo } from '../get-relation-info.js';
|
|
3
|
+
/**
|
|
4
|
+
* Allows to deep map the data like a response or delta changes with collection, field and relation context for each entry.
|
|
5
|
+
* Bottom to Top depth first mapping of values.
|
|
6
|
+
*/
|
|
7
|
+
export declare function deepMapWithSchema(object: Record<string, any>, callback: (entry: [key: string | number, value: unknown], context: {
|
|
8
|
+
collection: CollectionOverview;
|
|
9
|
+
field: FieldOverview;
|
|
10
|
+
relation: Relation | null;
|
|
11
|
+
leaf: boolean;
|
|
12
|
+
relationType: RelationInfo['relationType'] | null;
|
|
13
|
+
object: Record<string, any>;
|
|
14
|
+
}) => [key: string | number, value: unknown] | undefined, context: {
|
|
15
|
+
schema: SchemaOverview;
|
|
16
|
+
collection: string;
|
|
17
|
+
relationInfo?: RelationInfo;
|
|
18
|
+
}, options?: {
|
|
19
|
+
/** If set to true, non-existent fields will be included in the mapping and will have a value of undefined */
|
|
20
|
+
mapNonExistentFields?: boolean;
|
|
21
|
+
/** If set to true, it will map the "create", "update" and "delete" syntax for o2m relations found in deltas */
|
|
22
|
+
detailedUpdateSyntax?: boolean;
|
|
23
|
+
}): any;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { isPlainObject } from 'lodash-es';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { getRelationInfo } from '../get-relation-info.js';
|
|
4
|
+
import { InvalidQueryError } from '@directus/errors';
|
|
5
|
+
/**
|
|
6
|
+
* Allows to deep map the data like a response or delta changes with collection, field and relation context for each entry.
|
|
7
|
+
* Bottom to Top depth first mapping of values.
|
|
8
|
+
*/
|
|
9
|
+
export function deepMapWithSchema(object, callback, context, options) {
|
|
10
|
+
const collection = context.schema.collections[context.collection];
|
|
11
|
+
assert(isPlainObject(object) && typeof object === 'object' && object !== null, `DeepMapResponse only works on objects, received ${JSON.stringify(object)}`);
|
|
12
|
+
let fields;
|
|
13
|
+
if (options?.mapNonExistentFields) {
|
|
14
|
+
fields = Object.entries(collection.fields);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
fields = Object.keys(object).map((key) => [key, collection.fields[key]]);
|
|
18
|
+
}
|
|
19
|
+
return Object.fromEntries(fields
|
|
20
|
+
.map(([key, field]) => {
|
|
21
|
+
let value = object[key];
|
|
22
|
+
if (!field)
|
|
23
|
+
return [key, value];
|
|
24
|
+
const relationInfo = getRelationInfo(context.schema.relations, collection.collection, field.field);
|
|
25
|
+
let leaf = true;
|
|
26
|
+
if (relationInfo.relation && typeof value === 'object' && value !== null && isPlainObject(object)) {
|
|
27
|
+
switch (relationInfo.relationType) {
|
|
28
|
+
case 'm2o':
|
|
29
|
+
value = deepMapWithSchema(value, callback, {
|
|
30
|
+
schema: context.schema,
|
|
31
|
+
collection: relationInfo.relation.related_collection,
|
|
32
|
+
relationInfo,
|
|
33
|
+
}, options);
|
|
34
|
+
leaf = false;
|
|
35
|
+
break;
|
|
36
|
+
case 'o2m': {
|
|
37
|
+
function map(childValue) {
|
|
38
|
+
if (isPlainObject(childValue) && typeof childValue === 'object' && childValue !== null) {
|
|
39
|
+
leaf = false;
|
|
40
|
+
return deepMapWithSchema(childValue, callback, {
|
|
41
|
+
schema: context.schema,
|
|
42
|
+
collection: relationInfo.relation.collection,
|
|
43
|
+
relationInfo,
|
|
44
|
+
}, options);
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
return childValue;
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
value = value.map(map);
|
|
51
|
+
}
|
|
52
|
+
else if (options?.detailedUpdateSyntax && isPlainObject(value)) {
|
|
53
|
+
value = {
|
|
54
|
+
create: value['create']?.map(map),
|
|
55
|
+
update: value['update']?.map(map),
|
|
56
|
+
delete: value['delete']?.map(map),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case 'a2o': {
|
|
62
|
+
const related_collection = object[relationInfo.relation.meta.one_collection_field];
|
|
63
|
+
if (!related_collection) {
|
|
64
|
+
throw new InvalidQueryError({
|
|
65
|
+
reason: `When selecting '${collection.collection}.${field.field}', the field '${collection.collection}.${relationInfo.relation.meta.one_collection_field}' has to be selected when using versioning and m2a relations `,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
value = deepMapWithSchema(value, callback, {
|
|
69
|
+
schema: context.schema,
|
|
70
|
+
collection: related_collection,
|
|
71
|
+
relationInfo,
|
|
72
|
+
}, options);
|
|
73
|
+
leaf = false;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return callback([key, value], { collection, field, ...relationInfo, leaf, object });
|
|
79
|
+
})
|
|
80
|
+
.filter((f) => f));
|
|
81
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { PrimaryKey, Query, QueryOptions } from '@directus/types';
|
|
2
|
+
import type { ItemsService as ItemsServiceType } from '../../services/index.js';
|
|
3
|
+
export declare function handleVersion(self: ItemsServiceType, key: PrimaryKey, queryWithKey: Query, opts?: QueryOptions): Promise<any>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ForbiddenError } from '@directus/errors';
|
|
2
|
+
import { transaction } from '../transaction.js';
|
|
3
|
+
import { deepMapWithSchema } from './deep-map-with-schema.js';
|
|
4
|
+
import { splitRecursive } from './split-recursive.js';
|
|
5
|
+
export async function handleVersion(self, key, queryWithKey, opts) {
|
|
6
|
+
const { VersionsService } = await import('../../services/versions.js');
|
|
7
|
+
const { ItemsService } = await import('../../services/items.js');
|
|
8
|
+
if (queryWithKey.versionRaw) {
|
|
9
|
+
const originalData = await self.readByQuery(queryWithKey, opts);
|
|
10
|
+
if (originalData.length === 0) {
|
|
11
|
+
throw new ForbiddenError();
|
|
12
|
+
}
|
|
13
|
+
const versionsService = new VersionsService({
|
|
14
|
+
schema: self.schema,
|
|
15
|
+
accountability: self.accountability,
|
|
16
|
+
knex: self.knex,
|
|
17
|
+
});
|
|
18
|
+
const version = await versionsService.getVersionSave(queryWithKey.version, self.collection, key);
|
|
19
|
+
return Object.assign(originalData[0], version?.delta);
|
|
20
|
+
}
|
|
21
|
+
let result;
|
|
22
|
+
const versionsService = new VersionsService({
|
|
23
|
+
schema: self.schema,
|
|
24
|
+
accountability: self.accountability,
|
|
25
|
+
knex: self.knex,
|
|
26
|
+
});
|
|
27
|
+
const createdIDs = {};
|
|
28
|
+
const version = await versionsService.getVersionSave(queryWithKey.version, self.collection, key, false);
|
|
29
|
+
if (!version) {
|
|
30
|
+
throw new ForbiddenError();
|
|
31
|
+
}
|
|
32
|
+
const { delta } = version;
|
|
33
|
+
await transaction(self.knex, async (trx) => {
|
|
34
|
+
const itemsServiceAdmin = new ItemsService(self.collection, {
|
|
35
|
+
schema: self.schema,
|
|
36
|
+
accountability: {
|
|
37
|
+
admin: true,
|
|
38
|
+
},
|
|
39
|
+
knex: trx,
|
|
40
|
+
});
|
|
41
|
+
if (delta) {
|
|
42
|
+
const { rawDelta, defaultOverwrites } = splitRecursive(delta);
|
|
43
|
+
await itemsServiceAdmin.updateOne(key, rawDelta, {
|
|
44
|
+
emitEvents: false,
|
|
45
|
+
autoPurgeCache: false,
|
|
46
|
+
skipTracking: true,
|
|
47
|
+
overwriteDefaults: defaultOverwrites,
|
|
48
|
+
onItemCreate: (collection, pk) => {
|
|
49
|
+
if (collection in createdIDs === false)
|
|
50
|
+
createdIDs[collection] = [];
|
|
51
|
+
createdIDs[collection].push(pk);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const itemsServiceUser = new ItemsService(self.collection, {
|
|
56
|
+
schema: self.schema,
|
|
57
|
+
accountability: self.accountability,
|
|
58
|
+
knex: trx,
|
|
59
|
+
});
|
|
60
|
+
result = (await itemsServiceUser.readByQuery(queryWithKey, opts))[0];
|
|
61
|
+
await trx.rollback();
|
|
62
|
+
});
|
|
63
|
+
if (!result) {
|
|
64
|
+
throw new ForbiddenError();
|
|
65
|
+
}
|
|
66
|
+
return deepMapWithSchema(result, ([key, value], context) => {
|
|
67
|
+
if (context.relationType === 'm2o' || context.relationType === 'a2o') {
|
|
68
|
+
const ids = createdIDs[context.relation.related_collection];
|
|
69
|
+
const match = ids?.find((id) => String(id) === String(value));
|
|
70
|
+
if (match) {
|
|
71
|
+
return [key, null];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (context.relationType === 'o2m' && Array.isArray(value)) {
|
|
75
|
+
const ids = createdIDs[context.relation.collection];
|
|
76
|
+
return [
|
|
77
|
+
key,
|
|
78
|
+
value.map((val) => {
|
|
79
|
+
const match = ids?.find((id) => String(id) === String(val));
|
|
80
|
+
if (match) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return val;
|
|
84
|
+
}),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
if (context.field.field === context.collection.primary) {
|
|
88
|
+
const ids = createdIDs[context.collection.collection];
|
|
89
|
+
const match = ids?.find((id) => String(id) === String(value));
|
|
90
|
+
if (match) {
|
|
91
|
+
return [key, null];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [key, value];
|
|
95
|
+
}, { collection: self.collection, schema: self.schema });
|
|
96
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { cloneDeep } from 'lodash-es';
|
|
2
|
+
export function mergeVersionsRaw(item, versionData) {
|
|
3
|
+
const result = cloneDeep(item);
|
|
4
|
+
for (const versionRecord of versionData) {
|
|
5
|
+
for (const key of Object.keys(versionRecord)) {
|
|
6
|
+
result[key] = versionRecord[key];
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
return result;
|
|
10
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isPlainObject } from 'lodash-es';
|
|
2
|
+
export function splitRecursive(object) {
|
|
3
|
+
if (isPlainObject(object) && typeof object === 'object' && object !== null) {
|
|
4
|
+
const { _user, _date, ...rest } = object;
|
|
5
|
+
const defaultOverwrites = { _user, _date };
|
|
6
|
+
for (const key in rest) {
|
|
7
|
+
const { rawDelta, defaultOverwrites: innerDefaultOverwrites } = splitRecursive(rest[key]);
|
|
8
|
+
rest[key] = rawDelta;
|
|
9
|
+
if (innerDefaultOverwrites)
|
|
10
|
+
defaultOverwrites[key] = innerDefaultOverwrites;
|
|
11
|
+
}
|
|
12
|
+
return { rawDelta: rest, defaultOverwrites };
|
|
13
|
+
}
|
|
14
|
+
else if (Array.isArray(object)) {
|
|
15
|
+
const rest = [];
|
|
16
|
+
const defaultOverwrites = [];
|
|
17
|
+
for (const key in object) {
|
|
18
|
+
const { rawDelta, defaultOverwrites: innerDefaultOverwrites } = splitRecursive(object[key]);
|
|
19
|
+
rest[key] = rawDelta;
|
|
20
|
+
if (innerDefaultOverwrites)
|
|
21
|
+
defaultOverwrites[key] = innerDefaultOverwrites;
|
|
22
|
+
}
|
|
23
|
+
object.map((value) => splitRecursive(value));
|
|
24
|
+
return { rawDelta: rest, defaultOverwrites };
|
|
25
|
+
}
|
|
26
|
+
return { rawDelta: object, defaultOverwrites: undefined };
|
|
27
|
+
}
|