Skip to content

Commit cbd5edf

Browse files
committed
refactor(emmett-event-store-kysely): share mutual functions, add more tests, maintain the documents
1 parent 7bb5607 commit cbd5edf

5 files changed

Lines changed: 829 additions & 60 deletions

File tree

docs/emmett-event-store-kysely.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ const registry = createSnapshotProjectionRegistryWithSnapshotTable(
452452
- Cleaner read model tables (no event-sourcing columns)
453453
- Easier to create new read models
454454
- Centralized snapshot management
455+
- Deterministic `stream_id` construction from keys (URL-encoded for safety)
455456

456457
**Database schema required:**
457458

@@ -466,6 +467,14 @@ CREATE TABLE snapshots (
466467
);
467468
```
468469

470+
**Important Notes:**
471+
472+
- **Primary Key Consistency**: The `extractKeys` function must return the same set of keys for all events. The projection validates this at runtime and will throw an error if keys are inconsistent.
473+
- **Idempotency**: Events with `streamPosition <= lastProcessedPosition` are automatically skipped, ensuring idempotent processing.
474+
- **Race Condition Protection**: Uses `FOR UPDATE` row-level locking to prevent concurrent transaction conflicts.
475+
- **Snapshot Format**: Handles both string and parsed JSON snapshot formats (different database drivers return JSONB differently).
476+
- **Special Characters**: Keys with special characters (like `|` or `:`) are safely URL-encoded in the `stream_id` construction.
477+
469478
#### `createSnapshotProjectionRegistry(eventTypes, config)` (Legacy)
470479

471480
Creates a projection registry for snapshot-based read models (legacy approach - stores everything in the read model table).
@@ -483,6 +492,14 @@ const registry = createSnapshotProjectionRegistry(
483492
);
484493
```
485494

495+
**Important Notes:**
496+
497+
- **Primary Key Consistency**: The `extractKeys` function must return the same set of keys for all events. The projection validates this at runtime and will throw an error if keys are inconsistent.
498+
- **Idempotency**: Events with `streamPosition <= lastProcessedPosition` are automatically skipped, ensuring idempotent processing.
499+
- **Race Condition Protection**: Uses `FOR UPDATE` row-level locking to prevent concurrent transaction conflicts.
500+
- **Snapshot Format**: Handles both string and parsed JSON snapshot formats (different database drivers return JSONB differently).
501+
- **Transaction Safety**: All operations run within a transaction to ensure atomicity.
502+
486503
### Projection Runner
487504

488505
#### `createProjectionRunner(deps): ProjectionRunner`

example/src/docs/PROJECTIONS_ARCHITECTURE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export function createEvolve() {
3333
}
3434

3535
// cart.read-model.ts (Read Model)
36-
import { createSnapshotProjectionRegistry } from "@wataruoguchi/emmett-event-store-kysely/projections";
36+
import { createSnapshotProjectionRegistry } from "@wataruoguchi/emmett-event-store-kysely";
3737

3838
export function cartsSnapshotProjection() {
3939
const domainEvolve = createEvolve(); // Reuse!
@@ -133,7 +133,7 @@ The **projection runner** executes projections **on-demand** and **synchronously
133133
import {
134134
createProjectionRunner,
135135
createProjectionRegistry,
136-
} from "@wataruoguchi/emmett-event-store-kysely/projections";
136+
} from "@wataruoguchi/emmett-event-store-kysely";
137137
import { getKyselyEventStore } from "@wataruoguchi/emmett-event-store-kysely";
138138

139139
// In test setup

packages/emmett-event-store-kysely/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,18 @@ const registry = createSnapshotProjectionRegistryWithSnapshotTable(
117117
- ✅ Cleaner read model tables (no event-sourcing columns)
118118
- ✅ Easier to create new read models (no schema migrations for event-sourcing columns)
119119
- ✅ Centralized snapshot management
120+
- ✅ Race condition protection with `FOR UPDATE` locking
121+
- ✅ Automatic idempotency (skips already-processed events)
122+
- ✅ Primary key validation (ensures consistent `extractKeys`)
123+
124+
**Important:** The `extractKeys` function must return the same set of keys for all events. The projection validates this at runtime.
120125

121126
#### Option B: Legacy Approach (Backward Compatible)
122127

123128
Use `createSnapshotProjectionRegistry` to store everything in the read model table:
124129

130+
**Note:** This approach stores event-sourcing columns (`stream_id`, `last_stream_position`, etc.) directly in the read model table. Consider using Option A for new projects.
131+
125132
```typescript
126133
import {
127134
createSnapshotProjectionRegistry

packages/emmett-event-store-kysely/src/projections/snapshot-projection.ts

Lines changed: 123 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
7780
function 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

Comments
 (0)