diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 610296d6dba..f0a12f0f4f0 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -159,6 +159,9 @@ jobs: - name: Tool request transport boundary audit run: bun run check:tool-request-boundary + - name: Trigger/block initialization cycle audit + run: bun run check:trigger-block-cycle + - name: SQL Date binding audit run: bun run check:sql-date-binding diff --git a/apps/sim/lib/workflows/triggers/mock-payload.ts b/apps/sim/lib/workflows/triggers/mock-payload.ts new file mode 100644 index 00000000000..18c69b7397f --- /dev/null +++ b/apps/sim/lib/workflows/triggers/mock-payload.ts @@ -0,0 +1,118 @@ +/** + * Mock payload generation from a trigger's `outputs` definition. + * + * Deliberately dependency-free. `@/triggers` imports this module, so anything reachable + * from here becomes reachable from the trigger barrel — and reaching `@/blocks` (directly, + * or via `trigger-utils`) recreates the `triggers` <-> `blocks` initialization cycle that + * `scripts/check-trigger-block-cycle.ts` guards against. + */ + +/** + * Generates mock data based on the output type definition + */ +function generateMockValue(type: string, _description?: string, fieldName?: string): unknown { + const name = fieldName || 'value' + + switch (type) { + case 'string': + return `mock_${name}` + + case 'number': + return 42 + + case 'boolean': + return true + + case 'array': + return [ + { + id: 'item_1', + name: 'Sample Item', + value: 'Sample Value', + }, + ] + + case 'json': + case 'object': + return { + id: 'sample_id', + name: 'Sample Object', + status: 'active', + } + + default: + return null + } +} + +/** + * Recursively processes nested output structures, expanding JSON-Schema-style + * objects/arrays that define `properties` or `items` instead of returning + * a generic placeholder. + */ +function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 10): unknown { + if (depth > maxDepth) { + return null + } + + if ( + field && + typeof field === 'object' && + 'type' in field && + typeof (field as Record).type === 'string' + ) { + const typedField = field as { + type: string + description?: string + properties?: Record + items?: unknown + } + + if ( + (typedField.type === 'object' || typedField.type === 'json') && + typedField.properties && + typeof typedField.properties === 'object' + ) { + const nestedObject: Record = {} + for (const [nestedKey, nestedField] of Object.entries(typedField.properties)) { + nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth) + } + return nestedObject + } + + if (typedField.type === 'array' && typedField.items && typeof typedField.items === 'object') { + const itemValue = processOutputField(`${key}_item`, typedField.items, depth + 1, maxDepth) + return [itemValue] + } + + return generateMockValue(typedField.type, typedField.description, key) + } + + if (field && typeof field === 'object' && !Array.isArray(field)) { + const nestedObject: Record = {} + for (const [nestedKey, nestedField] of Object.entries(field)) { + nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth) + } + return nestedObject + } + + return null +} + +/** + * Generates a mock payload based on outputs definition + */ +export function generateMockPayloadFromOutputsDefinition( + outputs: Record +): Record { + const mockPayload: Record = {} + + for (const [key, output] of Object.entries(outputs)) { + if (key === 'visualization') { + continue + } + mockPayload[key] = processOutputField(key, output) + } + + return mockPayload +} diff --git a/apps/sim/lib/workflows/triggers/trigger-utils.ts b/apps/sim/lib/workflows/triggers/trigger-utils.ts index 4f55dd0bbb7..0ab01bb7fda 100644 --- a/apps/sim/lib/workflows/triggers/trigger-utils.ts +++ b/apps/sim/lib/workflows/triggers/trigger-utils.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' +import { generateMockPayloadFromOutputsDefinition } from '@/lib/workflows/triggers/mock-payload' import { type StartBlockCandidate, StartBlockPath } from '@/lib/workflows/triggers/triggers' import { getAllBlocks, getBlock } from '@/blocks' import type { BlockConfig } from '@/blocks/types' @@ -25,123 +26,6 @@ export function hasValidStartBlockInState(state: WorkflowState | null | undefine return !!startBlock } -/** - * Generates mock data based on the output type definition - */ -function generateMockValue(type: string, _description?: string, fieldName?: string): unknown { - const name = fieldName || 'value' - - switch (type) { - case 'string': - return `mock_${name}` - - case 'number': - return 42 - - case 'boolean': - return true - - case 'array': - return [ - { - id: 'item_1', - name: 'Sample Item', - value: 'Sample Value', - }, - ] - - case 'json': - case 'object': - return { - id: 'sample_id', - name: 'Sample Object', - status: 'active', - } - - default: - return null - } -} - -/** - * Recursively processes nested output structures, expanding JSON-Schema-style - * objects/arrays that define `properties` or `items` instead of returning - * a generic placeholder. - */ -function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 10): unknown { - if (depth > maxDepth) { - return null - } - - if ( - field && - typeof field === 'object' && - 'type' in field && - typeof (field as Record).type === 'string' - ) { - const typedField = field as { - type: string - description?: string - properties?: Record - items?: unknown - } - - if ( - (typedField.type === 'object' || typedField.type === 'json') && - typedField.properties && - typeof typedField.properties === 'object' - ) { - const nestedObject: Record = {} - for (const [nestedKey, nestedField] of Object.entries(typedField.properties)) { - nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth) - } - return nestedObject - } - - if (typedField.type === 'array' && typedField.items && typeof typedField.items === 'object') { - const itemValue = processOutputField(`${key}_item`, typedField.items, depth + 1, maxDepth) - return [itemValue] - } - - return generateMockValue(typedField.type, typedField.description, key) - } - - if (field && typeof field === 'object' && !Array.isArray(field)) { - const nestedObject: Record = {} - for (const [nestedKey, nestedField] of Object.entries(field)) { - nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth) - } - return nestedObject - } - - return null -} - -/** - * Generates mock payload from outputs object - */ -function generateMockPayloadFromOutputs(outputs: Record): Record { - const mockPayload: Record = {} - - for (const [key, output] of Object.entries(outputs)) { - if (key === 'visualization') { - continue - } - mockPayload[key] = processOutputField(key, output) - } - - return mockPayload -} - -/** - * Generates a mock payload based on outputs definition - */ -export function generateMockPayloadFromOutputsDefinition( - outputs: Record -): Record { - return generateMockPayloadFromOutputs(outputs) -} - interface TriggerInfo { id: string name: string diff --git a/apps/sim/triggers/clickup/subblocks.ts b/apps/sim/triggers/clickup/subblocks.ts index c9ec57d91e1..a4b9d1700c7 100644 --- a/apps/sim/triggers/clickup/subblocks.ts +++ b/apps/sim/triggers/clickup/subblocks.ts @@ -2,17 +2,15 @@ import { createLogger } from '@sim/logger' import { requestJson } from '@/lib/api/client/request' import { clickupWorkspacesSelectorContract } from '@/lib/api/contracts/selectors/clickup' import type { SubBlockConfig } from '@/blocks/types' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { clickupSetupInstructions } from '@/triggers/clickup/utils' +import { readSubBlockValue } from '@/triggers/editor-state' const logger = createLogger('ClickUpTriggerSubBlocks') async function fetchWorkspaceOptions( blockId: string ): Promise> { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as - | string - | null + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as string | null if (!credentialId) { throw new Error('No ClickUp credential selected') } diff --git a/apps/sim/triggers/editor-state.ts b/apps/sim/triggers/editor-state.ts new file mode 100644 index 00000000000..0480039ec72 --- /dev/null +++ b/apps/sim/triggers/editor-state.ts @@ -0,0 +1,61 @@ +/** + * Editor-state readers for trigger sub-block option resolvers. + * + * Trigger definitions are a definition layer: `@/triggers` must not reach `@/blocks` + * through a static import, because block configs spread `getTrigger(...).subBlocks` at + * module scope. A static edge makes the two barrels mutually recursive, and whichever + * one an entry point reaches first wins — enter through `@/triggers` and `getTrigger()` + * runs before `TRIGGER_REGISTRY` is initialized, throwing + * `ReferenceError: Cannot access 'TRIGGER_REGISTRY' before initialization`. + * + * The Zustand stores below sit on the far side of that edge (`subblock/store` imports + * `@/blocks`), so they are loaded with a dynamic `import()`. Dynamic imports resolve at + * call time rather than during module evaluation, so they carry no initialization-order + * obligation. Every caller is an editor-side `fetchOptions`/`fetchOptionById` resolver + * that already runs asynchronously, long after both registries are built. + * + * `scripts/check-trigger-block-cycle.ts` fails the build if a static edge reappears. + */ + +/** The value the user has entered for `subBlockId` on `blockId` in the open workflow. */ +export async function readSubBlockValue(blockId: string, subBlockId: string): Promise { + const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') + return useSubBlockStore.getState().getValue(blockId, subBlockId) +} + +/** + * Every stored sub-block value for `blockId`, for resolvers that read several fields at + * once. Returns `undefined` when the block has no stored values yet. + */ +export async function readBlockValues( + blockId: string +): Promise | undefined> { + const [{ useSubBlockStore }, { useWorkflowRegistry }] = await Promise.all([ + import('@/stores/workflows/subblock/store'), + import('@/stores/workflows/registry/store'), + ]) + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + if (!activeWorkflowId) return undefined + return useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] +} + +/** The active workspace's workflows, for trigger sub-blocks that select other workflows. */ +export async function readWorkspaceWorkflowOptions(options?: { + excludeActiveWorkflow?: boolean +}): Promise> { + const { fetchWorkspaceWorkflowOptions } = await import('@/lib/workflows/subblocks/options') + return fetchWorkspaceWorkflowOptions(options) +} + +/** The workflow and workspace the editor currently has open. */ +export async function readActiveWorkflowContext(): Promise<{ + activeWorkflowId: string | null + workspaceId: string | null +}> { + const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') + const state = useWorkflowRegistry.getState() + return { + activeWorkflowId: state.activeWorkflowId, + workspaceId: state.hydration.workspaceId, + } +} diff --git a/apps/sim/triggers/gmail/poller.ts b/apps/sim/triggers/gmail/poller.ts index 1d03c838f3b..40cf86ba79f 100644 --- a/apps/sim/triggers/gmail/poller.ts +++ b/apps/sim/triggers/gmail/poller.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { GmailIcon } from '@/components/icons' import { requestJson } from '@/lib/api/client/request' import { gmailLabelsSelectorContract } from '@/lib/api/contracts/selectors/google' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('GmailPollingTrigger') @@ -37,7 +37,7 @@ export const gmailPollingTrigger: TriggerConfig = { required: false, options: [], // Will be populated dynamically from user's Gmail labels fetchOptions: async (blockId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null if (!credentialId) { diff --git a/apps/sim/triggers/hubspot/poller.ts b/apps/sim/triggers/hubspot/poller.ts index a05e17068c0..e6e867bcc21 100644 --- a/apps/sim/triggers/hubspot/poller.ts +++ b/apps/sim/triggers/hubspot/poller.ts @@ -8,7 +8,7 @@ import { hubspotPropertiesSelectorContract, } from '@/lib/api/contracts/selectors/hubspot' import { getScopesForService } from '@/lib/oauth/utils' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('HubSpotPollingTrigger') @@ -19,11 +19,9 @@ const logger = createLogger('HubSpotPollingTrigger') * default ('contact') — otherwise the cascading property selectors render empty on * first render even when the dropdown visibly shows "contact". */ -function resolveSelectedObjectType(blockId: string): string | null { - const objectType = useSubBlockStore.getState().getValue(blockId, 'objectType') as string | null - const customId = useSubBlockStore.getState().getValue(blockId, 'customObjectTypeId') as - | string - | null +async function resolveSelectedObjectType(blockId: string): Promise { + const objectType = (await readSubBlockValue(blockId, 'objectType')) as string | null + const customId = (await readSubBlockValue(blockId, 'customObjectTypeId')) as string | null const selected = objectType ?? 'contact' if (selected === 'custom') { const trimmed = customId?.trim() @@ -33,9 +31,7 @@ function resolveSelectedObjectType(blockId: string): string | null { } async function fetchHubSpotProperties(blockId: string, objectType: string) { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as - | string - | null + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as string | null if (!credentialId) throw new Error('No HubSpot credential selected') const data = await requestJson(hubspotPropertiesSelectorContract, { query: { credentialId, objectType }, @@ -101,7 +97,7 @@ export const hubspotPollingTrigger: TriggerConfig = { placeholder: 'Select a list', options: [], fetchOptions: async (blockId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null if (!credentialId) throw new Error('No HubSpot credential selected') @@ -144,7 +140,7 @@ export const hubspotPollingTrigger: TriggerConfig = { placeholder: 'Select a property', options: [], fetchOptions: async (blockId: string) => { - const resolved = resolveSelectedObjectType(blockId) + const resolved = await resolveSelectedObjectType(blockId) if (!resolved) throw new Error('Select an object type first') try { return await fetchHubSpotProperties(blockId, resolved) @@ -172,7 +168,7 @@ export const hubspotPollingTrigger: TriggerConfig = { placeholder: 'Select properties (optional)', options: [], fetchOptions: async (blockId: string) => { - const resolved = resolveSelectedObjectType(blockId) + const resolved = await resolveSelectedObjectType(blockId) if (!resolved) return [] try { return await fetchHubSpotProperties(blockId, resolved) @@ -194,10 +190,10 @@ export const hubspotPollingTrigger: TriggerConfig = { placeholder: 'All pipelines', options: [], fetchOptions: async (blockId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null - const objectType = resolveSelectedObjectType(blockId) ?? 'contact' + const objectType = (await resolveSelectedObjectType(blockId)) ?? 'contact' if (!credentialId) throw new Error('No HubSpot credential selected') try { const data = await requestJson(hubspotPipelinesSelectorContract, { @@ -222,13 +218,11 @@ export const hubspotPollingTrigger: TriggerConfig = { placeholder: 'All stages', options: [], fetchOptions: async (blockId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as - | string - | null - const objectType = resolveSelectedObjectType(blockId) ?? 'contact' - const pipelineId = useSubBlockStore.getState().getValue(blockId, 'pipelineId') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null + const objectType = (await resolveSelectedObjectType(blockId)) ?? 'contact' + const pipelineId = (await readSubBlockValue(blockId, 'pipelineId')) as string | null if (!credentialId) throw new Error('No HubSpot credential selected') if (!pipelineId) return [] try { @@ -255,7 +249,7 @@ export const hubspotPollingTrigger: TriggerConfig = { placeholder: 'Any owner', options: [], fetchOptions: async (blockId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null if (!credentialId) throw new Error('No HubSpot credential selected') diff --git a/apps/sim/triggers/imap/poller.ts b/apps/sim/triggers/imap/poller.ts index 4da7014c239..5d791a1c323 100644 --- a/apps/sim/triggers/imap/poller.ts +++ b/apps/sim/triggers/imap/poller.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { MailServerIcon } from '@/components/icons' import { requestJson } from '@/lib/api/client/request' import { imapMailboxesContract } from '@/lib/api/contracts/tools/imap' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('ImapPollingTrigger') @@ -78,12 +78,13 @@ export const imapPollingTrigger: TriggerConfig = { required: false, options: [], fetchOptions: async (blockId: string) => { - const store = useSubBlockStore.getState() - const host = store.getValue(blockId, 'host') as string | null - const port = store.getValue(blockId, 'port') as string | null - const secure = store.getValue(blockId, 'secure') as boolean | null - const username = store.getValue(blockId, 'username') as string | null - const password = store.getValue(blockId, 'password') as string | null + const [host, port, secure, username, password] = await Promise.all([ + readSubBlockValue(blockId, 'host') as Promise, + readSubBlockValue(blockId, 'port') as Promise, + readSubBlockValue(blockId, 'secure') as Promise, + readSubBlockValue(blockId, 'username') as Promise, + readSubBlockValue(blockId, 'password') as Promise, + ]) if (!host || !username || !password) { throw new Error('Please enter IMAP server, username, and password first') diff --git a/apps/sim/triggers/index.ts b/apps/sim/triggers/index.ts index 3c540791ee3..2a2fccd86eb 100644 --- a/apps/sim/triggers/index.ts +++ b/apps/sim/triggers/index.ts @@ -1,4 +1,4 @@ -import { generateMockPayloadFromOutputsDefinition } from '@/lib/workflows/triggers/trigger-utils' +import { generateMockPayloadFromOutputsDefinition } from '@/lib/workflows/triggers/mock-payload' import type { SubBlockConfig } from '@/blocks/types' import { TRIGGER_REGISTRY } from '@/triggers/registry' import type { TriggerConfig } from '@/triggers/types' diff --git a/apps/sim/triggers/outlook/poller.ts b/apps/sim/triggers/outlook/poller.ts index 5496ac2f46f..cbec5cbc2ff 100644 --- a/apps/sim/triggers/outlook/poller.ts +++ b/apps/sim/triggers/outlook/poller.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { OutlookIcon } from '@/components/icons' import { requestJson } from '@/lib/api/client/request' import { outlookFoldersSelectorContract } from '@/lib/api/contracts/selectors/microsoft' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('OutlookPollingTrigger') @@ -37,7 +37,7 @@ export const outlookPollingTrigger: TriggerConfig = { required: false, options: [], // Will be populated dynamically fetchOptions: async (blockId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null if (!credentialId) { diff --git a/apps/sim/triggers/sim/workspace-event.ts b/apps/sim/triggers/sim/workspace-event.ts index 27028aa2da6..a2f885b2f02 100644 --- a/apps/sim/triggers/sim/workspace-event.ts +++ b/apps/sim/triggers/sim/workspace-event.ts @@ -1,11 +1,11 @@ import { SimTriggerIcon } from '@/components/icons' -import { fetchWorkspaceWorkflowOptions } from '@/lib/workflows/subblocks/options' import { SIM_EVENT_PAYLOAD_FIELDS, SIM_RULE_DEFAULTS, SIM_TRIGGER_PROVIDER, SIM_WORKSPACE_EVENT_TRIGGER_ID, } from '@/lib/workspace-events/constants' +import { readWorkspaceWorkflowOptions } from '@/triggers/editor-state' import type { TriggerConfig } from '@/triggers/types' export const simWorkspaceEventTrigger: TriggerConfig = { @@ -51,7 +51,7 @@ export const simWorkspaceEventTrigger: TriggerConfig = { required: false, mode: 'trigger', // A subscriber never receives events about itself, so exclude it. - fetchOptions: () => fetchWorkspaceWorkflowOptions({ excludeActiveWorkflow: true }), + fetchOptions: () => readWorkspaceWorkflowOptions({ excludeActiveWorkflow: true }), }, { id: 'consecutiveFailures', diff --git a/apps/sim/triggers/table/poller.ts b/apps/sim/triggers/table/poller.ts index e922c0502ec..8a35e886426 100644 --- a/apps/sim/triggers/table/poller.ts +++ b/apps/sim/triggers/table/poller.ts @@ -4,16 +4,14 @@ import { listTablesContract } from '@/lib/api/contracts/tables' import type { TableDefinition } from '@/lib/table' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { tableKeys } from '@/hooks/queries/utils/table-keys' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { readActiveWorkflowContext, readBlockValues } from '@/triggers/editor-state' import type { TriggerConfig } from '@/triggers/types' async function fetchTableColumns(blockId: string): Promise> { - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + const { activeWorkflowId, workspaceId } = await readActiveWorkflowContext() if (!activeWorkflowId || !workspaceId) return [] - const blockValues = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] + const blockValues = await readBlockValues(blockId) const tableId = (blockValues?.tableSelector as string) || (blockValues?.manualTableId as string) if (!tableId) return [] diff --git a/apps/sim/triggers/webflow/collection_item_changed.ts b/apps/sim/triggers/webflow/collection_item_changed.ts index df4659a2ad6..1702e272f16 100644 --- a/apps/sim/triggers/webflow/collection_item_changed.ts +++ b/apps/sim/triggers/webflow/collection_item_changed.ts @@ -5,7 +5,7 @@ import { webflowCollectionsSelectorContract, webflowSitesSelectorContract, } from '@/lib/api/contracts/selectors/webflow' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '../types' const logger = createLogger('webflow-collection-item-changed-trigger') @@ -48,7 +48,7 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = { value: 'webflow_collection_item_changed', }, fetchOptions: async (blockId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null if (!credentialId) { @@ -68,7 +68,7 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = { } }, fetchOptionById: async (blockId: string, optionId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null if (!credentialId) return null @@ -101,12 +101,10 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = { value: 'webflow_collection_item_changed', }, fetchOptions: async (blockId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as - | string - | null - const siteId = useSubBlockStore.getState().getValue(blockId, 'triggerSiteId') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null + const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null if (!credentialId || !siteId) { return [] } @@ -124,12 +122,10 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = { } }, fetchOptionById: async (blockId: string, optionId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as - | string - | null - const siteId = useSubBlockStore.getState().getValue(blockId, 'triggerSiteId') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null + const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null if (!credentialId || !siteId) return null try { const data = await requestJson(webflowCollectionsSelectorContract, { diff --git a/apps/sim/triggers/webflow/collection_item_created.ts b/apps/sim/triggers/webflow/collection_item_created.ts index 502a77e0f30..9f44df2216d 100644 --- a/apps/sim/triggers/webflow/collection_item_created.ts +++ b/apps/sim/triggers/webflow/collection_item_created.ts @@ -5,7 +5,7 @@ import { webflowCollectionsSelectorContract, webflowSitesSelectorContract, } from '@/lib/api/contracts/selectors/webflow' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '../types' const logger = createLogger('webflow-collection-item-created-trigger') @@ -62,7 +62,7 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = { value: 'webflow_collection_item_created', }, fetchOptions: async (blockId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null if (!credentialId) { @@ -82,7 +82,7 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = { } }, fetchOptionById: async (blockId: string, optionId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null if (!credentialId) return null @@ -115,12 +115,10 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = { value: 'webflow_collection_item_created', }, fetchOptions: async (blockId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as - | string - | null - const siteId = useSubBlockStore.getState().getValue(blockId, 'triggerSiteId') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null + const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null if (!credentialId || !siteId) { return [] } @@ -138,12 +136,10 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = { } }, fetchOptionById: async (blockId: string, optionId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as - | string - | null - const siteId = useSubBlockStore.getState().getValue(blockId, 'triggerSiteId') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null + const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null if (!credentialId || !siteId) return null try { const data = await requestJson(webflowCollectionsSelectorContract, { diff --git a/apps/sim/triggers/webflow/collection_item_deleted.ts b/apps/sim/triggers/webflow/collection_item_deleted.ts index 0ed7ea7f868..3d1acc00c26 100644 --- a/apps/sim/triggers/webflow/collection_item_deleted.ts +++ b/apps/sim/triggers/webflow/collection_item_deleted.ts @@ -5,7 +5,7 @@ import { webflowCollectionsSelectorContract, webflowSitesSelectorContract, } from '@/lib/api/contracts/selectors/webflow' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '../types' const logger = createLogger('webflow-collection-item-deleted-trigger') @@ -48,7 +48,7 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = { value: 'webflow_collection_item_deleted', }, fetchOptions: async (blockId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null if (!credentialId) { @@ -68,7 +68,7 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = { } }, fetchOptionById: async (blockId: string, optionId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null if (!credentialId) return null @@ -101,12 +101,10 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = { value: 'webflow_collection_item_deleted', }, fetchOptions: async (blockId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as - | string - | null - const siteId = useSubBlockStore.getState().getValue(blockId, 'triggerSiteId') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null + const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null if (!credentialId || !siteId) { return [] } @@ -124,12 +122,10 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = { } }, fetchOptionById: async (blockId: string, optionId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as - | string - | null - const siteId = useSubBlockStore.getState().getValue(blockId, 'triggerSiteId') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null + const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null if (!credentialId || !siteId) return null try { const data = await requestJson(webflowCollectionsSelectorContract, { diff --git a/apps/sim/triggers/webflow/form_submission.ts b/apps/sim/triggers/webflow/form_submission.ts index aee7cbb57d1..286ad9f5738 100644 --- a/apps/sim/triggers/webflow/form_submission.ts +++ b/apps/sim/triggers/webflow/form_submission.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { WebflowIcon } from '@/components/icons' import { requestJson } from '@/lib/api/client/request' import { webflowSitesSelectorContract } from '@/lib/api/contracts/selectors/webflow' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '../types' const logger = createLogger('webflow-form-submission-trigger') @@ -45,7 +45,7 @@ export const webflowFormSubmissionTrigger: TriggerConfig = { value: 'webflow_form_submission', }, fetchOptions: async (blockId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null if (!credentialId) { @@ -65,7 +65,7 @@ export const webflowFormSubmissionTrigger: TriggerConfig = { } }, fetchOptionById: async (blockId: string, optionId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as | string | null if (!credentialId) return null diff --git a/package.json b/package.json index b69411907e0..2b2645e4134 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts", "check:tool-request-boundary": "bun run scripts/check-tool-request-boundary.ts", "check:tool-registry-boundary": "bun run scripts/check-tool-registry-boundary.ts", + "check:trigger-block-cycle": "bun run scripts/check-trigger-block-cycle.ts", "check:sql-date-binding": "bun run scripts/check-sql-date-binding.ts", "check:zustand-v5": "bun run scripts/check-zustand-v5-selectors.ts", "check:react-query": "bun run scripts/check-react-query-patterns.ts --check", diff --git a/scripts/check-trigger-block-cycle.ts b/scripts/check-trigger-block-cycle.ts new file mode 100644 index 00000000000..e9e35d4552d --- /dev/null +++ b/scripts/check-trigger-block-cycle.ts @@ -0,0 +1,149 @@ +#!/usr/bin/env bun +/** + * Fails if `@/triggers` can statically reach `@/blocks`. + * + * Block configs spread `getTrigger('…').subBlocks` while their module body runs, so + * `blocks/*` legitimately depends on `triggers/*`. The reverse edge closes the loop, and + * then whichever barrel an entry point reaches first decides whether the process starts: + * enter through `@/triggers` and a block config calls `getTrigger()` before + * `TRIGGER_REGISTRY` is initialized, throwing + * `ReferenceError: Cannot access 'TRIGGER_REGISTRY' before initialization`. + * + * This regressed silently once already. `deploy.ts` imported a value from `@/blocks`, + * which biome sorts above `@/triggers`, so the safe barrel always evaluated first. #6272 + * deleted that import as unused cleanup and took all eleven deployment routes with it — + * a one-line deletion, forty lines from the import it was protecting, in a file whose + * tests mock both barrels and therefore could not fail. + * + * Only STATIC edges are walked. A dynamic `import()` resolves when it is called rather + * than during module evaluation, so it carries no initialization-order obligation — that + * is precisely how `triggers/editor-state.ts` reads the editor's Zustand stores. + * + * Usage: + * bun run scripts/check-trigger-block-cycle.ts + * bun run scripts/check-trigger-block-cycle.ts --verbose # print graph size + */ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const APP = join(ROOT, 'apps/sim') + +/** Entry points that must never reach `blocks/`. Both are barrels an app module may import first. */ +const ENTRIES = ['triggers/index.ts', 'triggers/registry.ts'] + +/** Directory the entries must not reach. */ +const FORBIDDEN_DIR = join(APP, 'blocks') + +const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs'] + +/** + * Static value imports and re-exports only. `import type` / `export type` are erased at + * compile time, so a type-only edge costs nothing at runtime and cannot affect ordering. + */ +const IMPORT_RE = /(?:^|\n)\s*import\s+(?!type\b)(?:[\s\S]*?from\s*)?['"]([^'"]+)['"]/g +const REEXPORT_RE = + /(?:^|\n)\s*export\s+(?!type\b)(?:\*(?:\s+as\s+[\w$]+)?|\{[\s\S]*?\})\s*from\s*['"]([^'"]+)['"]/g + +/** Resolves `@/` and relative specifiers. Bare package specifiers are ignored. */ +function resolveSpecifier(specifier: string, importer: string): string | null { + let base: string + if (specifier.startsWith('@/')) base = join(APP, specifier.slice(2)) + else if (specifier.startsWith('.')) base = resolve(dirname(importer), specifier) + else return null + + if (existsSync(base) && statSync(base).isFile()) return base + for (const ext of EXTENSIONS) { + if (existsSync(base + ext)) return base + ext + } + if (existsSync(base) && statSync(base).isDirectory()) { + for (const ext of EXTENSIONS) { + const indexPath = join(base, `index${ext}`) + if (existsSync(indexPath)) return indexPath + } + } + return null +} + +/** + * Breadth-first so the reported chain is the shortest one. A depth-first walk reports + * whichever path it wandered down, which can be dozens of hops long and unreadable. + */ +function findPathToBlocks(entry: string): { path: string[]; visited: number } { + const importedBy = new Map([[entry, null]]) + const queue: string[] = [entry] + + while (queue.length > 0) { + const file = queue.shift() as string + if (file.startsWith(`${FORBIDDEN_DIR}/`) || file === `${FORBIDDEN_DIR}.ts`) { + const chain: string[] = [] + let cursor: string | null = file + while (cursor) { + chain.unshift(relative(APP, cursor)) + cursor = importedBy.get(cursor) ?? null + } + return { path: chain, visited: importedBy.size } + } + + let source: string + try { + source = readFileSync(file, 'utf8') + } catch { + continue + } + + for (const pattern of [IMPORT_RE, REEXPORT_RE]) { + pattern.lastIndex = 0 + let match = pattern.exec(source) + while (match !== null) { + const resolved = resolveSpecifier(match[1], file) + if (resolved && !importedBy.has(resolved)) { + importedBy.set(resolved, file) + queue.push(resolved) + } + match = pattern.exec(source) + } + } + } + + return { path: [], visited: importedBy.size } +} + +const verbose = process.argv.includes('--verbose') +let failed = false + +for (const entry of ENTRIES) { + const entryPath = join(APP, entry) + if (!existsSync(entryPath)) { + console.error(`✗ check-trigger-block-cycle: entry not found: ${entry}`) + failed = true + continue + } + + const { path, visited } = findPathToBlocks(entryPath) + if (path.length > 0) { + failed = true + console.error(`\n✗ ${entry} can statically reach blocks/:\n`) + console.error(` ${path.join('\n -> ')}\n`) + } else if (verbose) { + console.log(`✓ ${entry} — ${visited} modules reachable, none under blocks/`) + } +} + +if (failed) { + console.error( + 'The triggers <-> blocks import cycle is back. Block configs call getTrigger() at module\n' + + 'scope, so a static triggers -> blocks edge makes module evaluation order load-bearing:\n' + + 'importing @/triggers before @/blocks throws\n' + + " ReferenceError: Cannot access 'TRIGGER_REGISTRY' before initialization\n\n" + + 'Do not fix this by reordering imports at the call site — that guard is invisible to the\n' + + 'test suite and one unused-import cleanup away from breaking again. Either keep the\n' + + 'dependency out of the triggers/ tree, or load it with a dynamic import() from\n' + + 'apps/sim/triggers/editor-state.ts the way the editor-state readers do.\n' + ) + process.exit(1) +} + +console.log('✓ check-trigger-block-cycle: triggers/ has no static path into blocks/')