From 4072715f0046e734f2277dd53f1be7d4c73d072d Mon Sep 17 00:00:00 2001 From: Frank Kudermann Date: Tue, 31 Mar 2026 16:13:01 +0200 Subject: [PATCH 1/2] feat: add SCHEMA_SYNC_SAFE flag to prevent destructive schema imports When SCHEMA_SYNC_SAFE=true, all DELETE operations are filtered from the schema diff before applying. This allows project-specific collections, fields and relations to coexist with a base schema snapshot without being dropped on import. Use case: multi-project setups where a base template schema is imported into projects that extend it with their own collections and fields. Made-with: Cursor --- src/index.ts | 1 + src/schemaExporter.ts | 38 +++++++++++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index bd0f550..c60c0df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ const registerHook: HookConfig = async ({ action, init }, { env, services, datab const schemaOptions = { split: typeof env.SCHEMA_SYNC_SPLIT === 'boolean' ? env.SCHEMA_SYNC_SPLIT : true, + safe: !!env.SCHEMA_SYNC_SAFE, }; let schema: SchemaOverview | null; diff --git a/src/schemaExporter.ts b/src/schemaExporter.ts index 2133bda..de966f3 100644 --- a/src/schemaExporter.ts +++ b/src/schemaExporter.ts @@ -1,6 +1,6 @@ import type { Snapshot, SnapshotField, SnapshotRelation } from '@directus/api/dist/types'; import type { ApiExtensionContext } from '@directus/extensions'; -import type { Collection, ExtensionsServices } from '@directus/types'; +import type { Collection, ExtensionsServices, SnapshotDiff } from '@directus/types'; import { mkdir, readFile, rm, writeFile } from 'fs/promises'; import { glob } from 'glob'; import { condenseAction } from './condenseAction.js'; @@ -8,6 +8,34 @@ import { exportHook } from './schemaExporterHooks.js'; import type { IExporter } from './types'; import { ExportHelper } from './utils.js'; +/** + * Removes all destructive (DELETE) operations from a schema diff. + * Used with SCHEMA_SYNC_SAFE=true to prevent project-specific collections, + * fields, and relations from being dropped when importing a base snapshot. + */ +function filterNonDestructive(diff: SnapshotDiff): SnapshotDiff { + const isTopLevelDelete = (diffs: ReadonlyArray<{ kind: string; path?: unknown }>) => + diffs.some(d => d.kind === 'D' && !d.path); + + const deletedCollections = new Set( + diff.collections.filter(c => isTopLevelDelete(c.diff)).map(c => c.collection) + ); + + return { + ...diff, + collections: diff.collections.filter(c => !deletedCollections.has(c.collection)), + fields: diff.fields.filter( + f => !deletedCollections.has(f.collection) && !isTopLevelDelete(f.diff) + ), + systemFields: (diff.systemFields ?? []).filter( + f => !deletedCollections.has(f.collection) && !isTopLevelDelete(f.diff) + ), + relations: diff.relations.filter( + r => !deletedCollections.has(r.collection) && !isTopLevelDelete(r.diff) + ), + }; +} + export class SchemaExporter implements IExporter { protected _filePath: string; protected _exportHandler = condenseAction(() => this.createAndSaveSnapshot()); @@ -16,7 +44,7 @@ export class SchemaExporter implements IExporter { constructor( protected getSchemaService: () => Promise>, protected logger: ApiExtensionContext['logger'], - protected options = { split: true } + protected options = { split: true, safe: false } ) { this._filePath = `${ExportHelper.dataDir}/schema.json`; } @@ -113,8 +141,12 @@ export class SchemaExporter implements IExporter { } this.logger.info(`Diffing schema with hash: ${currentHash} and hash: ${hash}`); - const diff = await svc.diff(snapshot, { currentSnapshot, force: true }); + let diff = await svc.diff(snapshot, { currentSnapshot, force: true }); if (diff !== null) { + if (this.options.safe) { + diff = filterNonDestructive(diff); + this.logger.info('SCHEMA_SYNC_SAFE: filtered destructive operations from diff'); + } this.logger.info(`Applying schema diff...`); await svc.apply({ diff, hash: currentHash }); this.logger.info(`Schema updated`); From a03b559defde3702f0dd46081e10533fd6035b11 Mon Sep 17 00:00:00 2001 From: Gerard Date: Tue, 7 Apr 2026 17:49:23 +0200 Subject: [PATCH 2/2] Fixed merged --- src/schemaExporter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schemaExporter.ts b/src/schemaExporter.ts index f28c01a..3e5ac25 100644 --- a/src/schemaExporter.ts +++ b/src/schemaExporter.ts @@ -1,5 +1,5 @@ import type { ApiExtensionContext } from '@directus/extensions'; -import type { Collection, ExtensionsServices, Snapshot, SnapshotField, SnapshotRelation } from '@directus/types'; +import type { Collection, ExtensionsServices, Snapshot, SnapshotDiff, SnapshotField, SnapshotRelation } from '@directus/types'; import { mkdir, readFile, rm, writeFile } from 'fs/promises'; import { glob } from 'glob'; import { condenseAction } from './condenseAction.js';