159 lines
5.2 KiB
TypeScript
159 lines
5.2 KiB
TypeScript
import type { BusinessEvent } from '../business-events/types.ts';
|
|
import type {
|
|
BeginProjectionCheckpointInput,
|
|
BusinessEventDeliveryRecord,
|
|
CompleteProjectionCheckpointInput,
|
|
CreateProjectionDeadLetterInput,
|
|
FailProjectionCheckpointInput,
|
|
ProjectionCheckpointRecord,
|
|
ProjectionHealthSnapshotInput,
|
|
ProjectionRuntimeStore,
|
|
UnsupportedProjectionCheckpointInput
|
|
} from './types.ts';
|
|
|
|
export class InMemoryProjectionRuntimeStore implements ProjectionRuntimeStore {
|
|
readonly deliveries = new Map<string, BusinessEventDeliveryRecord>();
|
|
readonly checkpoints = new Map<string, ProjectionCheckpointRecord>();
|
|
readonly deadLetters: CreateProjectionDeadLetterInput[] = [];
|
|
readonly healthSnapshots: ProjectionHealthSnapshotInput[] = [];
|
|
|
|
async persistEvent(event: BusinessEvent): Promise<BusinessEventDeliveryRecord> {
|
|
const existing = this.deliveries.get(event.eventId);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const record: BusinessEventDeliveryRecord = {
|
|
eventId: event.eventId,
|
|
organizationId: event.organizationId,
|
|
eventType: event.eventType,
|
|
schemaVersion: event.schemaVersion,
|
|
status: 'pending',
|
|
attemptCount: 0,
|
|
availableAt: new Date().toISOString(),
|
|
event
|
|
};
|
|
this.deliveries.set(event.eventId, record);
|
|
return record;
|
|
}
|
|
|
|
async markDeliveryProcessing(eventId: string): Promise<void> {
|
|
const delivery = this.requireDelivery(eventId);
|
|
delivery.status = 'processing';
|
|
delivery.attemptCount += 1;
|
|
}
|
|
|
|
async markDeliveryCompleted(eventId: string): Promise<void> {
|
|
this.requireDelivery(eventId).status = 'completed';
|
|
}
|
|
|
|
async markDeliveryFailed(eventId: string): Promise<void> {
|
|
this.requireDelivery(eventId).status = 'failed';
|
|
}
|
|
|
|
async markDeliveryDeadLetter(eventId: string): Promise<void> {
|
|
this.requireDelivery(eventId).status = 'dead_letter';
|
|
}
|
|
|
|
async getCheckpoint(
|
|
consumerName: string,
|
|
eventId: string
|
|
): Promise<ProjectionCheckpointRecord | null> {
|
|
return this.checkpoints.get(checkpointKey(consumerName, eventId)) ?? null;
|
|
}
|
|
|
|
async beginCheckpoint(input: BeginProjectionCheckpointInput): Promise<ProjectionCheckpointRecord> {
|
|
const key = checkpointKey(input.consumerName, input.eventId);
|
|
const existing = this.checkpoints.get(key);
|
|
|
|
if (existing) {
|
|
existing.status = 'processing';
|
|
existing.attemptCount += 1;
|
|
return existing;
|
|
}
|
|
|
|
const checkpoint: ProjectionCheckpointRecord = {
|
|
id: crypto.randomUUID(),
|
|
organizationId: input.organizationId,
|
|
consumerName: input.consumerName,
|
|
projectionName: input.projectionName,
|
|
eventId: input.eventId,
|
|
eventType: input.eventType,
|
|
schemaVersion: input.schemaVersion,
|
|
status: 'processing',
|
|
attemptCount: 1,
|
|
nextRetryAt: null,
|
|
errorCode: null,
|
|
errorMessage: null
|
|
};
|
|
this.checkpoints.set(key, checkpoint);
|
|
return checkpoint;
|
|
}
|
|
|
|
async completeCheckpoint(input: CompleteProjectionCheckpointInput): Promise<void> {
|
|
const checkpoint = this.requireCheckpoint(input.consumerName, input.eventId);
|
|
checkpoint.status = 'completed';
|
|
checkpoint.nextRetryAt = null;
|
|
checkpoint.errorCode = null;
|
|
checkpoint.errorMessage = null;
|
|
}
|
|
|
|
async failCheckpoint(input: FailProjectionCheckpointInput): Promise<void> {
|
|
const checkpoint =
|
|
this.checkpoints.get(checkpointKey(input.consumerName, input.eventId)) ??
|
|
(await this.beginCheckpoint({ ...input, now: input.now }));
|
|
|
|
checkpoint.status = input.deadLetter ? 'dead_letter' : 'retry_scheduled';
|
|
checkpoint.nextRetryAt = input.nextRetryAt?.toISOString() ?? null;
|
|
checkpoint.errorCode = input.failure.code;
|
|
checkpoint.errorMessage = input.failure.message;
|
|
}
|
|
|
|
async skipUnsupportedVersion(input: UnsupportedProjectionCheckpointInput): Promise<void> {
|
|
this.checkpoints.set(checkpointKey(input.consumerName, input.eventId), {
|
|
id: crypto.randomUUID(),
|
|
organizationId: input.organizationId,
|
|
consumerName: input.consumerName,
|
|
projectionName: input.projectionName,
|
|
eventId: input.eventId,
|
|
eventType: input.eventType,
|
|
schemaVersion: input.schemaVersion,
|
|
status: 'skipped_unsupported_version',
|
|
attemptCount: 0,
|
|
nextRetryAt: null,
|
|
errorCode: 'unsupported_event_version',
|
|
errorMessage: `Unsupported schema version ${input.schemaVersion}`
|
|
});
|
|
}
|
|
|
|
async createDeadLetter(input: CreateProjectionDeadLetterInput): Promise<void> {
|
|
this.deadLetters.push(input);
|
|
}
|
|
|
|
async recordHealthSnapshot(input: ProjectionHealthSnapshotInput): Promise<void> {
|
|
this.healthSnapshots.push(input);
|
|
}
|
|
|
|
private requireDelivery(eventId: string): BusinessEventDeliveryRecord {
|
|
const delivery = this.deliveries.get(eventId);
|
|
if (!delivery) {
|
|
throw new Error(`Missing delivery record for event ${eventId}`);
|
|
}
|
|
|
|
return delivery;
|
|
}
|
|
|
|
private requireCheckpoint(consumerName: string, eventId: string): ProjectionCheckpointRecord {
|
|
const checkpoint = this.checkpoints.get(checkpointKey(consumerName, eventId));
|
|
if (!checkpoint) {
|
|
throw new Error(`Missing checkpoint for ${consumerName}:${eventId}`);
|
|
}
|
|
|
|
return checkpoint;
|
|
}
|
|
}
|
|
|
|
function checkpointKey(consumerName: string, eventId: string): string {
|
|
return `${consumerName}:${eventId}`;
|
|
}
|