From 6a5d8b4a462d36c84a1e353d4b0cf67979e556e9 Mon Sep 17 00:00:00 2001 From: iamtheluckyest Date: Tue, 11 Aug 2026 07:36:44 -0400 Subject: [PATCH 1/5] feat(cli): pull per-workflow deletion dependencies into transcend.yml Reads `dependedOnDataSilosPerWorkflow` and writes each workflow that overrides the global dependencies as its own entry. Global dependencies keep the list-of-titles shorthand so existing configs round-trip unchanged. Overrides on workflows with no internal name are skipped with a warning, since transcend.yml references workflows by internal name. --- ...pull-per-workflow-deletion-dependencies.md | 8 + .../lib/graphql/buildDeletionDependencies.ts | 56 +++++++ .../lib/graphql/pullTranscendConfiguration.ts | 25 ++- .../tests/buildDeletionDependencies.test.ts | 142 ++++++++++++++++++ .../data-inventory/fetchEnrichedDataSilos.ts | 15 +- .../sdk/src/data-inventory/gqls/dataSilo.ts | 6 + 6 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 .changeset/wal-10452-pull-per-workflow-deletion-dependencies.md create mode 100644 packages/cli/src/lib/graphql/buildDeletionDependencies.ts create mode 100644 packages/cli/src/lib/tests/buildDeletionDependencies.test.ts diff --git a/.changeset/wal-10452-pull-per-workflow-deletion-dependencies.md b/.changeset/wal-10452-pull-per-workflow-deletion-dependencies.md new file mode 100644 index 00000000..e7662cc4 --- /dev/null +++ b/.changeset/wal-10452-pull-per-workflow-deletion-dependencies.md @@ -0,0 +1,8 @@ +--- +"@transcend-io/cli": minor +"@transcend-io/sdk": minor +--- + +`transcend inventory pull` now writes per-workflow deletion dependencies into `transcend.yml`. Global dependencies keep the existing list-of-titles shorthand, and each workflow that overrides them is written as its own entry. Overrides on workflows without an internal name are skipped with a warning, since `transcend.yml` references workflows by internal name. + +`DataSiloEnriched` gains `dependedOnDataSilosPerWorkflow`. diff --git a/packages/cli/src/lib/graphql/buildDeletionDependencies.ts b/packages/cli/src/lib/graphql/buildDeletionDependencies.ts new file mode 100644 index 00000000..3af07398 --- /dev/null +++ b/packages/cli/src/lib/graphql/buildDeletionDependencies.ts @@ -0,0 +1,56 @@ +import type { DataSiloEnriched, WorkflowConfigNode } from '@transcend-io/sdk'; +import colors from 'colors'; + +import type { DataSiloInput, DeletionDependencyInput } from '../../codecs.js'; +import { logger } from '../../logger.js'; + +/** + * Build the `deletion-dependencies` entries for a data silo being pulled into transcend.yml. + * + * Global dependencies keep the string shorthand so that existing configurations round-trip + * unchanged. Each workflow that overrides the global configuration is emitted as its own + * entry, including overrides with no dependencies at all. + * + * @param dataSilo - The data silo being pulled + * @param workflowConfigsById - The organization's erasure workflows, keyed by ID + * @returns The `deletion-dependencies` field, or an empty object when there is nothing to write + */ +export function buildDeletionDependenciesInput( + dataSilo: Pick< + DataSiloEnriched, + 'title' | 'dependentDataSilos' | 'dependedOnDataSilosPerWorkflow' + >, + workflowConfigsById: Record>, +): Pick { + const { title, dependentDataSilos, dependedOnDataSilosPerWorkflow } = dataSilo; + + const overrides = dependedOnDataSilosPerWorkflow.flatMap( + ({ workflowConfigId, dependedOnDataSilos }): DeletionDependencyInput[] => { + const workflowConfig = workflowConfigsById[workflowConfigId]; + + // transcend.yml references workflows by internal name, so an override on a workflow + // without one cannot be expressed + if (!workflowConfig?.internalName) { + logger.warn( + colors.yellow( + `Skipping the deletion dependency override on data silo "${title}" for workflow ` + + `"${workflowConfig?.title.defaultMessage ?? workflowConfigId}" because that ` + + 'workflow has no internal name.', + ), + ); + return []; + } + + return [ + { + workflow: workflowConfig.internalName, + titles: dependedOnDataSilos.map(({ title }) => title), + }, + ]; + }, + ); + + const dependencies = [...dependentDataSilos.map(({ title }) => title), ...overrides]; + + return dependencies.length > 0 ? { 'deletion-dependencies': dependencies } : {}; +} diff --git a/packages/cli/src/lib/graphql/pullTranscendConfiguration.ts b/packages/cli/src/lib/graphql/pullTranscendConfiguration.ts index 50fa0c6b..635d18a9 100644 --- a/packages/cli/src/lib/graphql/pullTranscendConfiguration.ts +++ b/packages/cli/src/lib/graphql/pullTranscendConfiguration.ts @@ -95,6 +95,7 @@ import { } from '../../codecs.js'; import { TranscendPullResource } from '../../enums.js'; import { logger } from '../../logger.js'; +import { buildDeletionDependenciesInput } from './buildDeletionDependencies.js'; import { fetchAllAssessmentTemplates } from './fetchAllAssessmentTemplates.js'; export const DEFAULT_TRANSCEND_PULL_RESOURCES = [ @@ -1507,6 +1508,20 @@ export async function pullTranscendConfiguration( // Save data silos if (dataSilos.length > 0 && resources.includes(TranscendPullResource.DataSilos)) { const indexedDataSubjects = keyBy(dataSubjects, 'type'); + // Per-workflow deletion dependencies come back keyed by ID, but transcend.yml + // references workflows by internal name + const hasWorkflowScopedDependencies = dataSilos.some( + ([{ dependedOnDataSilosPerWorkflow }]) => dependedOnDataSilosPerWorkflow.length > 0, + ); + const workflowConfigsById = hasWorkflowScopedDependencies + ? keyBy( + await fetchAllWorkflowConfigs(client, { + workflowConfigType: WorkflowConfigType.DSR, + logger, + }), + 'id', + ) + : {}; result['data-silos'] = dataSilos.map( ([ { @@ -1519,6 +1534,7 @@ export async function pullTranscendConfiguration( notifyEmailAddress, identifiers, dependentDataSilos, + dependedOnDataSilosPerWorkflow, owners, country, countrySubDivision, @@ -1548,11 +1564,10 @@ export async function pullTranscendConfiguration( 'identity-keys': identifiers .filter(({ isConnected }) => isConnected) .map(({ name }) => name), - ...(dependentDataSilos.length > 0 - ? { - 'deletion-dependencies': dependentDataSilos.map(({ title }) => title), - } - : {}), + ...buildDeletionDependenciesInput( + { title, dependentDataSilos, dependedOnDataSilosPerWorkflow }, + workflowConfigsById, + ), ...(owners.length > 0 ? { owners: owners.map(({ email }) => email) } : {}), ...(teams.length > 0 ? { teams: teams.map(({ name }) => name) } : {}), ...(discoveredBy.length > 0 diff --git a/packages/cli/src/lib/tests/buildDeletionDependencies.test.ts b/packages/cli/src/lib/tests/buildDeletionDependencies.test.ts new file mode 100644 index 00000000..03cdce5a --- /dev/null +++ b/packages/cli/src/lib/tests/buildDeletionDependencies.test.ts @@ -0,0 +1,142 @@ +import type { WorkflowConfigNode } from '@transcend-io/sdk'; +import { expect, describe, it, vi, afterEach } from 'vitest'; + +import { buildDeletionDependenciesInput } from '../graphql/buildDeletionDependencies.js'; + +type WorkflowConfigFixture = Pick & { + /** ID of the workflow config */ + id: string; +}; + +const GDPR_ERASURE: WorkflowConfigFixture = { + id: 'wf-gdpr', + internalName: 'GDPR Erasure', + title: { defaultMessage: 'GDPR Erasure' }, +}; + +const UNNAMED_WORKFLOW: WorkflowConfigFixture = { + id: 'wf-unnamed', + internalName: null, + title: { defaultMessage: 'Unnamed Erasure' }, +}; + +const workflowConfigsById = { + [GDPR_ERASURE.id]: GDPR_ERASURE, + [UNNAMED_WORKFLOW.id]: UNNAMED_WORKFLOW, +}; + +describe('buildDeletionDependenciesInput', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('omits the field when there are no dependencies', () => { + expect( + buildDeletionDependenciesInput( + { + title: 'Salesforce', + dependentDataSilos: [], + dependedOnDataSilosPerWorkflow: [], + }, + workflowConfigsById, + ), + ).to.deep.equal({}); + }); + + it('writes global dependencies as the string shorthand', () => { + expect( + buildDeletionDependenciesInput( + { + title: 'Salesforce', + dependentDataSilos: [{ title: 'Identity Service' }], + dependedOnDataSilosPerWorkflow: [], + }, + workflowConfigsById, + ), + ).to.deep.equal({ 'deletion-dependencies': ['Identity Service'] }); + }); + + it('writes workflow overrides keyed by internal name', () => { + expect( + buildDeletionDependenciesInput( + { + title: 'Salesforce', + dependentDataSilos: [{ title: 'Identity Service' }], + dependedOnDataSilosPerWorkflow: [ + { + workflowConfigId: GDPR_ERASURE.id, + dependedOnDataSilos: [{ title: 'Identity Service' }, { title: 'CRM Warehouse' }], + }, + ], + }, + workflowConfigsById, + ), + ).to.deep.equal({ + 'deletion-dependencies': [ + 'Identity Service', + { workflow: 'GDPR Erasure', titles: ['Identity Service', 'CRM Warehouse'] }, + ], + }); + }); + + it('keeps an explicit empty override distinguishable from an absent one', () => { + expect( + buildDeletionDependenciesInput( + { + title: 'Salesforce', + dependentDataSilos: [{ title: 'Identity Service' }], + dependedOnDataSilosPerWorkflow: [ + { workflowConfigId: GDPR_ERASURE.id, dependedOnDataSilos: [] }, + ], + }, + workflowConfigsById, + ), + ).to.deep.equal({ + 'deletion-dependencies': ['Identity Service', { workflow: 'GDPR Erasure', titles: [] }], + }); + }); + + it('skips overrides on workflows without an internal name', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + expect( + buildDeletionDependenciesInput( + { + title: 'Salesforce', + dependentDataSilos: [], + dependedOnDataSilosPerWorkflow: [ + { + workflowConfigId: UNNAMED_WORKFLOW.id, + dependedOnDataSilos: [{ title: 'Identity Service' }], + }, + ], + }, + workflowConfigsById, + ), + ).to.deep.equal({}); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0][0]).to.include('Unnamed Erasure'); + }); + + it('skips overrides on workflows that were not fetched', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + expect( + buildDeletionDependenciesInput( + { + title: 'Salesforce', + dependentDataSilos: [], + dependedOnDataSilosPerWorkflow: [ + { + workflowConfigId: 'wf-missing', + dependedOnDataSilos: [{ title: 'Identity Service' }], + }, + ], + }, + workflowConfigsById, + ), + ).to.deep.equal({}); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0][0]).to.include('wf-missing'); + }); +}); diff --git a/packages/sdk/src/data-inventory/fetchEnrichedDataSilos.ts b/packages/sdk/src/data-inventory/fetchEnrichedDataSilos.ts index b6b0491b..06a78888 100644 --- a/packages/sdk/src/data-inventory/fetchEnrichedDataSilos.ts +++ b/packages/sdk/src/data-inventory/fetchEnrichedDataSilos.ts @@ -46,11 +46,24 @@ export interface DataSiloEnriched { /** True if identifier is wired */ isConnected: boolean; }[]; - /** Dependent data silos */ + /** Dependent data silos that apply to every erasure workflow */ dependentDataSilos: { /** Title of silo */ title: string; }[]; + /** + * Dependent data silos for the erasure workflows that override the global configuration. + * An entry with an empty list is a workflow that runs this data silo with no dependencies. + */ + dependedOnDataSilosPerWorkflow: { + /** ID of the workflow config the override applies to */ + workflowConfigId: string; + /** Silos depended on within that workflow */ + dependedOnDataSilos: { + /** Title of silo */ + title: string; + }[]; + }[]; /** Silo owners */ owners: { /** Email owners */ diff --git a/packages/sdk/src/data-inventory/gqls/dataSilo.ts b/packages/sdk/src/data-inventory/gqls/dataSilo.ts index 9df87ec4..a316b39f 100644 --- a/packages/sdk/src/data-inventory/gqls/dataSilo.ts +++ b/packages/sdk/src/data-inventory/gqls/dataSilo.ts @@ -82,6 +82,12 @@ export const DATA_SILOS_ENRICHED = gql` dependentDataSilos { title } + dependedOnDataSilosPerWorkflow { + workflowConfigId + dependedOnDataSilos { + title + } + } owners { email } From ad8400a65de5c85a6a2d1970cd47d78e5d105dae Mon Sep 17 00:00:00 2001 From: iamtheluckyest Date: Tue, 11 Aug 2026 08:08:28 -0400 Subject: [PATCH 2/5] fix(cli): emit full object[] when pull has deletion-dependency overrides Match the either/or codec: keep string[] for global-only configs, and write global deps as `{ titles }` alongside overrides when any override exists. --- ...pull-per-workflow-deletion-dependencies.md | 2 +- .../lib/graphql/buildDeletionDependencies.ts | 27 +++++++--- .../tests/buildDeletionDependencies.test.ts | 50 +++++++++++++++++-- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/.changeset/wal-10452-pull-per-workflow-deletion-dependencies.md b/.changeset/wal-10452-pull-per-workflow-deletion-dependencies.md index e7662cc4..e68c8449 100644 --- a/.changeset/wal-10452-pull-per-workflow-deletion-dependencies.md +++ b/.changeset/wal-10452-pull-per-workflow-deletion-dependencies.md @@ -3,6 +3,6 @@ "@transcend-io/sdk": minor --- -`transcend inventory pull` now writes per-workflow deletion dependencies into `transcend.yml`. Global dependencies keep the existing list-of-titles shorthand, and each workflow that overrides them is written as its own entry. Overrides on workflows without an internal name are skipped with a warning, since `transcend.yml` references workflows by internal name. +`transcend inventory pull` now writes per-workflow deletion dependencies into `transcend.yml`. Global-only configs stay as a list of titles; once any override exists, the whole field is written as objects. Overrides on workflows without an internal name are skipped with a warning, since `transcend.yml` references workflows by internal name. `DataSiloEnriched` gains `dependedOnDataSilosPerWorkflow`. diff --git a/packages/cli/src/lib/graphql/buildDeletionDependencies.ts b/packages/cli/src/lib/graphql/buildDeletionDependencies.ts index 3af07398..99ecd737 100644 --- a/packages/cli/src/lib/graphql/buildDeletionDependencies.ts +++ b/packages/cli/src/lib/graphql/buildDeletionDependencies.ts @@ -1,15 +1,19 @@ import type { DataSiloEnriched, WorkflowConfigNode } from '@transcend-io/sdk'; import colors from 'colors'; -import type { DataSiloInput, DeletionDependencyInput } from '../../codecs.js'; +import type { + DataSiloInput, + DeletionDependencies, + DeletionDependencyObject, +} from '../../codecs.js'; import { logger } from '../../logger.js'; /** * Build the `deletion-dependencies` entries for a data silo being pulled into transcend.yml. * - * Global dependencies keep the string shorthand so that existing configurations round-trip - * unchanged. Each workflow that overrides the global configuration is emitted as its own - * entry, including overrides with no dependencies at all. + * With no per-workflow overrides, global dependencies stay as a list of titles so existing + * configurations round-trip unchanged. Once any override is present, the whole field is a + * list of objects (`{ titles }` for global, `{ workflow, titles }` for each override). * * @param dataSilo - The data silo being pulled * @param workflowConfigsById - The organization's erasure workflows, keyed by ID @@ -24,8 +28,10 @@ export function buildDeletionDependenciesInput( ): Pick { const { title, dependentDataSilos, dependedOnDataSilosPerWorkflow } = dataSilo; + const globalTitles = dependentDataSilos.map(({ title }) => title); + const overrides = dependedOnDataSilosPerWorkflow.flatMap( - ({ workflowConfigId, dependedOnDataSilos }): DeletionDependencyInput[] => { + ({ workflowConfigId, dependedOnDataSilos }): DeletionDependencyObject[] => { const workflowConfig = workflowConfigsById[workflowConfigId]; // transcend.yml references workflows by internal name, so an override on a workflow @@ -50,7 +56,14 @@ export function buildDeletionDependenciesInput( }, ); - const dependencies = [...dependentDataSilos.map(({ title }) => title), ...overrides]; + if (overrides.length === 0) { + return globalTitles.length > 0 ? { 'deletion-dependencies': globalTitles } : {}; + } + + const dependencies: DeletionDependencies = [ + ...(globalTitles.length > 0 ? [{ titles: globalTitles }] : []), + ...overrides, + ]; - return dependencies.length > 0 ? { 'deletion-dependencies': dependencies } : {}; + return { 'deletion-dependencies': dependencies }; } diff --git a/packages/cli/src/lib/tests/buildDeletionDependencies.test.ts b/packages/cli/src/lib/tests/buildDeletionDependencies.test.ts index 03cdce5a..8430e496 100644 --- a/packages/cli/src/lib/tests/buildDeletionDependencies.test.ts +++ b/packages/cli/src/lib/tests/buildDeletionDependencies.test.ts @@ -56,7 +56,7 @@ describe('buildDeletionDependenciesInput', () => { ).to.deep.equal({ 'deletion-dependencies': ['Identity Service'] }); }); - it('writes workflow overrides keyed by internal name', () => { + it('writes workflow overrides as a full object list', () => { expect( buildDeletionDependenciesInput( { @@ -73,7 +73,7 @@ describe('buildDeletionDependenciesInput', () => { ), ).to.deep.equal({ 'deletion-dependencies': [ - 'Identity Service', + { titles: ['Identity Service'] }, { workflow: 'GDPR Erasure', titles: ['Identity Service', 'CRM Warehouse'] }, ], }); @@ -92,7 +92,30 @@ describe('buildDeletionDependenciesInput', () => { workflowConfigsById, ), ).to.deep.equal({ - 'deletion-dependencies': ['Identity Service', { workflow: 'GDPR Erasure', titles: [] }], + 'deletion-dependencies': [ + { titles: ['Identity Service'] }, + { workflow: 'GDPR Erasure', titles: [] }, + ], + }); + }); + + it('omits the global object when only workflow overrides are present', () => { + expect( + buildDeletionDependenciesInput( + { + title: 'Salesforce', + dependentDataSilos: [], + dependedOnDataSilosPerWorkflow: [ + { + workflowConfigId: GDPR_ERASURE.id, + dependedOnDataSilos: [{ title: 'Identity Service' }], + }, + ], + }, + workflowConfigsById, + ), + ).to.deep.equal({ + 'deletion-dependencies': [{ workflow: 'GDPR Erasure', titles: ['Identity Service'] }], }); }); @@ -118,6 +141,27 @@ describe('buildDeletionDependenciesInput', () => { expect(warn.mock.calls[0][0]).to.include('Unnamed Erasure'); }); + it('keeps global titles as strings when every override is skipped', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + expect( + buildDeletionDependenciesInput( + { + title: 'Salesforce', + dependentDataSilos: [{ title: 'Identity Service' }], + dependedOnDataSilosPerWorkflow: [ + { + workflowConfigId: UNNAMED_WORKFLOW.id, + dependedOnDataSilos: [{ title: 'CRM Warehouse' }], + }, + ], + }, + workflowConfigsById, + ), + ).to.deep.equal({ 'deletion-dependencies': ['Identity Service'] }); + expect(warn).toHaveBeenCalledOnce(); + }); + it('skips overrides on workflows that were not fetched', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); From c812d282a7e308ace73f71b2e61bfb94f0a446a9 Mon Sep 17 00:00:00 2001 From: iamtheluckyest Date: Tue, 11 Aug 2026 09:59:18 -0400 Subject: [PATCH 3/5] fix(cli): rename DeletionDependencyObject import after push merge --- .../cli/src/lib/graphql/buildDeletionDependencies.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/lib/graphql/buildDeletionDependencies.ts b/packages/cli/src/lib/graphql/buildDeletionDependencies.ts index 99ecd737..fd54f6e8 100644 --- a/packages/cli/src/lib/graphql/buildDeletionDependencies.ts +++ b/packages/cli/src/lib/graphql/buildDeletionDependencies.ts @@ -1,19 +1,15 @@ import type { DataSiloEnriched, WorkflowConfigNode } from '@transcend-io/sdk'; import colors from 'colors'; -import type { - DataSiloInput, - DeletionDependencies, - DeletionDependencyObject, -} from '../../codecs.js'; +import type { DataSiloInput, DeletionDependencies, DeletionDependencyInput } from '../../codecs.js'; import { logger } from '../../logger.js'; /** * Build the `deletion-dependencies` entries for a data silo being pulled into transcend.yml. * * With no per-workflow overrides, global dependencies stay as a list of titles so existing - * configurations round-trip unchanged. Once any override is present, the whole field is a - * list of objects (`{ titles }` for global, `{ workflow, titles }` for each override). + * configurations round-trip unchanged. When overrides exist, the whole field is a list of + * objects (`{ titles }` for global, `{ workflow, titles }` for each override). * * @param dataSilo - The data silo being pulled * @param workflowConfigsById - The organization's erasure workflows, keyed by ID @@ -31,7 +27,7 @@ export function buildDeletionDependenciesInput( const globalTitles = dependentDataSilos.map(({ title }) => title); const overrides = dependedOnDataSilosPerWorkflow.flatMap( - ({ workflowConfigId, dependedOnDataSilos }): DeletionDependencyObject[] => { + ({ workflowConfigId, dependedOnDataSilos }): DeletionDependencyInput[] => { const workflowConfig = workflowConfigsById[workflowConfigId]; // transcend.yml references workflows by internal name, so an override on a workflow From c02170d76957a45cf3e6c3d1f7323c32b234ba87 Mon Sep 17 00:00:00 2001 From: iamtheluckyest Date: Tue, 11 Aug 2026 10:20:37 -0400 Subject: [PATCH 4/5] Add comments, small refactor --- .../lib/graphql/buildDeletionDependencies.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/lib/graphql/buildDeletionDependencies.ts b/packages/cli/src/lib/graphql/buildDeletionDependencies.ts index fd54f6e8..633bf479 100644 --- a/packages/cli/src/lib/graphql/buildDeletionDependencies.ts +++ b/packages/cli/src/lib/graphql/buildDeletionDependencies.ts @@ -26,7 +26,7 @@ export function buildDeletionDependenciesInput( const globalTitles = dependentDataSilos.map(({ title }) => title); - const overrides = dependedOnDataSilosPerWorkflow.flatMap( + const workflowOverrides = dependedOnDataSilosPerWorkflow.flatMap( ({ workflowConfigId, dependedOnDataSilos }): DeletionDependencyInput[] => { const workflowConfig = workflowConfigsById[workflowConfigId]; @@ -52,14 +52,21 @@ export function buildDeletionDependenciesInput( }, ); - if (overrides.length === 0) { - return globalTitles.length > 0 ? { 'deletion-dependencies': globalTitles } : {}; + // If there are no global or workflow dependencies, return an empty object + if (globalTitles.length === 0 && workflowOverrides.length === 0) { + return {}; } - const dependencies: DeletionDependencies = [ - ...(globalTitles.length > 0 ? [{ titles: globalTitles }] : []), - ...overrides, - ]; + // If there are no workflow overrides, return the global titles as string[], to keep legacy behavior + if (workflowOverrides.length === 0) { + return { 'deletion-dependencies': globalTitles }; + } - return { 'deletion-dependencies': dependencies }; + // If there are workflow overrides, return a list of objects + return { + 'deletion-dependencies': [ + ...(globalTitles.length > 0 ? [{ titles: globalTitles }] : []), + ...workflowOverrides, + ], + }; } From f36ecf6a416ffe0d054fae6bf1088e0819fac6a7 Mon Sep 17 00:00:00 2001 From: iamtheluckyest Date: Mon, 17 Aug 2026 13:35:24 -0400 Subject: [PATCH 5/5] fix(cli): use workflowInternalName for deletion-dependency pull Read the internal name from dependedOnDataSilosPerWorkflow so pull no longer needs a separate workflowConfigs fetch (or ViewWorkflows scope). --- .../lib/graphql/buildDeletionDependencies.ts | 44 +---- .../lib/graphql/pullTranscendConfiguration.ts | 22 +-- .../tests/buildDeletionDependencies.test.ts | 184 ++++-------------- .../data-inventory/fetchEnrichedDataSilos.ts | 2 + .../sdk/src/data-inventory/gqls/dataSilo.ts | 1 + 5 files changed, 55 insertions(+), 198 deletions(-) diff --git a/packages/cli/src/lib/graphql/buildDeletionDependencies.ts b/packages/cli/src/lib/graphql/buildDeletionDependencies.ts index 633bf479..19277be4 100644 --- a/packages/cli/src/lib/graphql/buildDeletionDependencies.ts +++ b/packages/cli/src/lib/graphql/buildDeletionDependencies.ts @@ -1,8 +1,6 @@ -import type { DataSiloEnriched, WorkflowConfigNode } from '@transcend-io/sdk'; -import colors from 'colors'; +import type { DataSiloEnriched } from '@transcend-io/sdk'; -import type { DataSiloInput, DeletionDependencies, DeletionDependencyInput } from '../../codecs.js'; -import { logger } from '../../logger.js'; +import type { DataSiloInput } from '../../codecs.js'; /** * Build the `deletion-dependencies` entries for a data silo being pulled into transcend.yml. @@ -12,44 +10,20 @@ import { logger } from '../../logger.js'; * objects (`{ titles }` for global, `{ workflow, titles }` for each override). * * @param dataSilo - The data silo being pulled - * @param workflowConfigsById - The organization's erasure workflows, keyed by ID * @returns The `deletion-dependencies` field, or an empty object when there is nothing to write */ export function buildDeletionDependenciesInput( - dataSilo: Pick< - DataSiloEnriched, - 'title' | 'dependentDataSilos' | 'dependedOnDataSilosPerWorkflow' - >, - workflowConfigsById: Record>, + dataSilo: Pick, ): Pick { - const { title, dependentDataSilos, dependedOnDataSilosPerWorkflow } = dataSilo; + const { dependentDataSilos, dependedOnDataSilosPerWorkflow } = dataSilo; const globalTitles = dependentDataSilos.map(({ title }) => title); - const workflowOverrides = dependedOnDataSilosPerWorkflow.flatMap( - ({ workflowConfigId, dependedOnDataSilos }): DeletionDependencyInput[] => { - const workflowConfig = workflowConfigsById[workflowConfigId]; - - // transcend.yml references workflows by internal name, so an override on a workflow - // without one cannot be expressed - if (!workflowConfig?.internalName) { - logger.warn( - colors.yellow( - `Skipping the deletion dependency override on data silo "${title}" for workflow ` + - `"${workflowConfig?.title.defaultMessage ?? workflowConfigId}" because that ` + - 'workflow has no internal name.', - ), - ); - return []; - } - - return [ - { - workflow: workflowConfig.internalName, - titles: dependedOnDataSilos.map(({ title }) => title), - }, - ]; - }, + const workflowOverrides = dependedOnDataSilosPerWorkflow.map( + ({ workflowInternalName, dependedOnDataSilos }) => ({ + workflow: workflowInternalName, + titles: dependedOnDataSilos.map(({ title }) => title), + }), ); // If there are no global or workflow dependencies, return an empty object diff --git a/packages/cli/src/lib/graphql/pullTranscendConfiguration.ts b/packages/cli/src/lib/graphql/pullTranscendConfiguration.ts index 635d18a9..fe4bfa69 100644 --- a/packages/cli/src/lib/graphql/pullTranscendConfiguration.ts +++ b/packages/cli/src/lib/graphql/pullTranscendConfiguration.ts @@ -1508,20 +1508,6 @@ export async function pullTranscendConfiguration( // Save data silos if (dataSilos.length > 0 && resources.includes(TranscendPullResource.DataSilos)) { const indexedDataSubjects = keyBy(dataSubjects, 'type'); - // Per-workflow deletion dependencies come back keyed by ID, but transcend.yml - // references workflows by internal name - const hasWorkflowScopedDependencies = dataSilos.some( - ([{ dependedOnDataSilosPerWorkflow }]) => dependedOnDataSilosPerWorkflow.length > 0, - ); - const workflowConfigsById = hasWorkflowScopedDependencies - ? keyBy( - await fetchAllWorkflowConfigs(client, { - workflowConfigType: WorkflowConfigType.DSR, - logger, - }), - 'id', - ) - : {}; result['data-silos'] = dataSilos.map( ([ { @@ -1564,10 +1550,10 @@ export async function pullTranscendConfiguration( 'identity-keys': identifiers .filter(({ isConnected }) => isConnected) .map(({ name }) => name), - ...buildDeletionDependenciesInput( - { title, dependentDataSilos, dependedOnDataSilosPerWorkflow }, - workflowConfigsById, - ), + ...buildDeletionDependenciesInput({ + dependentDataSilos, + dependedOnDataSilosPerWorkflow, + }), ...(owners.length > 0 ? { owners: owners.map(({ email }) => email) } : {}), ...(teams.length > 0 ? { teams: teams.map(({ name }) => name) } : {}), ...(discoveredBy.length > 0 diff --git a/packages/cli/src/lib/tests/buildDeletionDependencies.test.ts b/packages/cli/src/lib/tests/buildDeletionDependencies.test.ts index 8430e496..5fdfb704 100644 --- a/packages/cli/src/lib/tests/buildDeletionDependencies.test.ts +++ b/packages/cli/src/lib/tests/buildDeletionDependencies.test.ts @@ -1,76 +1,38 @@ -import type { WorkflowConfigNode } from '@transcend-io/sdk'; -import { expect, describe, it, vi, afterEach } from 'vitest'; +import { expect, describe, it } from 'vitest'; import { buildDeletionDependenciesInput } from '../graphql/buildDeletionDependencies.js'; -type WorkflowConfigFixture = Pick & { - /** ID of the workflow config */ - id: string; -}; - -const GDPR_ERASURE: WorkflowConfigFixture = { - id: 'wf-gdpr', - internalName: 'GDPR Erasure', - title: { defaultMessage: 'GDPR Erasure' }, -}; - -const UNNAMED_WORKFLOW: WorkflowConfigFixture = { - id: 'wf-unnamed', - internalName: null, - title: { defaultMessage: 'Unnamed Erasure' }, -}; - -const workflowConfigsById = { - [GDPR_ERASURE.id]: GDPR_ERASURE, - [UNNAMED_WORKFLOW.id]: UNNAMED_WORKFLOW, -}; - describe('buildDeletionDependenciesInput', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - it('omits the field when there are no dependencies', () => { expect( - buildDeletionDependenciesInput( - { - title: 'Salesforce', - dependentDataSilos: [], - dependedOnDataSilosPerWorkflow: [], - }, - workflowConfigsById, - ), + buildDeletionDependenciesInput({ + dependentDataSilos: [], + dependedOnDataSilosPerWorkflow: [], + }), ).to.deep.equal({}); }); it('writes global dependencies as the string shorthand', () => { expect( - buildDeletionDependenciesInput( - { - title: 'Salesforce', - dependentDataSilos: [{ title: 'Identity Service' }], - dependedOnDataSilosPerWorkflow: [], - }, - workflowConfigsById, - ), + buildDeletionDependenciesInput({ + dependentDataSilos: [{ title: 'Identity Service' }], + dependedOnDataSilosPerWorkflow: [], + }), ).to.deep.equal({ 'deletion-dependencies': ['Identity Service'] }); }); it('writes workflow overrides as a full object list', () => { expect( - buildDeletionDependenciesInput( - { - title: 'Salesforce', - dependentDataSilos: [{ title: 'Identity Service' }], - dependedOnDataSilosPerWorkflow: [ - { - workflowConfigId: GDPR_ERASURE.id, - dependedOnDataSilos: [{ title: 'Identity Service' }, { title: 'CRM Warehouse' }], - }, - ], - }, - workflowConfigsById, - ), + buildDeletionDependenciesInput({ + dependentDataSilos: [{ title: 'Identity Service' }], + dependedOnDataSilosPerWorkflow: [ + { + workflowConfigId: 'wf-gdpr', + workflowInternalName: 'GDPR Erasure', + dependedOnDataSilos: [{ title: 'Identity Service' }, { title: 'CRM Warehouse' }], + }, + ], + }), ).to.deep.equal({ 'deletion-dependencies': [ { titles: ['Identity Service'] }, @@ -81,16 +43,16 @@ describe('buildDeletionDependenciesInput', () => { it('keeps an explicit empty override distinguishable from an absent one', () => { expect( - buildDeletionDependenciesInput( - { - title: 'Salesforce', - dependentDataSilos: [{ title: 'Identity Service' }], - dependedOnDataSilosPerWorkflow: [ - { workflowConfigId: GDPR_ERASURE.id, dependedOnDataSilos: [] }, - ], - }, - workflowConfigsById, - ), + buildDeletionDependenciesInput({ + dependentDataSilos: [{ title: 'Identity Service' }], + dependedOnDataSilosPerWorkflow: [ + { + workflowConfigId: 'wf-gdpr', + workflowInternalName: 'GDPR Erasure', + dependedOnDataSilos: [], + }, + ], + }), ).to.deep.equal({ 'deletion-dependencies': [ { titles: ['Identity Service'] }, @@ -101,86 +63,18 @@ describe('buildDeletionDependenciesInput', () => { it('omits the global object when only workflow overrides are present', () => { expect( - buildDeletionDependenciesInput( - { - title: 'Salesforce', - dependentDataSilos: [], - dependedOnDataSilosPerWorkflow: [ - { - workflowConfigId: GDPR_ERASURE.id, - dependedOnDataSilos: [{ title: 'Identity Service' }], - }, - ], - }, - workflowConfigsById, - ), + buildDeletionDependenciesInput({ + dependentDataSilos: [], + dependedOnDataSilosPerWorkflow: [ + { + workflowConfigId: 'wf-gdpr', + workflowInternalName: 'GDPR Erasure', + dependedOnDataSilos: [{ title: 'Identity Service' }], + }, + ], + }), ).to.deep.equal({ 'deletion-dependencies': [{ workflow: 'GDPR Erasure', titles: ['Identity Service'] }], }); }); - - it('skips overrides on workflows without an internal name', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - - expect( - buildDeletionDependenciesInput( - { - title: 'Salesforce', - dependentDataSilos: [], - dependedOnDataSilosPerWorkflow: [ - { - workflowConfigId: UNNAMED_WORKFLOW.id, - dependedOnDataSilos: [{ title: 'Identity Service' }], - }, - ], - }, - workflowConfigsById, - ), - ).to.deep.equal({}); - expect(warn).toHaveBeenCalledOnce(); - expect(warn.mock.calls[0][0]).to.include('Unnamed Erasure'); - }); - - it('keeps global titles as strings when every override is skipped', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - - expect( - buildDeletionDependenciesInput( - { - title: 'Salesforce', - dependentDataSilos: [{ title: 'Identity Service' }], - dependedOnDataSilosPerWorkflow: [ - { - workflowConfigId: UNNAMED_WORKFLOW.id, - dependedOnDataSilos: [{ title: 'CRM Warehouse' }], - }, - ], - }, - workflowConfigsById, - ), - ).to.deep.equal({ 'deletion-dependencies': ['Identity Service'] }); - expect(warn).toHaveBeenCalledOnce(); - }); - - it('skips overrides on workflows that were not fetched', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - - expect( - buildDeletionDependenciesInput( - { - title: 'Salesforce', - dependentDataSilos: [], - dependedOnDataSilosPerWorkflow: [ - { - workflowConfigId: 'wf-missing', - dependedOnDataSilos: [{ title: 'Identity Service' }], - }, - ], - }, - workflowConfigsById, - ), - ).to.deep.equal({}); - expect(warn).toHaveBeenCalledOnce(); - expect(warn.mock.calls[0][0]).to.include('wf-missing'); - }); }); diff --git a/packages/sdk/src/data-inventory/fetchEnrichedDataSilos.ts b/packages/sdk/src/data-inventory/fetchEnrichedDataSilos.ts index 06a78888..b4c7acca 100644 --- a/packages/sdk/src/data-inventory/fetchEnrichedDataSilos.ts +++ b/packages/sdk/src/data-inventory/fetchEnrichedDataSilos.ts @@ -58,6 +58,8 @@ export interface DataSiloEnriched { dependedOnDataSilosPerWorkflow: { /** ID of the workflow config the override applies to */ workflowConfigId: string; + /** Internal name of the workflow config; used as the transcend.yml workflow key */ + workflowInternalName: string; /** Silos depended on within that workflow */ dependedOnDataSilos: { /** Title of silo */ diff --git a/packages/sdk/src/data-inventory/gqls/dataSilo.ts b/packages/sdk/src/data-inventory/gqls/dataSilo.ts index a316b39f..b9704da6 100644 --- a/packages/sdk/src/data-inventory/gqls/dataSilo.ts +++ b/packages/sdk/src/data-inventory/gqls/dataSilo.ts @@ -84,6 +84,7 @@ export const DATA_SILOS_ENRICHED = gql` } dependedOnDataSilosPerWorkflow { workflowConfigId + workflowInternalName dependedOnDataSilos { title }