directus-template-cli 0.7.8 → 0.8.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/README.md +134 -37
- package/dist/commands/apply.d.ts +5 -0
- package/dist/commands/apply.js +32 -68
- package/dist/commands/extract.d.ts +30 -0
- package/dist/commands/extract.js +14 -5
- package/dist/commands/init.d.ts +4 -0
- package/dist/commands/init.js +90 -87
- package/dist/lib/extract/expand-deep-plan.d.ts +2 -0
- package/dist/lib/extract/expand-deep-plan.js +54 -0
- package/dist/lib/extract/expand-schema-plan.d.ts +2 -0
- package/dist/lib/extract/expand-schema-plan.js +55 -0
- package/dist/lib/extract/extract-assets.js +19 -6
- package/dist/lib/extract/extract-collections.d.ts +2 -1
- package/dist/lib/extract/extract-collections.js +5 -2
- package/dist/lib/extract/extract-content.d.ts +2 -1
- package/dist/lib/extract/extract-content.js +108 -13
- package/dist/lib/extract/extract-fields.d.ts +2 -1
- package/dist/lib/extract/extract-fields.js +5 -5
- package/dist/lib/extract/extract-relations.d.ts +2 -1
- package/dist/lib/extract/extract-relations.js +6 -4
- package/dist/lib/extract/index.d.ts +2 -1
- package/dist/lib/extract/index.js +67 -29
- package/dist/lib/init/index.d.ts +15 -0
- package/dist/lib/init/index.js +29 -13
- package/dist/lib/load/apply-flags.d.ts +5 -2
- package/dist/lib/load/apply-flags.js +0 -50
- package/dist/lib/load/finalize-collections.d.ts +2 -0
- package/dist/lib/load/finalize-collections.js +28 -0
- package/dist/lib/load/finalize-fields.d.ts +2 -0
- package/dist/lib/load/finalize-fields.js +25 -0
- package/dist/lib/load/index.js +38 -18
- package/dist/lib/load/load-collections.d.ts +2 -1
- package/dist/lib/load/load-collections.js +17 -30
- package/dist/lib/load/load-data.d.ts +3 -1
- package/dist/lib/load/load-data.js +47 -34
- package/dist/lib/load/load-relations.d.ts +2 -1
- package/dist/lib/load/load-relations.js +17 -7
- package/dist/lib/template-plan/collections.d.ts +4 -0
- package/dist/lib/template-plan/collections.js +26 -0
- package/dist/lib/template-plan/flags.d.ts +18 -0
- package/dist/lib/template-plan/flags.js +61 -0
- package/dist/lib/template-plan/index.d.ts +16 -0
- package/dist/lib/template-plan/index.js +77 -0
- package/dist/lib/template-plan/junctions.d.ts +10 -0
- package/dist/lib/template-plan/junctions.js +19 -0
- package/dist/lib/template-plan/metadata-plan.d.ts +2 -0
- package/dist/lib/template-plan/metadata-plan.js +36 -0
- package/dist/lib/template-plan/metadata.d.ts +5 -0
- package/dist/lib/template-plan/metadata.js +42 -0
- package/dist/lib/template-plan/types.d.ts +34 -0
- package/dist/lib/template-plan/types.js +1 -0
- package/oclif.manifest.json +173 -16
- package/package.json +1 -1
- package/dist/lib/load/update-required-fields.d.ts +0 -1
- package/dist/lib/load/update-required-fields.js +0 -20
|
@@ -1,28 +1,51 @@
|
|
|
1
1
|
import { createItems, readItems, updateItemsBatch, updateSingleton } from '@directus/sdk';
|
|
2
2
|
import { ux } from '@oclif/core';
|
|
3
|
+
import fs from 'node:fs';
|
|
3
4
|
import path from 'pathe';
|
|
4
5
|
import { DIRECTUS_PINK } from '../constants.js';
|
|
5
6
|
import { api } from '../sdk.js';
|
|
7
|
+
import { getBrokenJunctionCollections, includesCollection } from '../template-plan/index.js';
|
|
6
8
|
import catchError from '../utils/catch-error.js';
|
|
7
9
|
import { chunkArray } from '../utils/chunk-array.js';
|
|
8
10
|
import readFile from '../utils/read-file.js';
|
|
9
11
|
const BATCH_SIZE = 50;
|
|
10
|
-
export default async function loadData(dir) {
|
|
11
|
-
const collections =
|
|
12
|
+
export default async function loadData(dir, plan) {
|
|
13
|
+
const collections = getUserCollections(dir, plan);
|
|
12
14
|
ux.action.start(ux.colorize(DIRECTUS_PINK, `Loading data for ${collections.length} collections`));
|
|
13
|
-
await loadSkeletonRecords(dir);
|
|
14
|
-
await loadFullData(dir);
|
|
15
|
-
await loadSingletons(dir);
|
|
15
|
+
await loadSkeletonRecords(dir, plan);
|
|
16
|
+
await loadFullData(dir, plan);
|
|
17
|
+
await loadSingletons(dir, plan);
|
|
16
18
|
ux.action.stop();
|
|
17
19
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
function getContentCollections(dir) {
|
|
21
|
+
const contentDir = path.resolve(dir, 'content');
|
|
22
|
+
if (!fs.existsSync(contentDir))
|
|
23
|
+
return new Set();
|
|
24
|
+
return new Set(fs
|
|
25
|
+
.readdirSync(contentDir)
|
|
26
|
+
.filter((file) => file.endsWith('.json'))
|
|
27
|
+
.map((file) => path.basename(file, '.json')));
|
|
28
|
+
}
|
|
29
|
+
export function getUserCollections(dir, plan) {
|
|
30
|
+
const contentCollections = getContentCollections(dir);
|
|
20
31
|
const collections = readFile('collections', dir);
|
|
32
|
+
const relationsPath = path.join(dir, 'relations.json');
|
|
33
|
+
const relations = plan?.partial && fs.existsSync(relationsPath) ? readFile('relations', dir) : [];
|
|
34
|
+
const brokenJunctions = getBrokenJunctionCollections(relations, plan);
|
|
35
|
+
if (brokenJunctions.size > 0) {
|
|
36
|
+
ux.warn(`Skipping junction collections with excluded FK targets: ${[...brokenJunctions].join(', ')}`);
|
|
37
|
+
}
|
|
38
|
+
return collections
|
|
39
|
+
.filter((item) => contentCollections.has(item.collection))
|
|
40
|
+
.filter((item) => !item.collection.startsWith('directus_', 0))
|
|
41
|
+
.filter((item) => item.schema !== null)
|
|
42
|
+
.filter((item) => includesCollection(item.collection, plan))
|
|
43
|
+
.filter((item) => !brokenJunctions.has(item.collection));
|
|
44
|
+
}
|
|
45
|
+
async function loadSkeletonRecords(dir, plan) {
|
|
46
|
+
ux.action.status = 'Loading skeleton records';
|
|
21
47
|
const primaryKeyMap = await getCollectionPrimaryKeys(dir);
|
|
22
|
-
const userCollections =
|
|
23
|
-
.filter(item => !item.collection.startsWith('directus_', 0))
|
|
24
|
-
.filter(item => item.schema !== null)
|
|
25
|
-
.filter(item => !item.meta.singleton);
|
|
48
|
+
const userCollections = getUserCollections(dir, plan).filter((item) => !item.meta.singleton);
|
|
26
49
|
await Promise.all(userCollections.map(async (collection) => {
|
|
27
50
|
const name = collection.collection;
|
|
28
51
|
const primaryKeyField = getPrimaryKey(primaryKeyMap, name);
|
|
@@ -31,14 +54,11 @@ async function loadSkeletonRecords(dir) {
|
|
|
31
54
|
// Fetch existing primary keys
|
|
32
55
|
const existingPrimaryKeys = await getExistingPrimaryKeys(name, primaryKeyField);
|
|
33
56
|
// Filter out existing records
|
|
34
|
-
const newData = data.filter(entry => !existingPrimaryKeys.has(entry[primaryKeyField]));
|
|
35
|
-
if (newData.length === 0)
|
|
36
|
-
// ux.stdout(`${ux.colorize('dim', '--')} Skipping ${name}: No new records to add`)
|
|
57
|
+
const newData = data.filter((entry) => !existingPrimaryKeys.has(entry[primaryKeyField]));
|
|
58
|
+
if (newData.length === 0)
|
|
37
59
|
return;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
await Promise.all(batches.map(batch => uploadBatch(name, batch, createItems)));
|
|
41
|
-
// ux.stdout(`${ux.colorize('dim', '--')} Added ${newData.length} new skeleton records to ${name}`)
|
|
60
|
+
const batches = chunkArray(newData, BATCH_SIZE).map((batch) => batch.map((entry) => ({ [primaryKeyField]: entry[primaryKeyField] })));
|
|
61
|
+
await Promise.all(batches.map((batch) => uploadBatch(name, batch, createItems)));
|
|
42
62
|
}));
|
|
43
63
|
ux.action.status = 'Loaded skeleton records';
|
|
44
64
|
}
|
|
@@ -63,7 +83,7 @@ async function getExistingPrimaryKeys(collection, primaryKeyField) {
|
|
|
63
83
|
page++;
|
|
64
84
|
}
|
|
65
85
|
catch (error) {
|
|
66
|
-
catchError(error);
|
|
86
|
+
catchError(error, { context: { collection, page } });
|
|
67
87
|
break;
|
|
68
88
|
}
|
|
69
89
|
}
|
|
@@ -74,31 +94,24 @@ async function uploadBatch(collection, batch, method) {
|
|
|
74
94
|
await api.client.request(method(collection, batch));
|
|
75
95
|
}
|
|
76
96
|
catch (error) {
|
|
77
|
-
catchError(error);
|
|
97
|
+
catchError(error, { context: { batchSize: batch.length, collection } });
|
|
78
98
|
}
|
|
79
99
|
}
|
|
80
|
-
async function loadFullData(dir) {
|
|
100
|
+
async function loadFullData(dir, plan) {
|
|
81
101
|
ux.action.status = 'Updating records with full data';
|
|
82
|
-
const
|
|
83
|
-
const userCollections = collections
|
|
84
|
-
.filter(item => !item.collection.startsWith('directus_', 0))
|
|
85
|
-
.filter(item => item.schema !== null)
|
|
86
|
-
.filter(item => !item.meta.singleton);
|
|
102
|
+
const userCollections = getUserCollections(dir, plan).filter((item) => !item.meta.singleton);
|
|
87
103
|
await Promise.all(userCollections.map(async (collection) => {
|
|
88
104
|
const name = collection.collection;
|
|
89
105
|
const sourceDir = path.resolve(dir, 'content');
|
|
90
106
|
const data = readFile(name, sourceDir);
|
|
91
|
-
const batches = chunkArray(data, BATCH_SIZE).map(batch => batch.map(({ user_created, user_updated, ...cleanedRow }) => cleanedRow));
|
|
92
|
-
await Promise.all(batches.map(batch => uploadBatch(name, batch, updateItemsBatch)));
|
|
107
|
+
const batches = chunkArray(data, BATCH_SIZE).map((batch) => batch.map(({ user_created, user_updated, ...cleanedRow }) => cleanedRow));
|
|
108
|
+
await Promise.all(batches.map((batch) => uploadBatch(name, batch, updateItemsBatch)));
|
|
93
109
|
}));
|
|
94
110
|
ux.action.status = 'Updated records with full data';
|
|
95
111
|
}
|
|
96
|
-
async function loadSingletons(dir) {
|
|
112
|
+
async function loadSingletons(dir, plan) {
|
|
97
113
|
ux.action.status = 'Loading data for singleton collections';
|
|
98
|
-
const
|
|
99
|
-
const singletonCollections = collections
|
|
100
|
-
.filter(item => !item.collection.startsWith('directus_', 0))
|
|
101
|
-
.filter(item => item.meta.singleton);
|
|
114
|
+
const singletonCollections = getUserCollections(dir, plan).filter((item) => item.meta.singleton);
|
|
102
115
|
await Promise.all(singletonCollections.map(async (collection) => {
|
|
103
116
|
const name = collection.collection;
|
|
104
117
|
const sourceDir = path.resolve(dir, 'content');
|
|
@@ -108,7 +121,7 @@ async function loadSingletons(dir) {
|
|
|
108
121
|
await api.client.request(updateSingleton(name, cleanedData));
|
|
109
122
|
}
|
|
110
123
|
catch (error) {
|
|
111
|
-
catchError(error);
|
|
124
|
+
catchError(error, { context: { collection: name } });
|
|
112
125
|
}
|
|
113
126
|
}));
|
|
114
127
|
ux.action.status = 'Loaded data for singleton collections';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { type TemplatePlan } from '../template-plan/index.js';
|
|
1
2
|
/**
|
|
2
3
|
* Load relationships into the Directus instance
|
|
3
4
|
* @param dir - The directory to read the relations from
|
|
4
5
|
* @returns {Promise<void>} - Returns nothing
|
|
5
6
|
*/
|
|
6
|
-
export default function loadRelations(dir: string): Promise<void>;
|
|
7
|
+
export default function loadRelations(dir: string, plan?: TemplatePlan): Promise<void>;
|
|
@@ -2,6 +2,7 @@ import { createRelation, readRelations } from '@directus/sdk';
|
|
|
2
2
|
import { ux } from '@oclif/core';
|
|
3
3
|
import { DIRECTUS_PINK } from '../constants.js';
|
|
4
4
|
import { api } from '../sdk.js';
|
|
5
|
+
import { includesRelation } from '../template-plan/index.js';
|
|
5
6
|
import catchError from '../utils/catch-error.js';
|
|
6
7
|
import readFile from '../utils/read-file.js';
|
|
7
8
|
/**
|
|
@@ -9,20 +10,22 @@ import readFile from '../utils/read-file.js';
|
|
|
9
10
|
* @param dir - The directory to read the relations from
|
|
10
11
|
* @returns {Promise<void>} - Returns nothing
|
|
11
12
|
*/
|
|
12
|
-
export default async function loadRelations(dir) {
|
|
13
|
-
const relations = readFile('relations', dir);
|
|
13
|
+
export default async function loadRelations(dir, plan) {
|
|
14
|
+
const relations = readFile('relations', dir).filter((relation) => includesRelation(relation.collection, relation.related_collection, plan));
|
|
14
15
|
ux.action.start(ux.colorize(DIRECTUS_PINK, `Loading ${relations.length} relations`));
|
|
15
16
|
if (relations && relations.length > 0) {
|
|
16
17
|
// Fetch existing relations
|
|
17
18
|
const existingRelations = await api.client.request(readRelations());
|
|
18
|
-
const existingRelationKeys = new Set(existingRelations.map(relation => `${relation.collection}:${relation.field}:${relation.related_collection}`));
|
|
19
|
-
const relationsToAdd = relations
|
|
19
|
+
const existingRelationKeys = new Set(existingRelations.map((relation) => `${relation.collection}:${relation.field}:${relation.related_collection}`));
|
|
20
|
+
const relationsToAdd = relations
|
|
21
|
+
.filter((relation) => {
|
|
20
22
|
const key = `${relation.collection}:${relation.field}:${relation.related_collection}`;
|
|
21
23
|
if (existingRelationKeys.has(key)) {
|
|
22
24
|
return false;
|
|
23
25
|
}
|
|
24
26
|
return true;
|
|
25
|
-
})
|
|
27
|
+
})
|
|
28
|
+
.map((relation) => {
|
|
26
29
|
const cleanRelation = { ...relation };
|
|
27
30
|
cleanRelation.meta.id = undefined;
|
|
28
31
|
return cleanRelation;
|
|
@@ -32,12 +35,19 @@ export default async function loadRelations(dir) {
|
|
|
32
35
|
ux.action.stop();
|
|
33
36
|
}
|
|
34
37
|
async function addRelations(relations) {
|
|
35
|
-
for
|
|
38
|
+
for (const relation of relations) {
|
|
36
39
|
try {
|
|
40
|
+
// eslint-disable-next-line no-await-in-loop
|
|
37
41
|
await api.client.request(createRelation(relation));
|
|
38
42
|
}
|
|
39
43
|
catch (error) {
|
|
40
|
-
catchError(error
|
|
44
|
+
catchError(error, {
|
|
45
|
+
context: {
|
|
46
|
+
collection: relation.collection,
|
|
47
|
+
field: relation.field,
|
|
48
|
+
related: relation.related_collection,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
53
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { TemplatePlan } from './types.js';
|
|
2
|
+
export declare function includesCollection(collection: string, plan?: TemplatePlan): boolean;
|
|
3
|
+
export declare function includesSchemaCollection(collection: string, plan?: TemplatePlan): boolean;
|
|
4
|
+
export declare function includesRelation(collection: string, relatedCollection?: null | string, plan?: TemplatePlan): boolean;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function includesCollection(collection, plan) {
|
|
2
|
+
if (plan?.collections && !plan.collections.includes(collection))
|
|
3
|
+
return false;
|
|
4
|
+
if (plan?.excludeCollections?.includes(collection))
|
|
5
|
+
return false;
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
export function includesSchemaCollection(collection, plan) {
|
|
9
|
+
const schemaCollections = plan?.schemaCollections || plan?.collections;
|
|
10
|
+
if (schemaCollections && !schemaCollections.includes(collection))
|
|
11
|
+
return false;
|
|
12
|
+
if (plan?.excludeCollections?.includes(collection))
|
|
13
|
+
return false;
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
export function includesRelation(collection, relatedCollection, plan) {
|
|
17
|
+
if (!includesSchemaCollection(collection, plan))
|
|
18
|
+
return false;
|
|
19
|
+
if (!relatedCollection)
|
|
20
|
+
return true;
|
|
21
|
+
if (plan?.excludeCollections?.includes(relatedCollection))
|
|
22
|
+
return plan.relationStrategy === 'preserve';
|
|
23
|
+
if (relatedCollection.startsWith('directus_'))
|
|
24
|
+
return true;
|
|
25
|
+
return includesSchemaCollection(relatedCollection, plan);
|
|
26
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const componentNames: readonly ["schema", "content", "files", "flows", "dashboards", "permissions", "settings", "extensions", "users"];
|
|
2
|
+
export declare const partial: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
3
|
+
export declare const componentFlags: {
|
|
4
|
+
content: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
5
|
+
dashboards: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
6
|
+
extensions: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
files: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
flows: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
|
+
permissions: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
schema: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
settings: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
users: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
};
|
|
14
|
+
export declare const collections: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
export declare const excludeCollections: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
export declare const relationStrategy: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
export declare const allowBrokenRelations: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
|
+
export declare const noAssets: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
export const componentNames = [
|
|
3
|
+
'schema',
|
|
4
|
+
'content',
|
|
5
|
+
'files',
|
|
6
|
+
'flows',
|
|
7
|
+
'dashboards',
|
|
8
|
+
'permissions',
|
|
9
|
+
'settings',
|
|
10
|
+
'extensions',
|
|
11
|
+
'users',
|
|
12
|
+
];
|
|
13
|
+
export const partial = Flags.boolean({
|
|
14
|
+
description: 'Enable partial template mode',
|
|
15
|
+
summary: 'Enable partial template mode',
|
|
16
|
+
});
|
|
17
|
+
export const componentFlags = {
|
|
18
|
+
content: Flags.boolean({ allowNo: true, default: undefined, description: 'Include content/data' }),
|
|
19
|
+
dashboards: Flags.boolean({ allowNo: true, default: undefined, description: 'Include dashboards and panels' }),
|
|
20
|
+
extensions: Flags.boolean({ allowNo: true, default: undefined, description: 'Include extensions' }),
|
|
21
|
+
files: Flags.boolean({ allowNo: true, default: undefined, description: 'Include files, folders, and assets' }),
|
|
22
|
+
flows: Flags.boolean({ allowNo: true, default: undefined, description: 'Include flows and operations' }),
|
|
23
|
+
permissions: Flags.boolean({
|
|
24
|
+
allowNo: true,
|
|
25
|
+
default: undefined,
|
|
26
|
+
description: 'Include permissions, roles, policies, and access',
|
|
27
|
+
}),
|
|
28
|
+
schema: Flags.boolean({
|
|
29
|
+
allowNo: true,
|
|
30
|
+
default: undefined,
|
|
31
|
+
description: 'Include schema, collections, fields, and relations',
|
|
32
|
+
}),
|
|
33
|
+
settings: Flags.boolean({
|
|
34
|
+
allowNo: true,
|
|
35
|
+
default: undefined,
|
|
36
|
+
description: 'Include settings, translations, and presets',
|
|
37
|
+
}),
|
|
38
|
+
users: Flags.boolean({ allowNo: true, default: undefined, description: 'Include users' }),
|
|
39
|
+
};
|
|
40
|
+
export const collections = Flags.string({
|
|
41
|
+
description: 'Only include these comma-separated collections',
|
|
42
|
+
});
|
|
43
|
+
export const excludeCollections = Flags.string({
|
|
44
|
+
aliases: ['exclude-collections'],
|
|
45
|
+
description: 'Exclude these comma-separated collections',
|
|
46
|
+
});
|
|
47
|
+
export const relationStrategy = Flags.string({
|
|
48
|
+
aliases: ['relation-strategy'],
|
|
49
|
+
description: 'How to handle relations to omitted data',
|
|
50
|
+
options: ['empty', 'preserve', 'deep'],
|
|
51
|
+
});
|
|
52
|
+
export const allowBrokenRelations = Flags.boolean({
|
|
53
|
+
aliases: ['allow-broken-relations'],
|
|
54
|
+
default: false,
|
|
55
|
+
description: 'Allow intentionally incomplete relation references',
|
|
56
|
+
});
|
|
57
|
+
export const noAssets = Flags.boolean({
|
|
58
|
+
aliases: ['no-assets'],
|
|
59
|
+
default: undefined,
|
|
60
|
+
description: 'Shorthand for --no-files and --exclude-collections directus_files',
|
|
61
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TemplateComponent, TemplatePlan } from './types.js';
|
|
2
|
+
type BuildFlags = Partial<Record<TemplateComponent, boolean>> & {
|
|
3
|
+
allowBrokenRelations?: boolean;
|
|
4
|
+
collections?: string | string[];
|
|
5
|
+
excludeCollections?: string | string[];
|
|
6
|
+
noAssets?: boolean;
|
|
7
|
+
partial?: boolean;
|
|
8
|
+
relationStrategy?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function buildTemplatePlan(flags?: BuildFlags): TemplatePlan;
|
|
11
|
+
export * from './collections.js';
|
|
12
|
+
export * from './flags.js';
|
|
13
|
+
export * from './junctions.js';
|
|
14
|
+
export * from './metadata-plan.js';
|
|
15
|
+
export * from './metadata.js';
|
|
16
|
+
export * from './types.js';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import catchError from '../utils/catch-error.js';
|
|
2
|
+
import { componentNames } from './flags.js';
|
|
3
|
+
function parseList(value) {
|
|
4
|
+
if (!value)
|
|
5
|
+
return undefined;
|
|
6
|
+
const raw = Array.isArray(value) ? value.join(',') : value;
|
|
7
|
+
const values = raw
|
|
8
|
+
.split(',')
|
|
9
|
+
.map((item) => item.trim())
|
|
10
|
+
.filter(Boolean);
|
|
11
|
+
return values.length > 0 ? values : undefined;
|
|
12
|
+
}
|
|
13
|
+
function hasPartialOnlyFlags(flags) {
|
|
14
|
+
return Boolean(flags.collections ||
|
|
15
|
+
flags.excludeCollections ||
|
|
16
|
+
flags.noAssets === true ||
|
|
17
|
+
flags.relationStrategy !== undefined ||
|
|
18
|
+
flags.allowBrokenRelations === true);
|
|
19
|
+
}
|
|
20
|
+
function hasScopingFlags(flags) {
|
|
21
|
+
return Boolean(flags.collections || flags.excludeCollections || flags.noAssets === true || hasComponentFlags(flags));
|
|
22
|
+
}
|
|
23
|
+
function hasComponentFlags(flags) {
|
|
24
|
+
return componentNames.some((component) => flags[component] !== undefined);
|
|
25
|
+
}
|
|
26
|
+
function buildComponents(flags, partial) {
|
|
27
|
+
const components = {};
|
|
28
|
+
const enabled = componentNames.filter((component) => flags[component] === true);
|
|
29
|
+
const disabled = componentNames.filter((component) => flags[component] === false);
|
|
30
|
+
if (!partial) {
|
|
31
|
+
for (const component of componentNames)
|
|
32
|
+
components[component] = true;
|
|
33
|
+
return components;
|
|
34
|
+
}
|
|
35
|
+
if (enabled.length > 0) {
|
|
36
|
+
for (const component of componentNames)
|
|
37
|
+
components[component] = enabled.includes(component);
|
|
38
|
+
}
|
|
39
|
+
else if (disabled.length > 0) {
|
|
40
|
+
for (const component of componentNames)
|
|
41
|
+
components[component] = !disabled.includes(component);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
for (const component of componentNames)
|
|
45
|
+
components[component] = true;
|
|
46
|
+
}
|
|
47
|
+
if (flags.noAssets)
|
|
48
|
+
components.files = false;
|
|
49
|
+
return components;
|
|
50
|
+
}
|
|
51
|
+
export function buildTemplatePlan(flags = {}) {
|
|
52
|
+
const collections = parseList(flags.collections);
|
|
53
|
+
const excludeCollections = parseList(flags.excludeCollections) || (flags.noAssets ? [] : undefined);
|
|
54
|
+
if (flags.noAssets && !excludeCollections?.includes('directus_files')) {
|
|
55
|
+
excludeCollections?.push('directus_files');
|
|
56
|
+
}
|
|
57
|
+
const partial = Boolean(flags.partial || hasComponentFlags(flags) || hasPartialOnlyFlags(flags));
|
|
58
|
+
const components = buildComponents(flags, partial);
|
|
59
|
+
if (!componentNames.some((component) => components[component])) {
|
|
60
|
+
catchError(new Error('At least one template component must be enabled.'), { fatal: true });
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
allowBrokenRelations: Boolean(flags.allowBrokenRelations),
|
|
64
|
+
collections,
|
|
65
|
+
components,
|
|
66
|
+
excludeCollections,
|
|
67
|
+
partial,
|
|
68
|
+
relationStrategy: (flags.relationStrategy ||
|
|
69
|
+
(partial ? (hasScopingFlags(flags) ? 'empty' : 'preserve') : 'deep')),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export * from './collections.js';
|
|
73
|
+
export * from './flags.js';
|
|
74
|
+
export * from './junctions.js';
|
|
75
|
+
export * from './metadata-plan.js';
|
|
76
|
+
export * from './metadata.js';
|
|
77
|
+
export * from './types.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TemplatePlan } from './types.js';
|
|
2
|
+
interface JunctionRelation {
|
|
3
|
+
collection: string;
|
|
4
|
+
meta?: {
|
|
5
|
+
junction_field?: null | string;
|
|
6
|
+
};
|
|
7
|
+
related_collection?: null | string;
|
|
8
|
+
}
|
|
9
|
+
export declare function getBrokenJunctionCollections(relations: JunctionRelation[], plan?: TemplatePlan): Set<string>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { includesCollection } from './collections.js';
|
|
2
|
+
// Directus sets meta.junction_field on both FK legs of an M2M to point to the other FK.
|
|
3
|
+
// Any collection appearing as `collection` on such a relation is a junction table.
|
|
4
|
+
// System collections (directus_*) always exist on every instance — never treat them as broken FK targets.
|
|
5
|
+
export function getBrokenJunctionCollections(relations, plan) {
|
|
6
|
+
if (!plan?.partial)
|
|
7
|
+
return new Set();
|
|
8
|
+
const junctionCollections = new Set(relations.filter((r) => r.meta?.junction_field).map((r) => r.collection));
|
|
9
|
+
const broken = new Set();
|
|
10
|
+
for (const junction of junctionCollections) {
|
|
11
|
+
const targets = relations
|
|
12
|
+
.filter((r) => r.collection === junction && r.related_collection)
|
|
13
|
+
.map((r) => r.related_collection);
|
|
14
|
+
if (targets.some((target) => !target.startsWith('directus_') && !includesCollection(target, plan))) {
|
|
15
|
+
broken.add(junction);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return broken;
|
|
19
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import catchError from '../utils/catch-error.js';
|
|
2
|
+
import { componentNames } from './flags.js';
|
|
3
|
+
function intersectCollections(scope, requested, available) {
|
|
4
|
+
if (requested && available) {
|
|
5
|
+
const collections = requested.filter((collection) => available.includes(collection));
|
|
6
|
+
if (collections.length === 0) {
|
|
7
|
+
catchError(new Error(`No requested ${scope} match this template`), { fatal: true });
|
|
8
|
+
}
|
|
9
|
+
return collections;
|
|
10
|
+
}
|
|
11
|
+
return requested || available;
|
|
12
|
+
}
|
|
13
|
+
function mergeExcludedCollections(requested, available) {
|
|
14
|
+
const values = [...(requested || []), ...(available || [])];
|
|
15
|
+
return values.length > 0 ? [...new Set(values)] : undefined;
|
|
16
|
+
}
|
|
17
|
+
export function applyMetadataToPlan(plan, metadata) {
|
|
18
|
+
if (!metadata)
|
|
19
|
+
return plan;
|
|
20
|
+
const components = { ...plan.components };
|
|
21
|
+
for (const component of componentNames) {
|
|
22
|
+
components[component] = components[component] && metadata.components[component];
|
|
23
|
+
}
|
|
24
|
+
const partial = plan.partial ||
|
|
25
|
+
metadata.partial ||
|
|
26
|
+
componentNames.some((component) => components[component] !== plan.components[component]);
|
|
27
|
+
return {
|
|
28
|
+
...plan,
|
|
29
|
+
collections: intersectCollections('collections', plan.collections, metadata.collections),
|
|
30
|
+
components,
|
|
31
|
+
excludeCollections: mergeExcludedCollections(plan.excludeCollections, metadata.excludedCollections),
|
|
32
|
+
partial,
|
|
33
|
+
relationStrategy: metadata.relationStrategy ?? plan.relationStrategy,
|
|
34
|
+
schemaCollections: intersectCollections('schema collections', plan.schemaCollections, metadata.schemaCollections),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { TemplateMetadata, TemplatePlan, TemplateWarning } from './types.js';
|
|
2
|
+
export declare function createTemplateMetadata(plan: TemplatePlan, warnings?: TemplateWarning[]): TemplateMetadata;
|
|
3
|
+
export declare function getTemplateMetadataPath(dir: string): string;
|
|
4
|
+
export declare function readTemplateMetadata(dir: string): TemplateMetadata | undefined;
|
|
5
|
+
export declare function writeTemplateMetadata(dir: string, plan: TemplatePlan, warnings?: TemplateWarning[]): Promise<void>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'pathe';
|
|
3
|
+
import catchError from '../utils/catch-error.js';
|
|
4
|
+
const META_FILE = 'template-meta.json';
|
|
5
|
+
export function createTemplateMetadata(plan, warnings = []) {
|
|
6
|
+
return {
|
|
7
|
+
allowBrokenRelations: plan.allowBrokenRelations,
|
|
8
|
+
collections: plan.collections,
|
|
9
|
+
components: plan.components,
|
|
10
|
+
excludedCollections: plan.excludeCollections,
|
|
11
|
+
partial: plan.partial,
|
|
12
|
+
relationStrategy: plan.relationStrategy,
|
|
13
|
+
schemaCollections: plan.schemaCollections,
|
|
14
|
+
version: 2,
|
|
15
|
+
warnings,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function getTemplateMetadataPath(dir) {
|
|
19
|
+
return path.join(dir, META_FILE);
|
|
20
|
+
}
|
|
21
|
+
export function readTemplateMetadata(dir) {
|
|
22
|
+
const filePath = getTemplateMetadataPath(dir);
|
|
23
|
+
if (!fs.existsSync(filePath))
|
|
24
|
+
return undefined;
|
|
25
|
+
let metadata;
|
|
26
|
+
try {
|
|
27
|
+
metadata = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
catchError(error, { fatal: true });
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
if (metadata.version !== 2) {
|
|
34
|
+
catchError(new Error(`Unsupported template metadata version: ${metadata.version}`), { fatal: true });
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
return metadata;
|
|
38
|
+
}
|
|
39
|
+
export async function writeTemplateMetadata(dir, plan, warnings = []) {
|
|
40
|
+
const filePath = getTemplateMetadataPath(dir);
|
|
41
|
+
await fs.promises.writeFile(filePath, JSON.stringify(createTemplateMetadata(plan, warnings), null, 2));
|
|
42
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type RelationStrategy = 'deep' | 'empty' | 'preserve';
|
|
2
|
+
export interface TemplateComponents {
|
|
3
|
+
content: boolean;
|
|
4
|
+
dashboards: boolean;
|
|
5
|
+
extensions: boolean;
|
|
6
|
+
files: boolean;
|
|
7
|
+
flows: boolean;
|
|
8
|
+
permissions: boolean;
|
|
9
|
+
schema: boolean;
|
|
10
|
+
settings: boolean;
|
|
11
|
+
users: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface TemplatePlan {
|
|
14
|
+
allowBrokenRelations: boolean;
|
|
15
|
+
collections?: string[];
|
|
16
|
+
components: TemplateComponents;
|
|
17
|
+
excludeCollections?: string[];
|
|
18
|
+
partial: boolean;
|
|
19
|
+
relationStrategy: RelationStrategy;
|
|
20
|
+
schemaCollections?: string[];
|
|
21
|
+
}
|
|
22
|
+
export type TemplateWarning = {
|
|
23
|
+
collection: string;
|
|
24
|
+
count: number;
|
|
25
|
+
field: string;
|
|
26
|
+
relatedCollection: string;
|
|
27
|
+
type: 'excluded_relation';
|
|
28
|
+
};
|
|
29
|
+
export interface TemplateMetadata extends Omit<TemplatePlan, 'excludeCollections'> {
|
|
30
|
+
excludedCollections?: string[];
|
|
31
|
+
version: 2;
|
|
32
|
+
warnings: TemplateWarning[];
|
|
33
|
+
}
|
|
34
|
+
export type TemplateComponent = keyof TemplateComponents;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|