@@ -57,14 +57,29 @@ interface HubSpotWebhookConfig {
5757 * workflow doesn't see a flood of historical members on activation.
5858 */
5959 membershipSeedComplete ?: boolean
60- /** Snapshot of the watched property's last-seen value per record (property_changed event). */
60+ /**
61+ * Snapshot of the watched property's last-seen value per record (property_changed event).
62+ * Persisted as an entries array (not a `Record`) because HubSpot record ids are numeric
63+ * strings, and JS engines enumerate integer-indexed object keys in numeric order
64+ * regardless of insertion — which would break LRU-style trimming. Array order is stable.
65+ */
6166 propertySnapshot ?: {
6267 property : string
63- values : Record < string , string | null >
68+ /** `[recordId, value]` pairs in LRU order — oldest first. */
69+ entries : Array < [ string , string | null ] >
6470 }
6571 lastCheckedTimestamp ?: string
6672}
6773
74+ /**
75+ * In-memory snapshot state used during a single poll. Uses a Map (not a plain object) so
76+ * insertion-order iteration is honored even for numeric-string keys.
77+ */
78+ interface PropertySnapshotState {
79+ property : string
80+ values : Map < string , string | null >
81+ }
82+
6883interface HubSpotSearchResult {
6984 id : string
7085 properties : Record < string , string | null >
@@ -227,7 +242,7 @@ async function pollSearchBased(
227242 ? {
228243 propertySnapshot : {
229244 property : config . targetPropertyName ?. trim ( ) ?? '' ,
230- values : { } ,
245+ entries : [ ] ,
231246 } ,
232247 }
233248 : { } ) ,
@@ -248,13 +263,13 @@ async function pollSearchBased(
248263 )
249264 }
250265
251- const properties = resolveRequestedProperties ( config , objectType , filterProperty )
266+ const userFilters = buildUserFilters ( config , logger , requestId )
267+ const properties = resolveRequestedProperties ( config , objectType , filterProperty , userFilters )
252268 const maxRecords = Math . min (
253269 Math . max ( config . maxRecordsPerPoll ?? DEFAULT_MAX_RECORDS , 1 ) ,
254270 MAX_MAX_RECORDS
255271 )
256272 const lastSeenObjectId = config . lastSeenObjectId
257- const userFilters = buildUserFilters ( config , logger , requestId )
258273
259274 const records = await fetchHubSpotChanges ( {
260275 accessToken,
@@ -313,7 +328,7 @@ async function pollSearchBased(
313328 lastCheckedTimestamp : new Date ( nowMs ) . toISOString ( ) ,
314329 }
315330 if ( snapshotForRun ) {
316- update . propertySnapshot = trimSnapshot ( snapshotForRun )
331+ update . propertySnapshot = serializeSnapshot ( snapshotForRun )
317332 }
318333 await updateWebhookProviderConfig ( webhookId , update , logger )
319334
@@ -481,7 +496,8 @@ function resolveObjectType(config: HubSpotWebhookConfig): string {
481496function resolveRequestedProperties (
482497 config : HubSpotWebhookConfig ,
483498 objectType : string ,
484- filterProperty : string
499+ filterProperty : string ,
500+ userFilters : FilterClause [ ]
485501) : string [ ] {
486502 const requested = new Set < string > ( )
487503
@@ -507,7 +523,7 @@ function resolveRequestedProperties(
507523 if ( config . targetPropertyName ?. trim ( ) ) {
508524 requested . add ( config . targetPropertyName . trim ( ) )
509525 }
510- for ( const f of buildUserFilters ( config ) ) {
526+ for ( const f of userFilters ) {
511527 if ( f . propertyName ) requested . add ( f . propertyName )
512528 }
513529
@@ -567,26 +583,25 @@ function buildUserFilters(
567583function resolvePropertySnapshot (
568584 config : HubSpotWebhookConfig ,
569585 property : string
570- ) : { property : string ; values : Record < string , string | null > } {
586+ ) : PropertySnapshotState {
571587 const existing = config . propertySnapshot
572- if ( existing && existing . property === property ) {
573- return { property, values : { ... existing . values } }
588+ if ( existing && existing . property === property && Array . isArray ( existing . entries ) ) {
589+ return { property, values : new Map ( existing . entries ) }
574590 }
575591 // Property changed since last config — start fresh so we don't compare against stale values.
576- return { property, values : { } }
592+ return { property, values : new Map ( ) }
577593}
578594
579- function trimSnapshot ( snapshot : { property : string ; values : Record < string , string | null > } ) : {
595+ function serializeSnapshot ( state : PropertySnapshotState ) : {
580596 property : string
581- values : Record < string , string | null >
597+ entries : Array < [ string , string | null ] >
582598} {
583- const keys = Object . keys ( snapshot . values )
584- if ( keys . length <= MAX_SNAPSHOT_SIZE ) return snapshot
585- // Drop oldest by insertion order (JS string-key iteration is insertion-ordered).
586- const keep = keys . slice ( keys . length - MAX_SNAPSHOT_SIZE )
587- const trimmed : Record < string , string | null > = { }
588- for ( const k of keep ) trimmed [ k ] = snapshot . values [ k ]
589- return { property : snapshot . property , values : trimmed }
599+ let entries = [ ...state . values . entries ( ) ]
600+ // Drop oldest by insertion order; Map preserves it regardless of key type.
601+ if ( entries . length > MAX_SNAPSHOT_SIZE ) {
602+ entries = entries . slice ( entries . length - MAX_SNAPSHOT_SIZE )
603+ }
604+ return { property : state . property , entries }
590605}
591606
592607interface FetchArgs {
@@ -721,7 +736,7 @@ async function processRecords(
721736 eventType : HubSpotEventType ,
722737 filterProperty : string ,
723738 targetProperty : string | undefined ,
724- snapshot : { property : string ; values : Record < string , string | null > } | null ,
739+ snapshot : PropertySnapshotState | null ,
725740 requestId : string ,
726741 logger : Logger
727742) : Promise < {
@@ -749,12 +764,13 @@ async function processRecords(
749764 let handledBySkip = false
750765 if ( eventType === 'property_changed' && targetProperty && snapshot ) {
751766 propertyValue = record . properties ?. [ targetProperty ] ?? null
752- const had = Object . hasOwn ( snapshot . values , record . id )
753- previousValue = had ? snapshot . values [ record . id ] : undefined
767+ const had = snapshot . values . has ( record . id )
768+ previousValue = had ? snapshot . values . get ( record . id ) : undefined
754769 if ( had && ( previousValue ?? null ) === ( propertyValue ?? null ) ) {
755- // Touch the snapshot to keep this record's entry from being trimmed before unchanged ones.
756- delete snapshot . values [ record . id ]
757- snapshot . values [ record . id ] = propertyValue ?? null
770+ // Touch the snapshot so this record's entry moves to the end of the LRU order.
771+ // Map.delete + Map.set re-inserts at the tail, regardless of key type.
772+ snapshot . values . delete ( record . id )
773+ snapshot . values . set ( record . id , propertyValue ?? null )
758774 skippedCount ++
759775 handledBySkip = true
760776 }
@@ -807,7 +823,9 @@ async function processRecords(
807823 processedCount ++
808824 handledSuccessfully = true
809825 if ( eventType === 'property_changed' && targetProperty && snapshot ) {
810- snapshot . values [ record . id ] = propertyValue ?? null
826+ // Same delete+set dance so an updated value moves to the tail of the LRU order.
827+ snapshot . values . delete ( record . id )
828+ snapshot . values . set ( record . id , propertyValue ?? null )
811829 }
812830 } catch ( error ) {
813831 failedCount ++
0 commit comments