Skip to content

Commit 8bbca9b

Browse files
authored
fix(trigger): fix polling trigger config defaults, row count, clock-skew, and stale config clearing (#4101)
* fix(trigger): fix polling trigger config defaults, row count, clock-skew, and stale config clearing * fix(deploy): track first-pass fills to prevent stale baseConfig bypassing required-field validation Use a dedicated `filledSubBlockIds` Set populated during the first pass so the second-pass skip guard is based solely on live `getConfigValue` results, not on stale entries spread from `baseConfig` (`triggerConfig`). * fix(trigger): prevent calendar cursor regression when all events are filtered client-side
1 parent 34f77e0 commit 8bbca9b

File tree

7 files changed

+81
-56
lines changed

7 files changed

+81
-56
lines changed

apps/sim/hooks/use-trigger-config-aggregation.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ export function useTriggerConfigAggregation(
6666
let valueToUse = fieldValue
6767
if (
6868
(fieldValue === null || fieldValue === undefined || fieldValue === '') &&
69-
subBlock.required &&
7069
subBlock.defaultValue !== undefined
7170
) {
7271
valueToUse = subBlock.defaultValue

apps/sim/lib/webhooks/deploy.ts

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '@/lib/webhooks/provider-subscriptions'
1414
import { getProviderHandler } from '@/lib/webhooks/providers'
1515
import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
16+
import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility'
1617
import { getBlock } from '@/blocks'
1718
import type { SubBlockConfig } from '@/blocks/types'
1819
import type { BlockState } from '@/stores/workflows/workflow/types'
@@ -150,7 +151,6 @@ function getConfigValue(block: BlockState, subBlock: SubBlockConfig): unknown {
150151

151152
if (
152153
(fieldValue === null || fieldValue === undefined || fieldValue === '') &&
153-
Boolean(subBlock.required) &&
154154
subBlock.defaultValue !== undefined
155155
) {
156156
return subBlock.defaultValue
@@ -182,20 +182,40 @@ function buildProviderConfig(
182182
Object.entries(block.subBlocks || {}).map(([key, value]) => [key, { value: value.value }])
183183
)
184184

185-
triggerDef.subBlocks
186-
.filter(
187-
(subBlock) =>
188-
(subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') &&
189-
!SYSTEM_SUBBLOCK_IDS.includes(subBlock.id)
190-
)
191-
.forEach((subBlock) => {
192-
const valueToUse = getConfigValue(block, subBlock)
193-
if (valueToUse !== null && valueToUse !== undefined && valueToUse !== '') {
194-
providerConfig[subBlock.id] = valueToUse
195-
} else if (isFieldRequired(subBlock, subBlockValues)) {
196-
missingFields.push(subBlock.title || subBlock.id)
197-
}
198-
})
185+
const canonicalIndex = buildCanonicalIndex(triggerDef.subBlocks)
186+
const satisfiedCanonicalIds = new Set<string>()
187+
const filledSubBlockIds = new Set<string>()
188+
189+
const relevantSubBlocks = triggerDef.subBlocks.filter(
190+
(subBlock) =>
191+
(subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') &&
192+
!SYSTEM_SUBBLOCK_IDS.includes(subBlock.id)
193+
)
194+
195+
// First pass: populate providerConfig, clear stale baseConfig entries, and track which
196+
// subblocks and canonical groups have a value.
197+
for (const subBlock of relevantSubBlocks) {
198+
const valueToUse = getConfigValue(block, subBlock)
199+
if (valueToUse !== null && valueToUse !== undefined && valueToUse !== '') {
200+
providerConfig[subBlock.id] = valueToUse
201+
filledSubBlockIds.add(subBlock.id)
202+
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlock.id]
203+
if (canonicalId) satisfiedCanonicalIds.add(canonicalId)
204+
} else {
205+
delete providerConfig[subBlock.id]
206+
}
207+
}
208+
209+
// Second pass: validate required fields. Skip subblocks that are filled or whose canonical
210+
// group is satisfied by another member.
211+
for (const subBlock of relevantSubBlocks) {
212+
if (filledSubBlockIds.has(subBlock.id)) continue
213+
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlock.id]
214+
if (canonicalId && satisfiedCanonicalIds.has(canonicalId)) continue
215+
if (isFieldRequired(subBlock, subBlockValues)) {
216+
missingFields.push(subBlock.title || subBlock.id)
217+
}
218+
}
199219

200220
const credentialConfig = triggerDef.subBlocks.find(
201221
(subBlock) => subBlock.id === 'triggerCredentials'

apps/sim/lib/webhooks/polling/google-calendar.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export const googleCalendarPollingHandler: PollingProviderHandler = {
106106
if (!config.lastCheckedTimestamp) {
107107
await updateWebhookProviderConfig(
108108
webhookId,
109-
{ lastCheckedTimestamp: new Date().toISOString() },
109+
{ lastCheckedTimestamp: new Date(Date.now() - 30_000).toISOString() },
110110
logger
111111
)
112112
await markWebhookSuccess(webhookId, logger)
@@ -135,11 +135,19 @@ export const googleCalendarPollingHandler: PollingProviderHandler = {
135135
logger
136136
)
137137

138+
// Advance cursor to latestUpdated - 5s for clock-skew overlap, but never regress
139+
// below the previous cursor — this prevents an infinite re-fetch loop when all
140+
// returned events are filtered client-side and latestUpdated is within 5s of the cursor.
138141
const newTimestamp =
139142
failedCount > 0
140143
? config.lastCheckedTimestamp
141144
: latestUpdated
142-
? new Date(new Date(latestUpdated).getTime() + 1).toISOString()
145+
? new Date(
146+
Math.max(
147+
new Date(latestUpdated).getTime() - 5000,
148+
new Date(config.lastCheckedTimestamp).getTime()
149+
)
150+
).toISOString()
143151
: config.lastCheckedTimestamp
144152
await updateWebhookProviderConfig(webhookId, { lastCheckedTimestamp: newTimestamp }, logger)
145153

@@ -182,7 +190,6 @@ async function fetchChangedEvents(
182190
updatedMin: config.lastCheckedTimestamp!,
183191
singleEvents: 'true',
184192
showDeleted: 'true',
185-
orderBy: 'updated',
186193
maxResults: String(Math.min(maxEvents, 250)),
187194
})
188195

apps/sim/lib/webhooks/polling/google-drive.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,11 @@ export const googleDrivePollingHandler: PollingProviderHandler = {
145145
logger
146146
)
147147

148-
// Update state: new pageToken and rolling knownFileIds
148+
// Update state: new pageToken and rolling knownFileIds.
149+
// Newest IDs are placed first so that when the set exceeds MAX_KNOWN_FILE_IDS,
150+
// the oldest (least recently seen) IDs are evicted. Recent files are more
151+
// likely to be modified again, so keeping them prevents misclassifying a
152+
// repeat modification as a "created" event.
149153
const existingKnownIds = config.knownFileIds || []
150154
const mergedKnownIds = [...new Set([...newKnownFileIds, ...existingKnownIds])].slice(
151155
0,
@@ -271,9 +275,14 @@ async function fetchChanges(
271275
}
272276

273277
const slicingOccurs = allChanges.length > maxFiles
278+
// Drive API guarantees exactly one of nextPageToken or newStartPageToken per response.
279+
// Slicing case: prefer lastNextPageToken (mid-list resume); fall back to newStartPageToken
280+
// (guaranteed on final page when hasMore was false). Non-slicing case: prefer newStartPageToken
281+
// (guaranteed when loop exhausted all pages); fall back to lastNextPageToken (when loop exited
282+
// early due to MAX_PAGES with hasMore still true).
274283
const resumeToken = slicingOccurs
275-
? (lastNextPageToken ?? config.pageToken!)
276-
: (newStartPageToken ?? lastNextPageToken ?? config.pageToken!)
284+
? (lastNextPageToken ?? newStartPageToken!)
285+
: (newStartPageToken ?? lastNextPageToken!)
277286

278287
return { changes: allChanges.slice(0, maxFiles), newStartPageToken: resumeToken }
279288
}

apps/sim/lib/webhooks/polling/google-sheets.ts

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ interface GoogleSheetsWebhookConfig {
1818
manualSpreadsheetId?: string
1919
sheetName?: string
2020
manualSheetName?: string
21-
includeHeaders: boolean
2221
valueRenderOption?: ValueRenderOption
2322
dateTimeRenderOption?: DateTimeRenderOption
2423
lastKnownRowCount?: number
@@ -147,19 +146,15 @@ export const googleSheetsPollingHandler: PollingProviderHandler = {
147146
const valueRender = config.valueRenderOption || 'FORMATTED_VALUE'
148147
const dateTimeRender = config.dateTimeRenderOption || 'SERIAL_NUMBER'
149148

150-
// Fetch headers (row 1) if includeHeaders is enabled
151-
let headers: string[] = []
152-
if (config.includeHeaders !== false) {
153-
headers = await fetchHeaderRow(
154-
accessToken,
155-
spreadsheetId,
156-
sheetName,
157-
valueRender,
158-
dateTimeRender,
159-
requestId,
160-
logger
161-
)
162-
}
149+
const headers = await fetchHeaderRow(
150+
accessToken,
151+
spreadsheetId,
152+
sheetName,
153+
valueRender,
154+
dateTimeRender,
155+
requestId,
156+
logger
157+
)
163158

164159
// Fetch new rows — startRow/endRow are already 1-indexed sheet row numbers
165160
// because lastKnownRowCount includes the header row
@@ -269,7 +264,12 @@ async function getDataRowCount(
269264
logger: ReturnType<typeof import('@sim/logger').createLogger>
270265
): Promise<number> {
271266
const encodedSheet = encodeURIComponent(sheetName)
272-
const url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedSheet}!A:A?majorDimension=COLUMNS&fields=values`
267+
// Fetch all rows across columns A–Z with majorDimension=ROWS so the API
268+
// returns one entry per row that has ANY non-empty cell. Rows where column A
269+
// is empty but other columns have data are included, whereas the previous
270+
// column-A-only approach silently missed them. The returned array length
271+
// equals the 1-indexed row number of the last row with data.
272+
const url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedSheet}!A:Z?majorDimension=ROWS&fields=values`
273273

274274
const response = await fetch(url, {
275275
headers: { Authorization: `Bearer ${accessToken}` },
@@ -291,9 +291,11 @@ async function getDataRowCount(
291291
}
292292

293293
const data = await response.json()
294-
// values is [[cell1, cell2, ...]] when majorDimension=COLUMNS
295-
const columnValues = data.values?.[0] as string[] | undefined
296-
return columnValues?.length ?? 0
294+
// values is [[row1col1, row1col2, ...], [row2col1, ...], ...] when majorDimension=ROWS.
295+
// The Sheets API omits trailing empty rows, so the array length is the last
296+
// non-empty row index (1-indexed), which is exactly what we need.
297+
const rows = data.values as string[][] | undefined
298+
return rows?.length ?? 0
297299
}
298300

299301
async function fetchHeaderRow(
@@ -399,15 +401,12 @@ async function processRows(
399401
'google-sheets',
400402
`${webhookData.id}:${spreadsheetId}:${sheetName}:row${rowNumber}`,
401403
async () => {
402-
// Map row values to headers
403404
let mappedRow: Record<string, string> | null = null
404-
if (headers.length > 0 && config.includeHeaders !== false) {
405+
if (headers.length > 0) {
405406
mappedRow = {}
406407
for (let j = 0; j < headers.length; j++) {
407-
const header = headers[j] || `Column ${j + 1}`
408-
mappedRow[header] = row[j] ?? ''
408+
mappedRow[headers[j] || `Column ${j + 1}`] = row[j] ?? ''
409409
}
410-
// Include any extra columns beyond headers
411410
for (let j = headers.length; j < row.length; j++) {
412411
mappedRow[`Column ${j + 1}`] = row[j] ?? ''
413412
}

apps/sim/triggers/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const SYSTEM_SUBBLOCK_IDS: string[] = [
1111
'samplePayload', // Example payload display
1212
'setupScript', // Setup script code (e.g., Apps Script)
1313
'scheduleInfo', // Schedule status display (next run, last run)
14+
'triggerSave', // UI-only save button — stores no config data
1415
]
1516

1617
/**

apps/sim/triggers/google-sheets/poller.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,6 @@ export const googleSheetsPollingTrigger: TriggerConfig = {
6969
mode: 'trigger-advanced',
7070
canonicalParamId: 'sheetName',
7171
},
72-
{
73-
id: 'includeHeaders',
74-
title: 'Map Row Values to Headers',
75-
type: 'switch',
76-
defaultValue: true,
77-
description:
78-
'When enabled, each row is returned as a key-value object mapped to column headers from row 1.',
79-
required: false,
80-
mode: 'trigger',
81-
},
8272
{
8373
id: 'valueRenderOption',
8474
title: 'Value Render',
@@ -139,7 +129,7 @@ export const googleSheetsPollingTrigger: TriggerConfig = {
139129
outputs: {
140130
row: {
141131
type: 'json',
142-
description: 'Row data mapped to column headers (when header mapping is enabled)',
132+
description: 'Row data mapped to column headers from row 1',
143133
},
144134
rawRow: {
145135
type: 'json',

0 commit comments

Comments
 (0)