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.
Files changed (55) hide show
  1. package/README.md +134 -37
  2. package/dist/commands/apply.d.ts +5 -0
  3. package/dist/commands/apply.js +32 -68
  4. package/dist/commands/extract.d.ts +30 -0
  5. package/dist/commands/extract.js +14 -5
  6. package/dist/commands/init.d.ts +4 -0
  7. package/dist/commands/init.js +90 -87
  8. package/dist/lib/extract/expand-deep-plan.d.ts +2 -0
  9. package/dist/lib/extract/expand-deep-plan.js +54 -0
  10. package/dist/lib/extract/expand-schema-plan.d.ts +2 -0
  11. package/dist/lib/extract/expand-schema-plan.js +55 -0
  12. package/dist/lib/extract/extract-assets.js +19 -6
  13. package/dist/lib/extract/extract-collections.d.ts +2 -1
  14. package/dist/lib/extract/extract-collections.js +5 -2
  15. package/dist/lib/extract/extract-content.d.ts +2 -1
  16. package/dist/lib/extract/extract-content.js +108 -13
  17. package/dist/lib/extract/extract-fields.d.ts +2 -1
  18. package/dist/lib/extract/extract-fields.js +5 -5
  19. package/dist/lib/extract/extract-relations.d.ts +2 -1
  20. package/dist/lib/extract/extract-relations.js +6 -4
  21. package/dist/lib/extract/index.d.ts +2 -1
  22. package/dist/lib/extract/index.js +67 -29
  23. package/dist/lib/init/index.d.ts +15 -0
  24. package/dist/lib/init/index.js +29 -13
  25. package/dist/lib/load/apply-flags.d.ts +5 -2
  26. package/dist/lib/load/apply-flags.js +0 -50
  27. package/dist/lib/load/finalize-collections.d.ts +2 -0
  28. package/dist/lib/load/finalize-collections.js +28 -0
  29. package/dist/lib/load/finalize-fields.d.ts +2 -0
  30. package/dist/lib/load/finalize-fields.js +25 -0
  31. package/dist/lib/load/index.js +38 -18
  32. package/dist/lib/load/load-collections.d.ts +2 -1
  33. package/dist/lib/load/load-collections.js +17 -30
  34. package/dist/lib/load/load-data.d.ts +3 -1
  35. package/dist/lib/load/load-data.js +47 -34
  36. package/dist/lib/load/load-relations.d.ts +2 -1
  37. package/dist/lib/load/load-relations.js +17 -7
  38. package/dist/lib/template-plan/collections.d.ts +4 -0
  39. package/dist/lib/template-plan/collections.js +26 -0
  40. package/dist/lib/template-plan/flags.d.ts +18 -0
  41. package/dist/lib/template-plan/flags.js +61 -0
  42. package/dist/lib/template-plan/index.d.ts +16 -0
  43. package/dist/lib/template-plan/index.js +77 -0
  44. package/dist/lib/template-plan/junctions.d.ts +10 -0
  45. package/dist/lib/template-plan/junctions.js +19 -0
  46. package/dist/lib/template-plan/metadata-plan.d.ts +2 -0
  47. package/dist/lib/template-plan/metadata-plan.js +36 -0
  48. package/dist/lib/template-plan/metadata.d.ts +5 -0
  49. package/dist/lib/template-plan/metadata.js +42 -0
  50. package/dist/lib/template-plan/types.d.ts +34 -0
  51. package/dist/lib/template-plan/types.js +1 -0
  52. package/oclif.manifest.json +173 -16
  53. package/package.json +1 -1
  54. package/dist/lib/load/update-required-fields.d.ts +0 -1
  55. 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 = readFile('collections', dir);
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
- async function loadSkeletonRecords(dir) {
19
- ux.action.status = 'Loading skeleton records';
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 = collections
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
- const batches = chunkArray(newData, BATCH_SIZE).map(batch => batch.map(entry => ({ [primaryKeyField]: entry[primaryKeyField] })));
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 collections = readFile('collections', dir);
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 collections = readFile('collections', dir);
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.filter(relation => {
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
- }).map(relation => {
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 await (const relation of relations) {
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,2 @@
1
+ import type { TemplateMetadata, TemplatePlan } from './types.js';
2
+ export declare function applyMetadataToPlan(plan: TemplatePlan, metadata?: TemplateMetadata): TemplatePlan;
@@ -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 {};