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`);