Skip to content

Feat/1226 transactional event projection - #1247

Merged
mikewheeleer merged 3 commits into
Talenttrust:mainfrom
shogun444:feat/1226-transactional-event-projection
Aug 30, 2026
Merged

mikewheeleer merged 3 commits into
Talenttrust:mainfrom
shogun444:feat/1226-transactional-event-projection

Conversation

@shogun444

Copy link
Copy Markdown
Contributor

Closes #1226


Why This PR Exists

Persisting event checkpoints separately from their read-model projections creates state drift, resulting in skipped or duplicated state during retries or process crashes. Previously, EventAuditService.processEvent persisted checkpoints in an in-memory map without a shared transaction boundary across the event audit log and projection state.

This PR places event checkpoints and projection writes inside a single atomic database transaction with explicit serialization retry semantics and bounded execution boundaries.


What Changed

  • Database Schema & Migrations (src/db/migrations.ts):
    • Added migration v16 creating:
      • event_audit: Primary key deduplication_key (event identity) for duplicate replay prevention.
      • event_projection: Primary key entity_id (entity identity) tracking last_event_id for deterministic projection ordering.
      • Associated performance and lookup indexes.
  • Transactional Persistence Layer (src/repository/sqliteEventAuditRepository.ts):
    • Implemented SqliteEventAuditRepository using better-sqlite3 transactions to atomically execute event checkpoint and projection updates.
    • Added withSerializationRetry() to handle concurrency lock contention—strictly retrying only SQLITE_BUSY (5) and SQLITE_BUSY_SNAPSHOT (517) errors, while letting data/constraint errors fail fast.
  • Service & Interface Decoupling (src/repository/eventAuditRepository.ts, src/events/registry.ts):
    • Extended IEventAuditRepository with persistEventAndProjection() while maintaining backwards compatibility with InMemoryEventAuditRepository.
    • Added projectionBuilder support to EventAuditService.
    • Wired SqliteEventAuditRepository and a tenant-isolated contractProjectionBuilder as production defaults.
  • Upstream Build Fixes:
    • Resolved blocking upstream syntax errors in src/errors/safeErrors.ts (invalid /r regex flag) and src/errors/appError.ts (AppExrror and mapZodErropToDetails typos).

Edge Case Coverage & Architecture

Edge Case Implementation Behavior
All writes succeed Atomically commits event checkpoint and projection state in a single transaction.
Projection constraint fails Entire transaction rolls back; checkpoint is not persisted in isolation.
Serialization contention Bounded retry loop triggered exclusively on SQLITE_BUSY/SQLITE_BUSY_SNAPSHOT.
External calls / timeouts External finality checks and side effects are kept strictly outside the transaction boundary.
Duplicate replay Deduplicated via deduplication_key on event_audit and last_event_id version checks on event_projection.
Tenant isolation Tenant boundaries enforced on both read and write operations.

Type of Change

  • Bug fix (resolves state inconsistency and race conditions)
  • Database migration (v16)
  • Refactor / Resilience hardening
  • Tests (unit and integration)

Test Evidence

Targeted Test Suite Verification

  • npm run buildPassed
  • npm run lintPassed (0 errors)
  • tsc --noEmit on changed modules — Passed (0 errors)
  • 81+ tests passing across all touched persistence, service, and routing modules:
 PASS  src/repository/sqliteEventAuditRepository.test.ts (12 tests)
  SqliteEventAuditRepository
    persistEventAndProjection
      ✓ persists event checkpoint and projection atomically
      ✓ rolls back checkpoint if projection write fails
      ✓ retries on SQLITE_BUSY serialization failure
      ✓ rejects duplicate replay using deduplication key
      ✓ enforces tenant isolation across writes
      ✓ executes external calls outside transaction boundary

 PASS  src/repository/eventAuditRepository.test.ts
 PASS  src/services/eventIngestionService.test.ts
 PASS  src/routes/events.routes.test.ts
 PASS  src/routes/admin.finality.routes.test.ts
 PASS  src/events/projection-replay.test.ts
 PASS  src/middleware/errorHandlers.test.ts
 PASS  src/db/migrations.test.ts

Test Suites: 8 passed, 8 total
Tests:       81 passed, 81 total
Snapshots:   0 total
Time:        3.412 s

…dule

Upstream main (d7a66f9) did not compile: src/dlqStore.ts had a double-colon
typo (db::) and src/errors/appError.ts had an invalid ??-optional chain
(response?.headers??.get?). The request-context module referenced by
eventIngestionService.ts and auth/middleware.ts (getContext,
requestContextStorage from '../context') was missing entirely, so builds and
tests could not run. Restore a building baseline.

- src/dlqStore.ts: change db:: to db
- src/errors/appError.ts: change ??.get?. to ?.get?
- src/context.ts: add requestContextStorage (AsyncLocalStorage) + getContext,
  mirroring src/middleware/requestContext.ts
Closes Talenttrust#1226

Persist the event checkpoint and its projection in one database transaction so
a partially-applied event can never leave a checkpoint without its read-model
(or vice-versa), which previously created skipped/duplicated state across
retries.

- migration v16: add event_audit (PK = deduplication_key, event identity) and
  event_projection (PK = entity_id, entity identity; last_event_id for
  replay idempotency) tables.
- SqliteEventAuditRepository: SQLite-backed IEventAuditRepository wired as the
  production default in events/registry.ts. Adds the explicit transactional
  persistEventAndProjection() method (save() stays a plain record write).
- Retry only serialization failures (SQLITE_BUSY=5, SQLITE_BUSY_SNAPSHOT=517),
  bounded attempts/delay; constraint and data errors never retried, so a real
  bug is not masked by a slow retry loop.
- External calls (finality/chain evaluation) run before the commit, so a
  network timeout cannot corrupt the transaction.
- Tenant isolation enforced on write and read (audit + projection rows are
  scoped by the same tenant).
- EventAuditService accepts an optional pure projectionBuilder; registry wires
  a contractId-keyed projection. In-memory repository stays valid for unit
  tests (persistEventAndProjection is optional on the interface).
- Tests cover every edge case: successful atomic commit, projection-constraint
  rollback, SQLITE_BUSY / SQLITE_BUSY_SNAPSHOT retry, retry exhaustion, no
  retry on non-serialization errors, external-call timeout (no partial write),
  duplicate replay (no double projection), and tenant isolation.

Also fixes pre-existing upstream syntax errors blocking the build/tests that
this work depends on: safeErrors.ts invalid regex /r flag, and appError.ts
AppExrror / mapZodErropToDetails typos.
@drips-wave

drips-wave Bot commented Aug 29, 2026

Copy link
Copy Markdown

@shogun444 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@mikewheeleer
mikewheeleer merged commit 67b77ee into Talenttrust:main Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make database transactions span event and projection writes

2 participants