@@ -73,6 +73,9 @@ export type SnapshotProjectionConfig<
7373 * Constructs a deterministic stream_id from the keys.
7474 * The stream_id is created by sorting the keys and concatenating them with a delimiter.
7575 * This ensures the same keys always produce the same stream_id.
76+ *
77+ * URL encoding is used to handle special characters (like `|` and `:`) in key names or values
78+ * that could otherwise cause collisions or parsing issues when used as delimiters.
7679 */
7780function constructStreamId ( keys : Record < string , string > ) : string {
7881 const sortedEntries = Object . entries ( keys ) . sort ( ( [ a ] , [ b ] ) =>
@@ -87,6 +90,94 @@ function constructStreamId(keys: Record<string, string>): string {
8790 . join ( "|" ) ;
8891}
8992
93+ /**
94+ * Validates and caches primary keys from extractKeys.
95+ * Ensures that extractKeys returns a consistent set of keys across all events.
96+ */
97+ function validateAndCachePrimaryKeys (
98+ keys : Record < string , string > ,
99+ tableName : string ,
100+ cachedKeys : string [ ] | undefined ,
101+ ) : string [ ] {
102+ const currentKeys = Object . keys ( keys ) ;
103+ const sortedCurrentKeys = [ ...currentKeys ] . sort ( ) ;
104+
105+ if ( ! cachedKeys ) {
106+ // Cache the initially inferred primary keys in a deterministic order
107+ return sortedCurrentKeys ;
108+ }
109+
110+ // Validate that subsequent calls to extractKeys return the same key set
111+ if (
112+ cachedKeys . length !== sortedCurrentKeys . length ||
113+ ! cachedKeys . every ( ( key , index ) => key === sortedCurrentKeys [ index ] )
114+ ) {
115+ throw new Error (
116+ `Snapshot projection "${ tableName } " received inconsistent primary keys from extractKeys. ` +
117+ `Expected keys: ${ cachedKeys . join ( ", " ) } , ` +
118+ `but received: ${ sortedCurrentKeys . join ( ", " ) } . ` +
119+ `Ensure extractKeys returns a consistent set of keys for all events.` ,
120+ ) ;
121+ }
122+
123+ return cachedKeys ;
124+ }
125+
126+ /**
127+ * Checks if the event should be processed based on the last processed position.
128+ * Returns true if the event should be skipped (already processed or older).
129+ * Uses -1n as the default to indicate no previous position (process from beginning).
130+ */
131+ function shouldSkipEvent (
132+ eventPosition : bigint ,
133+ lastProcessedPosition : bigint ,
134+ ) : boolean {
135+ return eventPosition <= lastProcessedPosition ;
136+ }
137+
138+ /**
139+ * Loads the current state from a snapshot, handling both string and parsed JSON formats.
140+ * Falls back to initial state if no snapshot exists.
141+ */
142+ function loadStateFromSnapshot < TState > (
143+ snapshot : unknown ,
144+ initialState : ( ) => TState ,
145+ ) : TState {
146+ if ( ! snapshot ) {
147+ return initialState ( ) ;
148+ }
149+
150+ // Some database drivers return JSONB as strings, others as parsed objects
151+ if ( typeof snapshot === "string" ) {
152+ return JSON . parse ( snapshot ) as TState ;
153+ }
154+
155+ return snapshot as unknown as TState ;
156+ }
157+
158+ /**
159+ * Builds the update set for denormalized columns from mapToColumns.
160+ * Returns an empty object if mapToColumns is not provided.
161+ */
162+ function buildDenormalizedUpdateSet < TState > (
163+ newState : TState ,
164+ mapToColumns ?: ( state : TState ) => Record < string , unknown > ,
165+ ) : Record < string , ( eb : ExpressionBuilder < DatabaseExecutor , any > ) => unknown > {
166+ const updateSet : Record <
167+ string ,
168+ ( eb : ExpressionBuilder < DatabaseExecutor , any > ) => unknown
169+ > = { } ;
170+
171+ if ( mapToColumns ) {
172+ const columns = mapToColumns ( newState ) ;
173+ for ( const columnName of Object . keys ( columns ) ) {
174+ updateSet [ columnName ] = ( eb ) => eb . ref ( `excluded.${ columnName } ` ) ;
175+ }
176+ }
177+
178+ return updateSet ;
179+ }
180+
90181/**
91182 * Creates a projection handler that stores the aggregate state as a snapshot.
92183 *
@@ -135,11 +226,12 @@ export function createSnapshotProjection<
135226 ) => {
136227 const keys = extractKeys ( event , partition ) ;
137228
138- // Infer primary keys from extractKeys on first call
139- if ( ! inferredPrimaryKeys ) {
140- inferredPrimaryKeys = Object . keys ( keys ) ;
141- }
142-
229+ // Validate and cache primary keys
230+ inferredPrimaryKeys = validateAndCachePrimaryKeys (
231+ keys ,
232+ tableName ,
233+ inferredPrimaryKeys ,
234+ ) ;
143235 const primaryKeys = inferredPrimaryKeys ;
144236
145237 // Check if event is newer than what we've already processed
@@ -166,15 +258,15 @@ export function createSnapshotProjection<
166258 : - 1n ;
167259
168260 // Skip if we've already processed a newer event
169- if ( event . metadata . streamPosition <= lastPos ) {
261+ if ( shouldSkipEvent ( event . metadata . streamPosition , lastPos ) ) {
170262 return ;
171263 }
172264
173265 // Load current state from snapshot or use initial state
174- // Note: snapshot is stored as JSONB and Kysely returns it as parsed JSON
175- const currentState : TState = existing ?. snapshot
176- ? ( existing . snapshot as unknown as TState )
177- : initialState ( ) ;
266+ const currentState : TState = loadStateFromSnapshot (
267+ existing ?. snapshot ,
268+ initialState ,
269+ ) ;
178270
179271 // Apply the event to get new state
180272 const newState = evolve ( currentState , event ) ;
@@ -208,13 +300,12 @@ export function createSnapshotProjection<
208300 last_global_position : ( eb ) => eb . ref ( "excluded.last_global_position" ) ,
209301 } ;
210302
211- // If mapToColumns is provided, also update the denormalized columns
212- if ( mapToColumns ) {
213- const columns = mapToColumns ( newState ) ;
214- for ( const columnName of Object . keys ( columns ) ) {
215- updateSet [ columnName ] = ( eb ) => eb . ref ( `excluded.${ columnName } ` ) ;
216- }
217- }
303+ // Add denormalized columns to update set if provided
304+ const denormalizedUpdateSet = buildDenormalizedUpdateSet (
305+ newState ,
306+ mapToColumns ,
307+ ) ;
308+ Object . assign ( updateSet , denormalizedUpdateSet ) ;
218309
219310 await insertQuery
220311 // Note: `any` is used here because the conflict builder needs to work with any table schema.
@@ -298,28 +389,12 @@ export function createSnapshotProjectionWithSnapshotTable<
298389 ) => {
299390 const keys = extractKeys ( event , partition ) ;
300391
301- const currentKeys = Object . keys ( keys ) ;
302- const sortedCurrentKeys = [ ...currentKeys ] . sort ( ) ;
303-
304- // Infer and validate primary keys from extractKeys
305- if ( ! inferredPrimaryKeys ) {
306- // Cache the initially inferred primary keys in a deterministic order
307- inferredPrimaryKeys = sortedCurrentKeys ;
308- } else {
309- // Validate that subsequent calls to extractKeys return the same key set
310- if (
311- inferredPrimaryKeys . length !== sortedCurrentKeys . length ||
312- ! inferredPrimaryKeys . every ( ( key , index ) => key === sortedCurrentKeys [ index ] )
313- ) {
314- throw new Error (
315- `Snapshot projection "${ tableName } " received inconsistent primary keys from extractKeys. ` +
316- `Expected keys: ${ inferredPrimaryKeys . join ( ", " ) } , ` +
317- `but received: ${ sortedCurrentKeys . join ( ", " ) } . ` +
318- `Ensure extractKeys returns a consistent set of keys for all events.` ,
319- ) ;
320- }
321- }
322-
392+ // Validate and cache primary keys
393+ inferredPrimaryKeys = validateAndCachePrimaryKeys (
394+ keys ,
395+ tableName ,
396+ inferredPrimaryKeys ,
397+ ) ;
323398 const primaryKeys = inferredPrimaryKeys ;
324399
325400 // Construct deterministic stream_id from keys
@@ -341,16 +416,15 @@ export function createSnapshotProjectionWithSnapshotTable<
341416 : - 1n ;
342417
343418 // Skip if we've already processed a newer event
344- if ( event . metadata . streamPosition <= lastPos ) {
419+ if ( shouldSkipEvent ( event . metadata . streamPosition , lastPos ) ) {
345420 return ;
346421 }
347422
348423 // Load current state from snapshot or use initial state
349- const currentState : TState = existing ?. snapshot
350- ? ( typeof existing . snapshot === "string"
351- ? ( JSON . parse ( existing . snapshot ) as TState )
352- : ( existing . snapshot as unknown as TState ) )
353- : initialState ( ) ;
424+ const currentState : TState = loadStateFromSnapshot (
425+ existing ?. snapshot ,
426+ initialState ,
427+ ) ;
354428
355429 // Apply the event to get new state
356430 const newState = evolve ( currentState , event ) ;
@@ -398,19 +472,10 @@ export function createSnapshotProjectionWithSnapshotTable<
398472 . values ( readModelData ) ;
399473
400474 // Build the update set for conflict resolution (only for denormalized columns)
401- type UpdateValue = (
402- eb : ExpressionBuilder < DatabaseExecutor , any > ,
403- ) => unknown ;
404- const readModelUpdateSet : Record < string , UpdateValue > = { } ;
405-
406- if ( mapToColumns ) {
407- const columns = mapToColumns ( newState ) ;
408- for ( const columnName of Object . keys ( columns ) ) {
409- readModelUpdateSet [ columnName ] = (
410- eb : ExpressionBuilder < DatabaseExecutor , any > ,
411- ) => eb . ref ( `excluded.${ columnName } ` ) ;
412- }
413- }
475+ const readModelUpdateSet = buildDenormalizedUpdateSet (
476+ newState ,
477+ mapToColumns ,
478+ ) ;
414479
415480 // Only update if there are denormalized columns, otherwise just insert (no-op on conflict)
416481 if ( Object . keys ( readModelUpdateSet ) . length > 0 ) {
0 commit comments