1
import { Action } from '@directus/constants';
2
import { InvalidPayloadError, UnprocessableContentError } from '@directus/errors';
3
import type { ContentVersion, Filter, Item, PrimaryKey, Query } from '@directus/types';
5
import { assign, pick } from 'lodash-es';
6
import objectHash from 'object-hash';
7
import { getCache } from '../cache.js';
8
import getDatabase from '../database/index.js';
9
import emitter from '../emitter.js';
10
import type { AbstractServiceOptions, MutationOptions } from '../types/index.js';
11
import { shouldClearCache } from '../utils/should-clear-cache.js';
12
import { ActivityService } from './activity.js';
13
import { AuthorizationService } from './authorization.js';
14
import { ItemsService } from './items.js';
15
import { PayloadService } from './payload.js';
16
import { RevisionsService } from './revisions.js';
18
export class VersionsService extends ItemsService {
19
authorizationService: AuthorizationService;
21
constructor(options: AbstractServiceOptions) {
22
super('directus_versions', options);
24
this.authorizationService = new AuthorizationService({
25
accountability: this.accountability,
31
private async validateCreateData(data: Partial<Item>): Promise<void> {
32
if (!data['key']) throw new InvalidPayloadError({ reason: `"key" is required` });
34
// Reserves the "main" version key for the version query parameter
35
if (data['key'] === 'main') throw new InvalidPayloadError({ reason: `"main" is a reserved version key` });
37
if (!data['collection']) {
38
throw new InvalidPayloadError({ reason: `"collection" is required` });
41
if (!data['item']) throw new InvalidPayloadError({ reason: `"item" is required` });
43
const { CollectionsService } = await import('./collections.js');
45
const collectionsService = new CollectionsService({
51
const existingCollection = await collectionsService.readOne(data['collection']);
53
if (!existingCollection.meta?.versioning) {
54
throw new UnprocessableContentError({
55
reason: `Content Versioning is not enabled for collection "${data['collection']}"`,
59
const existingVersions = await super.readByQuery({
60
aggregate: { count: ['*'] },
61
filter: { key: { _eq: data['key'] }, collection: { _eq: data['collection'] }, item: { _eq: data['item'] } },
64
if (existingVersions[0]!['count'] > 0) {
65
throw new UnprocessableContentError({
66
reason: `Version "${data['key']}" already exists for item "${data['item']}" in collection "${data['collection']}"`,
70
// will throw an error if the accountability does not have permission to read the item
71
await this.authorizationService.checkAccess('read', data['collection'], data['item']);
74
async getMainItem(collection: string, item: PrimaryKey, query?: Query): Promise<Item> {
75
// will throw an error if the accountability does not have permission to read the item
76
await this.authorizationService.checkAccess('read', collection, item);
78
const itemsService = new ItemsService(collection, {
80
accountability: this.accountability,
84
return await itemsService.readOne(item, query);
91
): Promise<{ outdated: boolean; mainHash: string }> {
92
const mainItem = await this.getMainItem(collection, item);
94
const mainHash = objectHash(mainItem);
96
return { outdated: hash !== mainHash, mainHash };
99
async getVersionSavesById(id: PrimaryKey): Promise<Partial<Item>[]> {
100
const revisionsService = new RevisionsService({
105
const result = await revisionsService.readByQuery({
106
filter: { version: { _eq: id } },
109
return result.map((revision) => revision['delta']);
112
async getVersionSaves(key: string, collection: string, item: string | undefined): Promise<Partial<Item>[] | null> {
113
const filter: Filter = {
115
collection: { _eq: collection },
119
filter['item'] = { _eq: item };
122
const versions = await this.readByQuery({ filter });
124
if (!versions?.[0]) return null;
126
const saves = await this.getVersionSavesById(versions[0]['id']);
131
override async createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> {
132
await this.validateCreateData(data);
134
const mainItem = await this.getMainItem(data['collection'], data['item']);
136
data['hash'] = objectHash(mainItem);
138
return super.createOne(data, opts);
141
override async createMany(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]> {
142
if (!Array.isArray(data)) {
143
throw new InvalidPayloadError({ reason: 'Input should be an array of items' });
146
const keyCombos = new Set();
148
for (const item of data) {
149
await this.validateCreateData(item);
151
const keyCombo = `${item['key']}-${item['collection']}-${item['item']}`;
153
if (keyCombos.has(keyCombo)) {
154
throw new UnprocessableContentError({
155
reason: `Cannot create multiple versions on "${item['item']}" in collection "${item['collection']}" with the same key "${item['key']}"`,
159
keyCombos.add(keyCombo);
161
const mainItem = await this.getMainItem(item['collection'], item['item']);
163
item['hash'] = objectHash(mainItem);
166
return super.createMany(data, opts);
169
override async updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]> {
170
// Only allow updates on "key" and "name" fields
171
const versionUpdateSchema = Joi.object({
173
name: Joi.string().allow(null).optional(),
176
const { error } = versionUpdateSchema.validate(data);
177
if (error) throw new InvalidPayloadError({ reason: error.message });
180
// Reserves the "main" version key for the version query parameter
181
if (data['key'] === 'main') throw new InvalidPayloadError({ reason: `"main" is a reserved version key` });
183
const keyCombos = new Set();
185
for (const pk of keys) {
186
const { collection, item } = await this.readOne(pk, { fields: ['collection', 'item'] });
188
const keyCombo = `${data['key']}-${collection}-${item}`;
190
if (keyCombos.has(keyCombo)) {
191
throw new UnprocessableContentError({
192
reason: `Cannot update multiple versions on "${item}" in collection "${collection}" to the same key "${data['key']}"`,
196
keyCombos.add(keyCombo);
198
const existingVersions = await super.readByQuery({
199
aggregate: { count: ['*'] },
200
filter: { id: { _neq: pk }, key: { _eq: data['key'] }, collection: { _eq: collection }, item: { _eq: item } },
203
if (existingVersions[0]!['count'] > 0) {
204
throw new UnprocessableContentError({
205
reason: `Version "${data['key']}" already exists for item "${item}" in collection "${collection}"`,
211
return super.updateMany(keys, data, opts);
214
async save(key: PrimaryKey, data: Partial<Item>) {
215
const version = await super.readOne(key);
217
const payloadService = new PayloadService(this.collection, {
218
accountability: this.accountability,
223
const activityService = new ActivityService({
228
const revisionsService = new RevisionsService({
233
const { item, collection } = version;
235
const activity = await activityService.createOne({
236
action: Action.VERSION_SAVE,
237
user: this.accountability?.user ?? null,
239
ip: this.accountability?.ip ?? null,
240
user_agent: this.accountability?.userAgent ?? null,
241
origin: this.accountability?.origin ?? null,
245
const revisionDelta = await payloadService.prepareDelta(data);
247
await revisionsService.createOne({
253
delta: revisionDelta,
256
const { cache } = getCache();
258
if (shouldClearCache(cache, undefined, collection)) {
265
async promote(version: PrimaryKey, mainHash: string, fields?: string[]) {
266
const { id, collection, item } = (await this.readOne(version)) as ContentVersion;
268
// will throw an error if the accountability does not have permission to update the item
269
await this.authorizationService.checkAccess('update', collection, item);
271
const { outdated } = await this.verifyHash(collection, item, mainHash);
274
throw new UnprocessableContentError({
275
reason: `Main item has changed since this version was last updated`,
279
const saves = await this.getVersionSavesById(id);
281
const versionResult = assign({}, ...saves);
283
const payloadToUpdate = fields ? pick(versionResult, fields) : versionResult;
285
const itemsService = new ItemsService(collection, {
286
accountability: this.accountability,
290
const payloadAfterHooks = await emitter.emitFilter(
291
['items.promote', `${collection}.items.promote`],
299
database: getDatabase(),
301
accountability: this.accountability,
305
const updatedItemKey = await itemsService.updateOne(item, payloadAfterHooks);
308
['items.promote', `${collection}.items.promote`],
310
payload: payloadAfterHooks,
312
item: updatedItemKey,
316
database: getDatabase(),
318
accountability: this.accountability,
322
return updatedItemKey;