@directus/api 13.0.0 → 13.1.0-beta.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/__utils__/snapshots.js +9 -0
- package/dist/app.js +2 -0
- package/dist/controllers/branches.d.ts +2 -0
- package/dist/controllers/branches.js +190 -0
- package/dist/database/migrations/20230823A-add-content-versioning.d.ts +3 -0
- package/dist/database/migrations/20230823A-add-content-versioning.js +26 -0
- package/dist/database/system-data/fields/branches.yaml +19 -0
- package/dist/database/system-data/fields/collections.yaml +19 -0
- package/dist/middleware/respond.js +20 -0
- package/dist/services/branches.d.ts +25 -0
- package/dist/services/branches.js +205 -0
- package/dist/types/collection.d.ts +1 -0
- package/dist/utils/sanitize-query.js +3 -0
- package/dist/utils/validate-query.js +1 -0
- package/package.json +14 -14
|
@@ -13,6 +13,7 @@ export const snapshotBeforeCreateCollection = {
|
|
|
13
13
|
item_duplication_fields: null,
|
|
14
14
|
note: null,
|
|
15
15
|
singleton: false,
|
|
16
|
+
branches_enabled: false,
|
|
16
17
|
translations: {},
|
|
17
18
|
},
|
|
18
19
|
schema: {
|
|
@@ -86,6 +87,7 @@ export const snapshotCreateCollection = {
|
|
|
86
87
|
item_duplication_fields: null,
|
|
87
88
|
note: null,
|
|
88
89
|
singleton: false,
|
|
90
|
+
branches_enabled: false,
|
|
89
91
|
translations: {},
|
|
90
92
|
},
|
|
91
93
|
schema: {
|
|
@@ -105,6 +107,7 @@ export const snapshotCreateCollection = {
|
|
|
105
107
|
item_duplication_fields: null,
|
|
106
108
|
note: null,
|
|
107
109
|
singleton: false,
|
|
110
|
+
branches_enabled: false,
|
|
108
111
|
translations: {},
|
|
109
112
|
},
|
|
110
113
|
schema: {
|
|
@@ -124,6 +127,7 @@ export const snapshotCreateCollection = {
|
|
|
124
127
|
item_duplication_fields: null,
|
|
125
128
|
note: null,
|
|
126
129
|
singleton: false,
|
|
130
|
+
branches_enabled: false,
|
|
127
131
|
translations: {},
|
|
128
132
|
},
|
|
129
133
|
schema: {
|
|
@@ -287,6 +291,7 @@ export const snapshotCreateCollectionNotNested = {
|
|
|
287
291
|
item_duplication_fields: null,
|
|
288
292
|
note: null,
|
|
289
293
|
singleton: false,
|
|
294
|
+
branches_enabled: false,
|
|
290
295
|
translations: {},
|
|
291
296
|
},
|
|
292
297
|
schema: {
|
|
@@ -306,6 +311,7 @@ export const snapshotCreateCollectionNotNested = {
|
|
|
306
311
|
item_duplication_fields: null,
|
|
307
312
|
note: null,
|
|
308
313
|
singleton: false,
|
|
314
|
+
branches_enabled: false,
|
|
309
315
|
translations: {},
|
|
310
316
|
},
|
|
311
317
|
schema: {
|
|
@@ -424,6 +430,7 @@ export const snapshotBeforeDeleteCollection = {
|
|
|
424
430
|
item_duplication_fields: null,
|
|
425
431
|
note: null,
|
|
426
432
|
singleton: false,
|
|
433
|
+
branches_enabled: false,
|
|
427
434
|
translations: {},
|
|
428
435
|
},
|
|
429
436
|
schema: {
|
|
@@ -443,6 +450,7 @@ export const snapshotBeforeDeleteCollection = {
|
|
|
443
450
|
item_duplication_fields: null,
|
|
444
451
|
note: null,
|
|
445
452
|
singleton: false,
|
|
453
|
+
branches_enabled: false,
|
|
446
454
|
translations: {},
|
|
447
455
|
},
|
|
448
456
|
schema: {
|
|
@@ -462,6 +470,7 @@ export const snapshotBeforeDeleteCollection = {
|
|
|
462
470
|
item_duplication_fields: null,
|
|
463
471
|
note: null,
|
|
464
472
|
singleton: false,
|
|
473
|
+
branches_enabled: false,
|
|
465
474
|
translations: {},
|
|
466
475
|
},
|
|
467
476
|
schema: {
|
package/dist/app.js
CHANGED
|
@@ -10,6 +10,7 @@ import { registerAuthProviders } from './auth.js';
|
|
|
10
10
|
import activityRouter from './controllers/activity.js';
|
|
11
11
|
import assetsRouter from './controllers/assets.js';
|
|
12
12
|
import authRouter from './controllers/auth.js';
|
|
13
|
+
import branchesRouter from './controllers/branches.js';
|
|
13
14
|
import collectionsRouter from './controllers/collections.js';
|
|
14
15
|
import dashboardsRouter from './controllers/dashboards.js';
|
|
15
16
|
import extensionsRouter from './controllers/extensions.js';
|
|
@@ -199,6 +200,7 @@ export default async function createApp() {
|
|
|
199
200
|
app.use('/graphql', graphqlRouter);
|
|
200
201
|
app.use('/activity', activityRouter);
|
|
201
202
|
app.use('/assets', assetsRouter);
|
|
203
|
+
app.use('/branches', branchesRouter);
|
|
202
204
|
app.use('/collections', collectionsRouter);
|
|
203
205
|
app.use('/dashboards', dashboardsRouter);
|
|
204
206
|
app.use('/extensions', extensionsRouter);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { isDirectusError } from '@directus/errors';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { assign } from 'lodash-es';
|
|
4
|
+
import { ErrorCode, InvalidPayloadError } from '../errors/index.js';
|
|
5
|
+
import { respond } from '../middleware/respond.js';
|
|
6
|
+
import useCollection from '../middleware/use-collection.js';
|
|
7
|
+
import { validateBatch } from '../middleware/validate-batch.js';
|
|
8
|
+
import { BranchesService } from '../services/branches.js';
|
|
9
|
+
import { MetaService } from '../services/meta.js';
|
|
10
|
+
import asyncHandler from '../utils/async-handler.js';
|
|
11
|
+
import { sanitizeQuery } from '../utils/sanitize-query.js';
|
|
12
|
+
const router = express.Router();
|
|
13
|
+
router.use(useCollection('directus_branches'));
|
|
14
|
+
router.post('/', asyncHandler(async (req, res, next) => {
|
|
15
|
+
const service = new BranchesService({
|
|
16
|
+
accountability: req.accountability,
|
|
17
|
+
schema: req.schema,
|
|
18
|
+
});
|
|
19
|
+
const savedKeys = [];
|
|
20
|
+
if (Array.isArray(req.body)) {
|
|
21
|
+
const keys = await service.createMany(req.body);
|
|
22
|
+
savedKeys.push(...keys);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
const primaryKey = await service.createOne(req.body);
|
|
26
|
+
savedKeys.push(primaryKey);
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
if (Array.isArray(req.body)) {
|
|
30
|
+
const records = await service.readMany(savedKeys, req.sanitizedQuery);
|
|
31
|
+
res.locals['payload'] = { data: records };
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
const record = await service.readOne(savedKeys[0], req.sanitizedQuery);
|
|
35
|
+
res.locals['payload'] = { data: record };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (isDirectusError(error, ErrorCode.Forbidden)) {
|
|
40
|
+
return next();
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
return next();
|
|
45
|
+
}), respond);
|
|
46
|
+
const readHandler = asyncHandler(async (req, res, next) => {
|
|
47
|
+
const service = new BranchesService({
|
|
48
|
+
accountability: req.accountability,
|
|
49
|
+
schema: req.schema,
|
|
50
|
+
});
|
|
51
|
+
const metaService = new MetaService({
|
|
52
|
+
accountability: req.accountability,
|
|
53
|
+
schema: req.schema,
|
|
54
|
+
});
|
|
55
|
+
let result;
|
|
56
|
+
if (req.singleton) {
|
|
57
|
+
result = await service.readSingleton(req.sanitizedQuery);
|
|
58
|
+
}
|
|
59
|
+
else if (req.body.keys) {
|
|
60
|
+
result = await service.readMany(req.body.keys, req.sanitizedQuery);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
result = await service.readByQuery(req.sanitizedQuery);
|
|
64
|
+
}
|
|
65
|
+
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
|
|
66
|
+
res.locals['payload'] = { data: result, meta };
|
|
67
|
+
return next();
|
|
68
|
+
});
|
|
69
|
+
router.get('/', validateBatch('read'), readHandler, respond);
|
|
70
|
+
router.search('/', validateBatch('read'), readHandler, respond);
|
|
71
|
+
router.get('/:pk', asyncHandler(async (req, res, next) => {
|
|
72
|
+
const service = new BranchesService({
|
|
73
|
+
accountability: req.accountability,
|
|
74
|
+
schema: req.schema,
|
|
75
|
+
});
|
|
76
|
+
const record = await service.readOne(req.params['pk'], req.sanitizedQuery);
|
|
77
|
+
res.locals['payload'] = { data: record || null };
|
|
78
|
+
return next();
|
|
79
|
+
}), respond);
|
|
80
|
+
router.patch('/', validateBatch('update'), asyncHandler(async (req, res, next) => {
|
|
81
|
+
const service = new BranchesService({
|
|
82
|
+
accountability: req.accountability,
|
|
83
|
+
schema: req.schema,
|
|
84
|
+
});
|
|
85
|
+
let keys = [];
|
|
86
|
+
if (Array.isArray(req.body)) {
|
|
87
|
+
keys = await service.updateBatch(req.body);
|
|
88
|
+
}
|
|
89
|
+
else if (req.body.keys) {
|
|
90
|
+
keys = await service.updateMany(req.body.keys, req.body.data);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const sanitizedQuery = sanitizeQuery(req.body.query, req.accountability);
|
|
94
|
+
keys = await service.updateByQuery(sanitizedQuery, req.body.data);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const result = await service.readMany(keys, req.sanitizedQuery);
|
|
98
|
+
res.locals['payload'] = { data: result || null };
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (isDirectusError(error, ErrorCode.Forbidden)) {
|
|
102
|
+
return next();
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
return next();
|
|
107
|
+
}), respond);
|
|
108
|
+
router.patch('/:pk', asyncHandler(async (req, res, next) => {
|
|
109
|
+
const service = new BranchesService({
|
|
110
|
+
accountability: req.accountability,
|
|
111
|
+
schema: req.schema,
|
|
112
|
+
});
|
|
113
|
+
const primaryKey = await service.updateOne(req.params['pk'], req.body);
|
|
114
|
+
try {
|
|
115
|
+
const record = await service.readOne(primaryKey, req.sanitizedQuery);
|
|
116
|
+
res.locals['payload'] = { data: record || null };
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (isDirectusError(error, ErrorCode.Forbidden)) {
|
|
120
|
+
return next();
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
return next();
|
|
125
|
+
}), respond);
|
|
126
|
+
router.delete('/', validateBatch('delete'), asyncHandler(async (req, _res, next) => {
|
|
127
|
+
const service = new BranchesService({
|
|
128
|
+
accountability: req.accountability,
|
|
129
|
+
schema: req.schema,
|
|
130
|
+
});
|
|
131
|
+
if (Array.isArray(req.body)) {
|
|
132
|
+
await service.deleteMany(req.body);
|
|
133
|
+
}
|
|
134
|
+
else if (req.body.keys) {
|
|
135
|
+
await service.deleteMany(req.body.keys);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const sanitizedQuery = sanitizeQuery(req.body.query, req.accountability);
|
|
139
|
+
await service.deleteByQuery(sanitizedQuery);
|
|
140
|
+
}
|
|
141
|
+
return next();
|
|
142
|
+
}), respond);
|
|
143
|
+
router.delete('/:pk', asyncHandler(async (req, _res, next) => {
|
|
144
|
+
const service = new BranchesService({
|
|
145
|
+
accountability: req.accountability,
|
|
146
|
+
schema: req.schema,
|
|
147
|
+
});
|
|
148
|
+
await service.deleteOne(req.params['pk']);
|
|
149
|
+
return next();
|
|
150
|
+
}), respond);
|
|
151
|
+
router.get('/:pk/compare', asyncHandler(async (req, res, next) => {
|
|
152
|
+
const service = new BranchesService({
|
|
153
|
+
accountability: req.accountability,
|
|
154
|
+
schema: req.schema,
|
|
155
|
+
});
|
|
156
|
+
const branch = await service.readOne(req.params['pk']);
|
|
157
|
+
const { outdated, mainHash } = await service.verifyHash(branch['collection'], branch['item'], branch['hash']);
|
|
158
|
+
const commits = await service.getBranchCommits(branch['id']);
|
|
159
|
+
const current = assign({}, ...commits);
|
|
160
|
+
const fields = Object.keys(current);
|
|
161
|
+
const mainBranchItem = await service.getMainBranchItem(branch['collection'], branch['item'], fields.length > 0 ? { fields } : undefined);
|
|
162
|
+
res.locals['payload'] = { data: { outdated, mainHash, current, main: mainBranchItem } };
|
|
163
|
+
return next();
|
|
164
|
+
}), respond);
|
|
165
|
+
router.post('/:pk/commit', asyncHandler(async (req, res, next) => {
|
|
166
|
+
const service = new BranchesService({
|
|
167
|
+
accountability: req.accountability,
|
|
168
|
+
schema: req.schema,
|
|
169
|
+
});
|
|
170
|
+
const branch = await service.readOne(req.params['pk']);
|
|
171
|
+
const mainBranchItem = await service.getMainBranchItem(branch['collection'], branch['item']);
|
|
172
|
+
await service.commit(req.params['pk'], req.body);
|
|
173
|
+
const commits = await service.getBranchCommits(req.params['pk']);
|
|
174
|
+
const result = assign(mainBranchItem, ...commits);
|
|
175
|
+
res.locals['payload'] = { data: result || null };
|
|
176
|
+
return next();
|
|
177
|
+
}), respond);
|
|
178
|
+
router.post('/:pk/merge', asyncHandler(async (req, res, next) => {
|
|
179
|
+
if (typeof req.body.mainHash !== 'string') {
|
|
180
|
+
throw new InvalidPayloadError({ reason: `"mainHash" field is required` });
|
|
181
|
+
}
|
|
182
|
+
const service = new BranchesService({
|
|
183
|
+
accountability: req.accountability,
|
|
184
|
+
schema: req.schema,
|
|
185
|
+
});
|
|
186
|
+
const updatedItemKey = await service.merge(req.params['pk'], req.body.mainHash, req.body?.['fields']);
|
|
187
|
+
res.locals['payload'] = { data: updatedItemKey || null };
|
|
188
|
+
return next();
|
|
189
|
+
}), respond);
|
|
190
|
+
export default router;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export async function up(knex) {
|
|
2
|
+
await knex.schema.createTable('directus_branches', (table) => {
|
|
3
|
+
table.uuid('id').primary().notNullable();
|
|
4
|
+
table.string('name').notNullable();
|
|
5
|
+
table.string('collection', 64).references('collection').inTable('directus_collections').onDelete('CASCADE');
|
|
6
|
+
table.string('item');
|
|
7
|
+
table.string('hash').notNullable();
|
|
8
|
+
table.timestamp('date_created').defaultTo(knex.fn.now());
|
|
9
|
+
table.uuid('user_created').references('id').inTable('directus_users').onDelete('SET NULL');
|
|
10
|
+
});
|
|
11
|
+
await knex.schema.alterTable('directus_collections', (table) => {
|
|
12
|
+
table.boolean('branches_enabled').notNullable().defaultTo(false);
|
|
13
|
+
});
|
|
14
|
+
await knex.schema.alterTable('directus_revisions', (table) => {
|
|
15
|
+
table.uuid('branch').references('id').inTable('directus_branches').onDelete('CASCADE');
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export async function down(knex) {
|
|
19
|
+
await knex.schema.dropTable('directus_branches');
|
|
20
|
+
await knex.schema.alterTable('directus_collections', (table) => {
|
|
21
|
+
table.dropColumn('branches_enabled');
|
|
22
|
+
});
|
|
23
|
+
await knex.schema.alterTable('directus_revisions', (table) => {
|
|
24
|
+
table.dropColumn('branch');
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
table: directus_branches
|
|
2
|
+
|
|
3
|
+
fields:
|
|
4
|
+
- field: id
|
|
5
|
+
special:
|
|
6
|
+
- uuid
|
|
7
|
+
readonly: true
|
|
8
|
+
hidden: true
|
|
9
|
+
- field: name
|
|
10
|
+
- field: collection
|
|
11
|
+
- field: item
|
|
12
|
+
- field: hash
|
|
13
|
+
- field: date_created
|
|
14
|
+
special:
|
|
15
|
+
- date-created
|
|
16
|
+
- cast-timestamp
|
|
17
|
+
- field: user_created
|
|
18
|
+
special:
|
|
19
|
+
- user-created
|
|
@@ -114,8 +114,27 @@ fields:
|
|
|
114
114
|
interface: system-display-template
|
|
115
115
|
options:
|
|
116
116
|
collectionField: collection
|
|
117
|
+
injectBranchField: true
|
|
117
118
|
width: full
|
|
118
119
|
|
|
120
|
+
- field: content_versioning_divider
|
|
121
|
+
special:
|
|
122
|
+
- alias
|
|
123
|
+
- no-data
|
|
124
|
+
interface: presentation-divider
|
|
125
|
+
options:
|
|
126
|
+
icon: update
|
|
127
|
+
title: $t:field_options.directus_collections.content_versioning_divider
|
|
128
|
+
width: full
|
|
129
|
+
|
|
130
|
+
- field: branches_enabled
|
|
131
|
+
interface: boolean
|
|
132
|
+
special:
|
|
133
|
+
- cast-boolean
|
|
134
|
+
options:
|
|
135
|
+
label: $t:field_options.directus_collections.enable_branches
|
|
136
|
+
width: half
|
|
137
|
+
|
|
119
138
|
- field: archive_divider
|
|
120
139
|
special:
|
|
121
140
|
- alias
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { parse as parseBytesConfiguration } from 'bytes';
|
|
2
|
+
import { assign } from 'lodash-es';
|
|
2
3
|
import { getCache, setCacheValue } from '../cache.js';
|
|
3
4
|
import env from '../env.js';
|
|
4
5
|
import logger from '../logger.js';
|
|
6
|
+
import { BranchesService } from '../services/branches.js';
|
|
5
7
|
import { ExportService } from '../services/import-export.js';
|
|
6
8
|
import asyncHandler from '../utils/async-handler.js';
|
|
7
9
|
import { getCacheControlHeader } from '../utils/get-cache-headers.js';
|
|
@@ -39,6 +41,24 @@ export const respond = asyncHandler(async (req, res) => {
|
|
|
39
41
|
res.setHeader('Cache-Control', 'no-cache');
|
|
40
42
|
res.setHeader('Vary', 'Origin, Cache-Control');
|
|
41
43
|
}
|
|
44
|
+
if (req.sanitizedQuery.branch &&
|
|
45
|
+
req.collection &&
|
|
46
|
+
(req.singleton || req.params['pk']) &&
|
|
47
|
+
'data' in res.locals['payload']) {
|
|
48
|
+
const branchesService = new BranchesService({ accountability: req.accountability ?? null, schema: req.schema });
|
|
49
|
+
const filter = {
|
|
50
|
+
name: { _eq: req.sanitizedQuery.branch },
|
|
51
|
+
collection: { _eq: req.collection },
|
|
52
|
+
};
|
|
53
|
+
if (req.params['pk']) {
|
|
54
|
+
filter['item'] = { _eq: req.params['pk'] };
|
|
55
|
+
}
|
|
56
|
+
const branch = await branchesService.readByQuery({ filter });
|
|
57
|
+
if (branch[0]) {
|
|
58
|
+
const commits = await branchesService.getBranchCommits(branch[0]['id']);
|
|
59
|
+
assign(res.locals['payload'].data, ...commits);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
42
62
|
if (req.sanitizedQuery.export) {
|
|
43
63
|
const exportService = new ExportService({ accountability: req.accountability ?? null, schema: req.schema });
|
|
44
64
|
let filename = '';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Item, PrimaryKey, Query } from '@directus/types';
|
|
2
|
+
import type { AbstractServiceOptions, MutationOptions } from '../types/index.js';
|
|
3
|
+
import { AuthorizationService } from './authorization.js';
|
|
4
|
+
import { CollectionsService } from './collections.js';
|
|
5
|
+
import { ItemsService } from './items.js';
|
|
6
|
+
export declare class BranchesService extends ItemsService {
|
|
7
|
+
authorizationService: AuthorizationService;
|
|
8
|
+
collectionsService: CollectionsService;
|
|
9
|
+
constructor(options: AbstractServiceOptions);
|
|
10
|
+
private validateCreateData;
|
|
11
|
+
private validateUpdateData;
|
|
12
|
+
getMainBranchItem(collection: string, item: PrimaryKey, query?: Query): Promise<Item>;
|
|
13
|
+
verifyHash(collection: string, item: PrimaryKey, hash: string): Promise<{
|
|
14
|
+
outdated: boolean;
|
|
15
|
+
mainHash: string;
|
|
16
|
+
}>;
|
|
17
|
+
getBranchCommits(key: PrimaryKey): Promise<Partial<Item>[]>;
|
|
18
|
+
createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey>;
|
|
19
|
+
createMany(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]>;
|
|
20
|
+
updateBatch(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]>;
|
|
21
|
+
updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]>;
|
|
22
|
+
updateByQuery(query: Query, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]>;
|
|
23
|
+
commit(key: PrimaryKey, data: Partial<Item>): Promise<Partial<Item>>;
|
|
24
|
+
merge(branch: PrimaryKey, mainHash: string, fields?: string[]): Promise<import("../types/items.js").PrimaryKey>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import Joi from 'joi';
|
|
2
|
+
import { assign, pick } from 'lodash-es';
|
|
3
|
+
import objectHash from 'object-hash';
|
|
4
|
+
import getDatabase from '../database/index.js';
|
|
5
|
+
import emitter from '../emitter.js';
|
|
6
|
+
import { InvalidPayloadError, UnprocessableContentError } from '../errors/index.js';
|
|
7
|
+
import { ActivityService } from './activity.js';
|
|
8
|
+
import { AuthorizationService } from './authorization.js';
|
|
9
|
+
import { CollectionsService } from './collections.js';
|
|
10
|
+
import { ItemsService } from './items.js';
|
|
11
|
+
import { PayloadService } from './payload.js';
|
|
12
|
+
import { RevisionsService } from './revisions.js';
|
|
13
|
+
const branchUpdateSchema = Joi.object({
|
|
14
|
+
name: Joi.string(),
|
|
15
|
+
});
|
|
16
|
+
export class BranchesService extends ItemsService {
|
|
17
|
+
authorizationService;
|
|
18
|
+
collectionsService;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
super('directus_branches', options);
|
|
21
|
+
this.authorizationService = new AuthorizationService({
|
|
22
|
+
accountability: this.accountability,
|
|
23
|
+
knex: this.knex,
|
|
24
|
+
schema: this.schema,
|
|
25
|
+
});
|
|
26
|
+
this.collectionsService = new CollectionsService({
|
|
27
|
+
accountability: this.accountability,
|
|
28
|
+
knex: this.knex,
|
|
29
|
+
schema: this.schema,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async validateCreateData(data) {
|
|
33
|
+
if (!data['name'])
|
|
34
|
+
throw new InvalidPayloadError({ reason: `"name" is required` });
|
|
35
|
+
// Reserves the "main" branch name for the branch query parameter
|
|
36
|
+
if (data['name'] === 'main')
|
|
37
|
+
throw new InvalidPayloadError({ reason: `"main" is a reserved branch name` });
|
|
38
|
+
if (!data['collection']) {
|
|
39
|
+
throw new InvalidPayloadError({ reason: `"collection" is required` });
|
|
40
|
+
}
|
|
41
|
+
if (!data['item'])
|
|
42
|
+
throw new InvalidPayloadError({ reason: `"item" is required` });
|
|
43
|
+
// will throw an error if the collection does not exist or the accountability does not have permission to read it
|
|
44
|
+
const existingCollection = await this.collectionsService.readOne(data['collection']);
|
|
45
|
+
if (!existingCollection.meta?.branches_enabled) {
|
|
46
|
+
throw new UnprocessableContentError({
|
|
47
|
+
reason: `Branch feature is not enabled for collection "${data['collection']}"`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const existingBranches = await super.readByQuery({
|
|
51
|
+
fields: ['name', 'collection', 'item'],
|
|
52
|
+
filter: { name: { _eq: data['name'] }, collection: { _eq: data['collection'] }, item: { _eq: data['item'] } },
|
|
53
|
+
});
|
|
54
|
+
if (existingBranches.length > 0) {
|
|
55
|
+
throw new UnprocessableContentError({
|
|
56
|
+
reason: `Branch "${data['name']}" already exists for item "${data['item']}" in collection "${data['collection']}"`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// will throw an error if the accountability does not have permission to read the item
|
|
60
|
+
await this.authorizationService.checkAccess('read', data['collection'], data['item']);
|
|
61
|
+
}
|
|
62
|
+
async validateUpdateData(data) {
|
|
63
|
+
// Only allow updates on "name" field
|
|
64
|
+
const { error } = branchUpdateSchema.validate(data);
|
|
65
|
+
if (error)
|
|
66
|
+
throw new InvalidPayloadError({ reason: error.message });
|
|
67
|
+
// Reserves the "main" branch name for the branch query parameter
|
|
68
|
+
if ('name' in data && data['name'] === 'main') {
|
|
69
|
+
throw new InvalidPayloadError({ reason: `"main" is a reserved branch name` });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async getMainBranchItem(collection, item, query) {
|
|
73
|
+
// will throw an error if the accountability does not have permission to read the item
|
|
74
|
+
await this.authorizationService.checkAccess('read', collection, item);
|
|
75
|
+
const itemsService = new ItemsService(collection, {
|
|
76
|
+
knex: this.knex,
|
|
77
|
+
accountability: this.accountability,
|
|
78
|
+
schema: this.schema,
|
|
79
|
+
});
|
|
80
|
+
return await itemsService.readOne(item, query);
|
|
81
|
+
}
|
|
82
|
+
async verifyHash(collection, item, hash) {
|
|
83
|
+
const mainBranchItem = await this.getMainBranchItem(collection, item);
|
|
84
|
+
const mainHash = objectHash(mainBranchItem);
|
|
85
|
+
return { outdated: hash !== mainHash, mainHash };
|
|
86
|
+
}
|
|
87
|
+
async getBranchCommits(key) {
|
|
88
|
+
const revisionsService = new RevisionsService({
|
|
89
|
+
knex: this.knex,
|
|
90
|
+
schema: this.schema,
|
|
91
|
+
});
|
|
92
|
+
const result = await revisionsService.readByQuery({
|
|
93
|
+
filter: { branch: { _eq: key } },
|
|
94
|
+
});
|
|
95
|
+
return result.map((revision) => revision['delta']);
|
|
96
|
+
}
|
|
97
|
+
async createOne(data, opts) {
|
|
98
|
+
await this.validateCreateData(data);
|
|
99
|
+
const mainBranchItem = await this.getMainBranchItem(data['collection'], data['item']);
|
|
100
|
+
data['hash'] = objectHash(mainBranchItem);
|
|
101
|
+
return super.createOne(data, opts);
|
|
102
|
+
}
|
|
103
|
+
async createMany(data, opts) {
|
|
104
|
+
if (!Array.isArray(data)) {
|
|
105
|
+
throw new InvalidPayloadError({ reason: 'Input should be an array of items' });
|
|
106
|
+
}
|
|
107
|
+
for (const item of data) {
|
|
108
|
+
await this.validateCreateData(item);
|
|
109
|
+
const mainBranchItem = await this.getMainBranchItem(item['collection'], item['item']);
|
|
110
|
+
item['hash'] = objectHash(mainBranchItem);
|
|
111
|
+
}
|
|
112
|
+
return super.createMany(data, opts);
|
|
113
|
+
}
|
|
114
|
+
async updateBatch(data, opts) {
|
|
115
|
+
if (!Array.isArray(data)) {
|
|
116
|
+
throw new InvalidPayloadError({ reason: 'Input should be an array of items' });
|
|
117
|
+
}
|
|
118
|
+
for (const item of data) {
|
|
119
|
+
await this.validateUpdateData(item);
|
|
120
|
+
}
|
|
121
|
+
return super.updateBatch(data, opts);
|
|
122
|
+
}
|
|
123
|
+
async updateMany(keys, data, opts) {
|
|
124
|
+
await this.validateUpdateData(data);
|
|
125
|
+
return super.updateMany(keys, data, opts);
|
|
126
|
+
}
|
|
127
|
+
async updateByQuery(query, data, opts) {
|
|
128
|
+
await this.validateUpdateData(data);
|
|
129
|
+
return super.updateByQuery(query, data, opts);
|
|
130
|
+
}
|
|
131
|
+
async commit(key, data) {
|
|
132
|
+
const branch = await super.readOne(key);
|
|
133
|
+
const payloadService = new PayloadService(this.collection, {
|
|
134
|
+
accountability: this.accountability,
|
|
135
|
+
knex: this.knex,
|
|
136
|
+
schema: this.schema,
|
|
137
|
+
});
|
|
138
|
+
const activityService = new ActivityService({
|
|
139
|
+
knex: this.knex,
|
|
140
|
+
schema: this.schema,
|
|
141
|
+
});
|
|
142
|
+
const revisionsService = new RevisionsService({
|
|
143
|
+
knex: this.knex,
|
|
144
|
+
schema: this.schema,
|
|
145
|
+
});
|
|
146
|
+
const activity = await activityService.createOne({
|
|
147
|
+
action: 'commit',
|
|
148
|
+
user: this.accountability?.user ?? null,
|
|
149
|
+
collection: branch['collection'],
|
|
150
|
+
ip: this.accountability?.ip ?? null,
|
|
151
|
+
user_agent: this.accountability?.userAgent ?? null,
|
|
152
|
+
origin: this.accountability?.origin ?? null,
|
|
153
|
+
item: branch['item'],
|
|
154
|
+
});
|
|
155
|
+
const revisionDelta = await payloadService.prepareDelta(data);
|
|
156
|
+
await revisionsService.createOne({
|
|
157
|
+
activity,
|
|
158
|
+
branch: key,
|
|
159
|
+
collection: branch['collection'],
|
|
160
|
+
item: branch['item'],
|
|
161
|
+
data: revisionDelta,
|
|
162
|
+
delta: revisionDelta,
|
|
163
|
+
});
|
|
164
|
+
return data;
|
|
165
|
+
}
|
|
166
|
+
async merge(branch, mainHash, fields) {
|
|
167
|
+
const { id, collection, item } = (await this.readOne(branch));
|
|
168
|
+
// will throw an error if the accountability does not have permission to update the item
|
|
169
|
+
await this.authorizationService.checkAccess('update', collection, item);
|
|
170
|
+
const { outdated } = await this.verifyHash(collection, item, mainHash);
|
|
171
|
+
if (outdated) {
|
|
172
|
+
throw new UnprocessableContentError({
|
|
173
|
+
reason: `Main branch has changed since this branch was last updated`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const commits = await this.getBranchCommits(id);
|
|
177
|
+
const branchResult = assign({}, ...commits);
|
|
178
|
+
const payloadToUpdate = fields ? pick(branchResult, fields) : branchResult;
|
|
179
|
+
const itemsService = new ItemsService(collection, {
|
|
180
|
+
accountability: this.accountability,
|
|
181
|
+
schema: this.schema,
|
|
182
|
+
});
|
|
183
|
+
const payloadAfterHooks = await emitter.emitFilter(['items.merge', `${collection}.items.merge`], payloadToUpdate, {
|
|
184
|
+
collection,
|
|
185
|
+
item,
|
|
186
|
+
branch,
|
|
187
|
+
}, {
|
|
188
|
+
database: getDatabase(),
|
|
189
|
+
schema: this.schema,
|
|
190
|
+
accountability: this.accountability,
|
|
191
|
+
});
|
|
192
|
+
const updatedItemKey = await itemsService.updateOne(item, payloadAfterHooks);
|
|
193
|
+
emitter.emitAction(['items.merge', `${collection}.items.merge`], {
|
|
194
|
+
payload: payloadAfterHooks,
|
|
195
|
+
collection,
|
|
196
|
+
item: updatedItemKey,
|
|
197
|
+
branch,
|
|
198
|
+
}, {
|
|
199
|
+
database: getDatabase(),
|
|
200
|
+
schema: this.schema,
|
|
201
|
+
accountability: this.accountability,
|
|
202
|
+
});
|
|
203
|
+
return updatedItemKey;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -7,6 +7,7 @@ export type CollectionMeta = {
|
|
|
7
7
|
singleton: boolean;
|
|
8
8
|
icon: string | null;
|
|
9
9
|
translations: Record<string, string>;
|
|
10
|
+
branches_enabled: boolean;
|
|
10
11
|
item_duplication_fields: string[] | null;
|
|
11
12
|
accountability: 'all' | 'accountability' | null;
|
|
12
13
|
group: string | null;
|
|
@@ -46,6 +46,9 @@ export function sanitizeQuery(rawQuery, accountability) {
|
|
|
46
46
|
if (rawQuery['search'] && typeof rawQuery['search'] === 'string') {
|
|
47
47
|
query.search = rawQuery['search'];
|
|
48
48
|
}
|
|
49
|
+
if (rawQuery['branch']) {
|
|
50
|
+
query.branch = rawQuery['branch'];
|
|
51
|
+
}
|
|
49
52
|
if (rawQuery['export']) {
|
|
50
53
|
query.export = rawQuery['export'];
|
|
51
54
|
}
|
|
@@ -17,6 +17,7 @@ const querySchema = Joi.object({
|
|
|
17
17
|
meta: Joi.array().items(Joi.string().valid('total_count', 'filter_count')),
|
|
18
18
|
search: Joi.string(),
|
|
19
19
|
export: Joi.string().valid('csv', 'json', 'xml', 'yaml'),
|
|
20
|
+
branch: Joi.string(),
|
|
20
21
|
aggregate: Joi.object(),
|
|
21
22
|
deep: Joi.object(),
|
|
22
23
|
alias: Joi.object(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@directus/api",
|
|
3
|
-
"version": "13.0.0",
|
|
3
|
+
"version": "13.1.0-beta.0",
|
|
4
4
|
"description": "Directus is a real-time API and App dashboard for managing SQL database content",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"directus",
|
|
@@ -143,22 +143,22 @@
|
|
|
143
143
|
"ws": "8.12.1",
|
|
144
144
|
"zod": "3.21.4",
|
|
145
145
|
"zod-validation-error": "1.0.1",
|
|
146
|
-
"@directus/app": "10.
|
|
147
|
-
"@directus/constants": "10.2.
|
|
146
|
+
"@directus/app": "10.8.0-beta.0",
|
|
147
|
+
"@directus/constants": "10.2.4-beta.0",
|
|
148
148
|
"@directus/errors": "0.0.2",
|
|
149
|
-
"@directus/extensions-sdk": "10.1.
|
|
150
|
-
"@directus/pressure": "1.0.
|
|
149
|
+
"@directus/extensions-sdk": "10.1.10-beta.0",
|
|
150
|
+
"@directus/pressure": "1.0.9-beta.0",
|
|
151
151
|
"@directus/schema": "10.0.2",
|
|
152
152
|
"@directus/specs": "10.2.0",
|
|
153
153
|
"@directus/storage": "10.0.5",
|
|
154
|
-
"@directus/storage-driver-azure": "10.0.
|
|
155
|
-
"@directus/storage-driver-cloudinary": "10.0.
|
|
156
|
-
"@directus/storage-driver-gcs": "10.0.
|
|
157
|
-
"@directus/storage-driver-local": "10.0.
|
|
158
|
-
"@directus/storage-driver-s3": "10.0.
|
|
159
|
-
"@directus/storage-driver-supabase": "1.0.
|
|
160
|
-
"@directus/utils": "10.0.
|
|
161
|
-
"@directus/validation": "0.0.
|
|
154
|
+
"@directus/storage-driver-azure": "10.0.10-beta.0",
|
|
155
|
+
"@directus/storage-driver-cloudinary": "10.0.10-beta.0",
|
|
156
|
+
"@directus/storage-driver-gcs": "10.0.10-beta.0",
|
|
157
|
+
"@directus/storage-driver-local": "10.0.10-beta.0",
|
|
158
|
+
"@directus/storage-driver-s3": "10.0.10-beta.0",
|
|
159
|
+
"@directus/storage-driver-supabase": "1.0.2-beta.0",
|
|
160
|
+
"@directus/utils": "10.0.10-beta.0",
|
|
161
|
+
"@directus/validation": "0.0.5-beta.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@ngneat/falso": "6.4.0",
|
|
@@ -207,7 +207,7 @@
|
|
|
207
207
|
"vitest": "0.31.1",
|
|
208
208
|
"@directus/random": "0.2.2",
|
|
209
209
|
"@directus/tsconfig": "1.0.0",
|
|
210
|
-
"@directus/types": "10.1.
|
|
210
|
+
"@directus/types": "10.1.6-beta.0"
|
|
211
211
|
},
|
|
212
212
|
"optionalDependencies": {
|
|
213
213
|
"@keyv/redis": "2.5.8",
|