task ep.1.4
This commit is contained in:
167
src/db/schema.ts
167
src/db/schema.ts
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
boolean,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
@@ -451,6 +452,172 @@ export const appNotificationDeliveries = pgTable('app_notification_deliveries',
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const businessEventOutbox = pgTable(
|
||||
'business_event_outbox',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
eventType: text('event_type').notNull(),
|
||||
schemaVersion: integer('schema_version').notNull(),
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: text('entity_id').notNull(),
|
||||
correlationId: text('correlation_id').notNull(),
|
||||
causationId: text('causation_id'),
|
||||
eventEnvelope: jsonb('event_envelope').notNull(),
|
||||
deliveryStatus: text('delivery_status').default('pending').notNull(),
|
||||
attemptCount: integer('attempt_count').default(0).notNull(),
|
||||
availableAt: timestamp('available_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
processingStartedAt: timestamp('processing_started_at', { withTimezone: true }),
|
||||
dispatchedAt: timestamp('dispatched_at', { withTimezone: true }),
|
||||
failedAt: timestamp('failed_at', { withTimezone: true }),
|
||||
deadLetteredAt: timestamp('dead_lettered_at', { withTimezone: true }),
|
||||
lastErrorCode: text('last_error_code'),
|
||||
lastErrorMessage: text('last_error_message'),
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
statusAvailableIdx: index('business_event_outbox_status_available_idx').on(
|
||||
table.deliveryStatus,
|
||||
table.availableAt
|
||||
),
|
||||
organizationEventIdx: index('business_event_outbox_org_event_idx').on(
|
||||
table.organizationId,
|
||||
table.eventType
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const projectionConsumerCheckpoints = pgTable(
|
||||
'projection_consumer_checkpoints',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
consumerName: text('consumer_name').notNull(),
|
||||
projectionName: text('projection_name').notNull(),
|
||||
eventId: text('event_id').notNull(),
|
||||
eventType: text('event_type').notNull(),
|
||||
schemaVersion: integer('schema_version').notNull(),
|
||||
processingStatus: text('processing_status').default('pending').notNull(),
|
||||
attemptCount: integer('attempt_count').default(0).notNull(),
|
||||
firstAttemptedAt: timestamp('first_attempted_at', { withTimezone: true }),
|
||||
lastAttemptedAt: timestamp('last_attempted_at', { withTimezone: true }),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
nextRetryAt: timestamp('next_retry_at', { withTimezone: true }),
|
||||
errorCode: text('error_code'),
|
||||
errorMessage: text('error_message'),
|
||||
processingDurationMs: integer('processing_duration_ms'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
consumerEventIdx: uniqueIndex('projection_checkpoints_consumer_event_idx').on(
|
||||
table.consumerName,
|
||||
table.eventId
|
||||
),
|
||||
statusRetryIdx: index('projection_checkpoints_status_retry_idx').on(
|
||||
table.processingStatus,
|
||||
table.nextRetryAt
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const projectionDeadLetters = pgTable(
|
||||
'projection_dead_letters',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
consumerName: text('consumer_name').notNull(),
|
||||
projectionName: text('projection_name').notNull(),
|
||||
eventId: text('event_id').notNull(),
|
||||
eventType: text('event_type').notNull(),
|
||||
schemaVersion: integer('schema_version').notNull(),
|
||||
attemptCount: integer('attempt_count').notNull(),
|
||||
errorCode: text('error_code').notNull(),
|
||||
errorMessage: text('error_message').notNull(),
|
||||
failureMetadata: jsonb('failure_metadata'),
|
||||
status: text('status').default('open').notNull(),
|
||||
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
|
||||
resolvedBy: text('resolved_by'),
|
||||
resolutionReason: text('resolution_reason'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
consumerEventIdx: uniqueIndex('projection_dead_letters_consumer_event_idx').on(
|
||||
table.consumerName,
|
||||
table.eventId
|
||||
),
|
||||
organizationStatusIdx: index('projection_dead_letters_org_status_idx').on(
|
||||
table.organizationId,
|
||||
table.status
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const projectionRebuildRuns = pgTable(
|
||||
'projection_rebuild_runs',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
projectionName: text('projection_name').notNull(),
|
||||
entityType: text('entity_type'),
|
||||
entityId: text('entity_id'),
|
||||
occurredFrom: timestamp('occurred_from', { withTimezone: true }),
|
||||
occurredTo: timestamp('occurred_to', { withTimezone: true }),
|
||||
dryRun: boolean('dry_run').default(true).notNull(),
|
||||
resetExisting: boolean('reset_existing').default(false).notNull(),
|
||||
sourceStrategy: text('source_strategy').notNull(),
|
||||
status: text('status').default('pending').notNull(),
|
||||
processedCount: integer('processed_count').default(0).notNull(),
|
||||
skippedCount: integer('skipped_count').default(0).notNull(),
|
||||
failedCount: integer('failed_count').default(0).notNull(),
|
||||
errorCode: text('error_code'),
|
||||
errorMessage: text('error_message'),
|
||||
requestedBy: text('requested_by').notNull(),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
organizationProjectionIdx: index('projection_rebuild_runs_org_projection_idx').on(
|
||||
table.organizationId,
|
||||
table.projectionName
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const projectionHealthSnapshots = pgTable(
|
||||
'projection_health_snapshots',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
projectionName: text('projection_name').notNull(),
|
||||
consumerName: text('consumer_name').notNull(),
|
||||
status: text('status').default('healthy').notNull(),
|
||||
lastProcessedEventId: text('last_processed_event_id'),
|
||||
lastSuccessfulProcessingAt: timestamp('last_successful_processing_at', { withTimezone: true }),
|
||||
pendingCount: integer('pending_count').default(0).notNull(),
|
||||
retryCount: integer('retry_count').default(0).notNull(),
|
||||
deadLetterCount: integer('dead_letter_count').default(0).notNull(),
|
||||
unsupportedVersionCount: integer('unsupported_version_count').default(0).notNull(),
|
||||
processingLagMs: integer('processing_lag_ms').default(0).notNull(),
|
||||
averageProcessingDurationMs: integer('average_processing_duration_ms').default(0).notNull(),
|
||||
lastRebuildAt: timestamp('last_rebuild_at', { withTimezone: true }),
|
||||
rebuildSource: text('rebuild_source'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
organizationConsumerIdx: uniqueIndex('projection_health_org_consumer_idx').on(
|
||||
table.organizationId,
|
||||
table.consumerName
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const crmCustomers = pgTable(
|
||||
'crm_customers',
|
||||
{
|
||||
|
||||
33
src/features/foundation/projections/consumers.ts
Normal file
33
src/features/foundation/projections/consumers.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { BusinessEvent } from '../business-events/types.ts';
|
||||
import { getProjectionDefinition } from './registry.ts';
|
||||
import type { ProjectionConsumer, ProjectionHandleResult } from './types.ts';
|
||||
|
||||
function createNoopConsumer(projectionName: string): ProjectionConsumer {
|
||||
const definition = getProjectionDefinition(projectionName);
|
||||
|
||||
return {
|
||||
consumerName: definition.consumerName,
|
||||
projectionName: definition.projectionName,
|
||||
supportedEventTypes: definition.consumedEventTypes,
|
||||
supportedVersions: definition.supportedVersions,
|
||||
async handle(_event: BusinessEvent): Promise<ProjectionHandleResult> {
|
||||
return {
|
||||
status: 'processed',
|
||||
message: `${definition.projectionName} projection skeleton acknowledged event`,
|
||||
sideEffectCount: 0
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const timelineProjectionConsumer = createNoopConsumer('Timeline');
|
||||
export const calendarProjectionConsumer = createNoopConsumer('Calendar');
|
||||
export const dashboardProjectionConsumer = createNoopConsumer('Dashboard');
|
||||
export const notificationProjectionConsumer = createNoopConsumer('Notification');
|
||||
|
||||
export const INITIAL_PROJECTION_CONSUMERS = [
|
||||
timelineProjectionConsumer,
|
||||
calendarProjectionConsumer,
|
||||
dashboardProjectionConsumer,
|
||||
notificationProjectionConsumer
|
||||
];
|
||||
9
src/features/foundation/projections/index.ts
Normal file
9
src/features/foundation/projections/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './consumers.ts';
|
||||
export * from './matrices.ts';
|
||||
export * from './memory-store.ts';
|
||||
export * from './publisher.ts';
|
||||
export * from './rebuild.ts';
|
||||
export * from './registry.ts';
|
||||
export * from './retry-policy.ts';
|
||||
export * from './runtime.ts';
|
||||
export * from './types.ts';
|
||||
178
src/features/foundation/projections/matrices.ts
Normal file
178
src/features/foundation/projections/matrices.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
export const PROJECTION_DELIVERY_MATRIX = [
|
||||
{
|
||||
projection: 'Timeline',
|
||||
eventSource: 'Activity + CRM events',
|
||||
initialBuildSource: 'source queries + adapters',
|
||||
incrementalStrategy: 'Business Events',
|
||||
persistence: 'cached read model',
|
||||
idempotency: 'consumerName + eventId',
|
||||
retry: 'projection-default-v1',
|
||||
cutoverPhase: 'EP.1.5 Timeline epic'
|
||||
},
|
||||
{
|
||||
projection: 'Calendar',
|
||||
eventSource: 'Activity + milestones + approval',
|
||||
initialBuildSource: 'source queries',
|
||||
incrementalStrategy: 'Business Events',
|
||||
persistence: 'cached/query-time',
|
||||
idempotency: 'consumerName + eventId',
|
||||
retry: 'projection-default-v1',
|
||||
cutoverPhase: 'Calendar epic'
|
||||
},
|
||||
{
|
||||
projection: 'Dashboard',
|
||||
eventSource: 'Opportunity + Quotation + Approval + Activity',
|
||||
initialBuildSource: 'existing datasets',
|
||||
incrementalStrategy: 'hybrid',
|
||||
persistence: 'existing datasets + future read model',
|
||||
idempotency: 'consumerName + eventId',
|
||||
retry: 'projection-default-v1',
|
||||
cutoverPhase: 'Dashboard enhancement'
|
||||
},
|
||||
{
|
||||
projection: 'Notification',
|
||||
eventSource: 'Activity + Approval + CRM events',
|
||||
initialBuildSource: 'not applicable',
|
||||
incrementalStrategy: 'Business Events',
|
||||
persistence: 'notification-owned records',
|
||||
idempotency: 'consumerName + eventId + recipient',
|
||||
retry: 'projection-default-v1',
|
||||
cutoverPhase: 'Notification expansion'
|
||||
},
|
||||
{
|
||||
projection: 'My Day',
|
||||
eventSource: 'Activity + approvals + Hot Project',
|
||||
initialBuildSource: 'source composition',
|
||||
incrementalStrategy: 'hybrid',
|
||||
persistence: 'query-time/cached',
|
||||
idempotency: 'consumerName + eventId',
|
||||
retry: 'projection-default-v1',
|
||||
cutoverPhase: 'My Day epic'
|
||||
},
|
||||
{
|
||||
projection: 'Relationship Health',
|
||||
eventSource: 'Customer + Activity + Opportunity',
|
||||
initialBuildSource: 'source rebuild',
|
||||
incrementalStrategy: 'Business Events',
|
||||
persistence: 'cached score',
|
||||
idempotency: 'consumerName + eventId',
|
||||
retry: 'projection-default-v1',
|
||||
cutoverPhase: 'Relationship Health epic'
|
||||
},
|
||||
{
|
||||
projection: 'Forecast',
|
||||
eventSource: 'Opportunity events',
|
||||
initialBuildSource: 'source opportunity query',
|
||||
incrementalStrategy: 'hybrid',
|
||||
persistence: 'report/read model',
|
||||
idempotency: 'consumerName + eventId',
|
||||
retry: 'projection-default-v1',
|
||||
cutoverPhase: 'Forecast enhancement'
|
||||
}
|
||||
] as const;
|
||||
|
||||
export const PROJECTION_FAILURE_MATRIX = [
|
||||
{
|
||||
failureScenario: 'Event persistence fails inside source transaction',
|
||||
sourceMutationResult: 'rollback',
|
||||
eventState: 'none',
|
||||
consumerState: 'none',
|
||||
retry: 'no',
|
||||
userApiResult: 'mutation fails'
|
||||
},
|
||||
{
|
||||
failureScenario: 'Source mutation commits, consumer fails',
|
||||
sourceMutationResult: 'committed',
|
||||
eventState: 'persisted',
|
||||
consumerState: 'retry_scheduled',
|
||||
retry: 'yes',
|
||||
userApiResult: 'mutation succeeds'
|
||||
},
|
||||
{
|
||||
failureScenario: 'One of multiple consumers fails',
|
||||
sourceMutationResult: 'committed',
|
||||
eventState: 'persisted',
|
||||
consumerState: 'isolated failure',
|
||||
retry: 'yes',
|
||||
userApiResult: 'mutation succeeds'
|
||||
},
|
||||
{
|
||||
failureScenario: 'Unsupported event version',
|
||||
sourceMutationResult: 'committed',
|
||||
eventState: 'persisted',
|
||||
consumerState: 'skipped_unsupported_version',
|
||||
retry: 'governed by version migration',
|
||||
userApiResult: 'mutation succeeds'
|
||||
},
|
||||
{
|
||||
failureScenario: 'Duplicate event delivered',
|
||||
sourceMutationResult: 'committed',
|
||||
eventState: 'persisted',
|
||||
consumerState: 'duplicate_completed',
|
||||
retry: 'no side effect',
|
||||
userApiResult: 'unchanged'
|
||||
},
|
||||
{
|
||||
failureScenario: 'Projection storage unavailable',
|
||||
sourceMutationResult: 'committed',
|
||||
eventState: 'persisted',
|
||||
consumerState: 'retry_scheduled',
|
||||
retry: 'yes',
|
||||
userApiResult: 'mutation succeeds'
|
||||
},
|
||||
{
|
||||
failureScenario: 'Dead-letter threshold reached',
|
||||
sourceMutationResult: 'committed',
|
||||
eventState: 'persisted',
|
||||
consumerState: 'dead_letter',
|
||||
retry: 'manual retry after review',
|
||||
userApiResult: 'mutation succeeds'
|
||||
},
|
||||
{
|
||||
failureScenario: 'Rebuild fails',
|
||||
sourceMutationResult: 'unchanged',
|
||||
eventState: 'unchanged',
|
||||
consumerState: 'rebuild failed',
|
||||
retry: 'restartable',
|
||||
userApiResult: 'no source impact'
|
||||
}
|
||||
] as const;
|
||||
|
||||
export const PROJECTION_SECURITY_MATRIX = [
|
||||
{
|
||||
projection: 'Timeline',
|
||||
rule: 'Must reapply source record visibility, internal-only filters, and pricing redaction.'
|
||||
},
|
||||
{
|
||||
projection: 'Calendar',
|
||||
rule: 'Must scope by organization and personal/team access before showing scheduled work.'
|
||||
},
|
||||
{
|
||||
projection: 'Dashboard',
|
||||
rule: 'Must preserve dashboard pricing visibility and report context boundaries.'
|
||||
},
|
||||
{
|
||||
projection: 'Notification',
|
||||
rule: 'Must derive recipients through governed source rules and avoid sensitive payload storage.'
|
||||
},
|
||||
{
|
||||
projection: 'My Day',
|
||||
rule: 'Must personalize from source-authorized activities, approvals, and priorities.'
|
||||
},
|
||||
{
|
||||
projection: 'Manager Workspace',
|
||||
rule: 'Must use resolved CRM branch, product, ownership, and approval authority scopes.'
|
||||
},
|
||||
{
|
||||
projection: 'Executive Workspace',
|
||||
rule: 'Must keep pricing-sensitive metrics behind pricing visibility gates.'
|
||||
},
|
||||
{
|
||||
projection: 'Relationship Health',
|
||||
rule: 'Must not expose customer or contact data beyond ownership/contact-sharing boundaries.'
|
||||
},
|
||||
{
|
||||
projection: 'Forecast',
|
||||
rule: 'Must reuse report foundation access context and revenue/pricing governance.'
|
||||
}
|
||||
] as const;
|
||||
158
src/features/foundation/projections/memory-store.ts
Normal file
158
src/features/foundation/projections/memory-store.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
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}`;
|
||||
}
|
||||
36
src/features/foundation/projections/publisher.ts
Normal file
36
src/features/foundation/projections/publisher.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
BusinessEvent,
|
||||
BusinessEventDispatchResult,
|
||||
BusinessEventPublisher
|
||||
} from '../business-events/types.ts';
|
||||
import { ProjectionConsumerRuntime } from './runtime.ts';
|
||||
|
||||
export class ProjectionOutboxBusinessEventPublisher implements BusinessEventPublisher {
|
||||
private readonly runtime: ProjectionConsumerRuntime;
|
||||
|
||||
constructor(options: { runtime: ProjectionConsumerRuntime }) {
|
||||
this.runtime = options.runtime;
|
||||
}
|
||||
|
||||
async publish(event: BusinessEvent): Promise<BusinessEventDispatchResult> {
|
||||
await this.runtime.persistEvent(event);
|
||||
|
||||
return {
|
||||
eventId: event.eventId,
|
||||
eventType: event.eventType,
|
||||
status: 'dispatched',
|
||||
durationMs: 0,
|
||||
subscriberResults: []
|
||||
};
|
||||
}
|
||||
|
||||
async publishMany(events: BusinessEvent[]): Promise<BusinessEventDispatchResult[]> {
|
||||
const results: BusinessEventDispatchResult[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
results.push(await this.publish(event));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
31
src/features/foundation/projections/rebuild.ts
Normal file
31
src/features/foundation/projections/rebuild.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { getProjectionDefinition } from './registry.ts';
|
||||
import type { ProjectionRebuildRequest, ProjectionRebuildResult } from './types.ts';
|
||||
|
||||
export function createProjectionRebuildPlan(
|
||||
request: ProjectionRebuildRequest
|
||||
): ProjectionRebuildResult {
|
||||
const definition = getProjectionDefinition(request.projectionName);
|
||||
|
||||
if (!request.organizationId) {
|
||||
throw new Error('Projection rebuild requires organizationId');
|
||||
}
|
||||
|
||||
return {
|
||||
rebuildRunId: crypto.randomUUID(),
|
||||
projectionName: definition.projectionName,
|
||||
organizationId: request.organizationId,
|
||||
dryRun: request.dryRun ?? true,
|
||||
status: 'pending',
|
||||
processedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
export const PROJECTION_REBUILD_STRATEGY = {
|
||||
initialBuild: 'Governed source queries plus legacy adapters',
|
||||
incrementalUpdates: 'Business Events through projection runtime checkpoints',
|
||||
recovery: 'Source rebuild plus event checkpoint reconciliation',
|
||||
resetRule: 'resetExisting must be explicit and organization-scoped',
|
||||
rollback: 'Disable projection consumer and rebuild from source-owned data'
|
||||
} as const;
|
||||
155
src/features/foundation/projections/registry.ts
Normal file
155
src/features/foundation/projections/registry.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { ProjectionDefinition } from './types.ts';
|
||||
|
||||
const ACTIVITY_EVENTS = [
|
||||
'activity.created',
|
||||
'activity.assigned',
|
||||
'activity.reassigned',
|
||||
'activity.started',
|
||||
'activity.rescheduled',
|
||||
'activity.completed',
|
||||
'activity.cancelled',
|
||||
'activity.deleted'
|
||||
];
|
||||
|
||||
const ACTIVITY_EVENT_VERSIONS = Object.fromEntries(
|
||||
ACTIVITY_EVENTS.map((eventType) => [eventType, [1]])
|
||||
) as Record<string, number[]>;
|
||||
|
||||
export const PROJECTION_REGISTRY: ProjectionDefinition[] = [
|
||||
{
|
||||
projectionName: 'Timeline',
|
||||
consumerName: 'timeline.projection.consumer',
|
||||
owningFeature: 'foundation/projections',
|
||||
consumedEventTypes: ACTIVITY_EVENTS,
|
||||
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
||||
rebuildStrategy: 'hybrid',
|
||||
persistence: 'cached-read-model',
|
||||
securityClassification: 'organization-scoped; source-visibility-governed',
|
||||
idempotencyKey: 'consumerName + eventId',
|
||||
retryPolicy: 'projection-default-v1',
|
||||
retentionPolicy: 'event checkpoints retained with projection audit horizon',
|
||||
implementationStatus: 'contract-only'
|
||||
},
|
||||
{
|
||||
projectionName: 'Calendar',
|
||||
consumerName: 'calendar.projection.consumer',
|
||||
owningFeature: 'foundation/projections',
|
||||
consumedEventTypes: ACTIVITY_EVENTS,
|
||||
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
||||
rebuildStrategy: 'hybrid',
|
||||
persistence: 'cached-read-model',
|
||||
securityClassification: 'organization-scoped; assignment and source-visibility-governed',
|
||||
idempotencyKey: 'consumerName + eventId',
|
||||
retryPolicy: 'projection-default-v1',
|
||||
retentionPolicy: 'calendar projection rows rebuildable from source activity and events',
|
||||
implementationStatus: 'contract-only'
|
||||
},
|
||||
{
|
||||
projectionName: 'Dashboard',
|
||||
consumerName: 'dashboard.projection.consumer',
|
||||
owningFeature: 'crm/dashboard',
|
||||
consumedEventTypes: ACTIVITY_EVENTS,
|
||||
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
||||
rebuildStrategy: 'hybrid',
|
||||
persistence: 'query-time',
|
||||
securityClassification: 'organization-scoped; pricing visibility remains source-governed',
|
||||
idempotencyKey: 'consumerName + eventId',
|
||||
retryPolicy: 'projection-default-v1',
|
||||
retentionPolicy: 'no final dashboard read-model introduced in EP.1.4',
|
||||
implementationStatus: 'contract-only'
|
||||
},
|
||||
{
|
||||
projectionName: 'Notification',
|
||||
consumerName: 'notification.projection.consumer',
|
||||
owningFeature: 'foundation/notifications',
|
||||
consumedEventTypes: ACTIVITY_EVENTS,
|
||||
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
||||
rebuildStrategy: 'event-replay',
|
||||
persistence: 'cached-read-model',
|
||||
securityClassification: 'organization-scoped; recipient and template governed',
|
||||
idempotencyKey: 'consumerName + eventId + recipientUserId',
|
||||
retryPolicy: 'projection-default-v1',
|
||||
retentionPolicy: 'notification dedupe and delivery retention remains notification-owned',
|
||||
implementationStatus: 'contract-only'
|
||||
},
|
||||
{
|
||||
projectionName: 'My Day',
|
||||
consumerName: 'my-day.projection.consumer',
|
||||
owningFeature: 'future/my-day',
|
||||
consumedEventTypes: ACTIVITY_EVENTS,
|
||||
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
||||
rebuildStrategy: 'hybrid',
|
||||
persistence: 'query-time',
|
||||
securityClassification: 'personalized organization-scoped source composition',
|
||||
idempotencyKey: 'consumerName + eventId',
|
||||
retryPolicy: 'projection-default-v1',
|
||||
retentionPolicy: 'future workspace task',
|
||||
implementationStatus: 'contract-only'
|
||||
},
|
||||
{
|
||||
projectionName: 'Manager Workspace',
|
||||
consumerName: 'manager-workspace.projection.consumer',
|
||||
owningFeature: 'future/manager-workspace',
|
||||
consumedEventTypes: ACTIVITY_EVENTS,
|
||||
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
||||
rebuildStrategy: 'hybrid',
|
||||
persistence: 'query-time',
|
||||
securityClassification: 'manager scope governed by resolved CRM access',
|
||||
idempotencyKey: 'consumerName + eventId',
|
||||
retryPolicy: 'projection-default-v1',
|
||||
retentionPolicy: 'future workspace task',
|
||||
implementationStatus: 'contract-only'
|
||||
},
|
||||
{
|
||||
projectionName: 'Executive Workspace',
|
||||
consumerName: 'executive-workspace.projection.consumer',
|
||||
owningFeature: 'future/executive-workspace',
|
||||
consumedEventTypes: ACTIVITY_EVENTS,
|
||||
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
||||
rebuildStrategy: 'hybrid',
|
||||
persistence: 'query-time',
|
||||
securityClassification: 'executive scope with pricing visibility gates',
|
||||
idempotencyKey: 'consumerName + eventId',
|
||||
retryPolicy: 'projection-default-v1',
|
||||
retentionPolicy: 'future workspace task',
|
||||
implementationStatus: 'contract-only'
|
||||
},
|
||||
{
|
||||
projectionName: 'Relationship Health',
|
||||
consumerName: 'relationship-health.projection.consumer',
|
||||
owningFeature: 'future/relationship-health',
|
||||
consumedEventTypes: ACTIVITY_EVENTS,
|
||||
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
||||
rebuildStrategy: 'hybrid',
|
||||
persistence: 'cached-read-model',
|
||||
securityClassification: 'customer visibility and internal-only rules required',
|
||||
idempotencyKey: 'consumerName + eventId',
|
||||
retryPolicy: 'projection-default-v1',
|
||||
retentionPolicy: 'derived score rebuildable from customer, opportunity, activity sources',
|
||||
implementationStatus: 'contract-only'
|
||||
},
|
||||
{
|
||||
projectionName: 'Forecast',
|
||||
consumerName: 'forecast.projection.consumer',
|
||||
owningFeature: 'crm/reports',
|
||||
consumedEventTypes: ACTIVITY_EVENTS,
|
||||
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
||||
rebuildStrategy: 'hybrid',
|
||||
persistence: 'query-time',
|
||||
securityClassification: 'report context and pricing visibility governed',
|
||||
idempotencyKey: 'consumerName + eventId',
|
||||
retryPolicy: 'projection-default-v1',
|
||||
retentionPolicy: 'forecast enhancement task',
|
||||
implementationStatus: 'contract-only'
|
||||
}
|
||||
];
|
||||
|
||||
export function getProjectionDefinition(projectionName: string): ProjectionDefinition {
|
||||
const definition = PROJECTION_REGISTRY.find((projection) => projection.projectionName === projectionName);
|
||||
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown projection: ${projectionName}`);
|
||||
}
|
||||
|
||||
return definition;
|
||||
}
|
||||
79
src/features/foundation/projections/retry-policy.ts
Normal file
79
src/features/foundation/projections/retry-policy.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ProjectionFailure } from './types.ts';
|
||||
|
||||
export interface ProjectionRetryPolicy {
|
||||
name: string;
|
||||
maxAttempts: number;
|
||||
backoffMs: number[];
|
||||
jitterRatio: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PROJECTION_RETRY_POLICY: ProjectionRetryPolicy = {
|
||||
name: 'projection-default-v1',
|
||||
maxAttempts: 5,
|
||||
backoffMs: [0, 60_000, 300_000, 1_800_000],
|
||||
jitterRatio: 0.1
|
||||
};
|
||||
|
||||
export class ProjectionConsumerError extends Error {
|
||||
readonly code: string;
|
||||
readonly retryable: boolean;
|
||||
|
||||
constructor(message: string, options: { code?: string; retryable?: boolean } = {}) {
|
||||
super(message);
|
||||
this.name = 'ProjectionConsumerError';
|
||||
this.code = options.code ?? 'projection_consumer_error';
|
||||
this.retryable = options.retryable ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeProjectionFailure(error: unknown): ProjectionFailure {
|
||||
if (error instanceof ProjectionConsumerError) {
|
||||
return {
|
||||
code: error.code,
|
||||
message: sanitizeErrorMessage(error.message),
|
||||
retryable: error.retryable
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
code: 'unexpected_projection_error',
|
||||
message: sanitizeErrorMessage(error.message),
|
||||
retryable: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 'unknown_projection_error',
|
||||
message: 'Unknown projection processing error',
|
||||
retryable: true
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveNextRetryAt(
|
||||
attemptCount: number,
|
||||
failure: ProjectionFailure,
|
||||
now: Date,
|
||||
policy: ProjectionRetryPolicy = DEFAULT_PROJECTION_RETRY_POLICY
|
||||
): Date | null {
|
||||
if (!failure.retryable || attemptCount >= policy.maxAttempts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const delayIndex = Math.min(Math.max(attemptCount - 1, 0), policy.backoffMs.length - 1);
|
||||
const baseDelay = policy.backoffMs[delayIndex] ?? policy.backoffMs.at(-1) ?? 0;
|
||||
const jitter = Math.round(baseDelay * policy.jitterRatio * 0.5);
|
||||
return new Date(now.getTime() + baseDelay + jitter);
|
||||
}
|
||||
|
||||
export function shouldDeadLetter(
|
||||
attemptCount: number,
|
||||
failure: ProjectionFailure,
|
||||
policy: ProjectionRetryPolicy = DEFAULT_PROJECTION_RETRY_POLICY
|
||||
): boolean {
|
||||
return !failure.retryable || attemptCount >= policy.maxAttempts;
|
||||
}
|
||||
|
||||
function sanitizeErrorMessage(message: string): string {
|
||||
return message.replace(/\s+/g, ' ').slice(0, 500);
|
||||
}
|
||||
190
src/features/foundation/projections/runtime.test.ts
Normal file
190
src/features/foundation/projections/runtime.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBusinessEvent } from '../business-events/publisher.ts';
|
||||
import { INITIAL_PROJECTION_CONSUMERS } from './consumers.ts';
|
||||
import { InMemoryProjectionRuntimeStore } from './memory-store.ts';
|
||||
import { PROJECTION_REGISTRY, getProjectionDefinition } from './registry.ts';
|
||||
import { ProjectionOutboxBusinessEventPublisher } from './publisher.ts';
|
||||
import { ProjectionConsumerError } from './retry-policy.ts';
|
||||
import { ProjectionConsumerRuntime } from './runtime.ts';
|
||||
import type { ProjectionConsumer } from './types.ts';
|
||||
|
||||
function activityEvent(overrides: Record<string, unknown> = {}) {
|
||||
return createBusinessEvent({
|
||||
eventType: 'activity.completed',
|
||||
organizationId: 'org-1',
|
||||
entityType: 'activity',
|
||||
entityId: 'activity-1',
|
||||
primaryRecord: { type: 'activity', id: 'activity-1' },
|
||||
actorUserId: 'user-1',
|
||||
correlationId: 'corr-1',
|
||||
payload: { activityId: 'activity-1', ...overrides }
|
||||
});
|
||||
}
|
||||
|
||||
test('projection registry includes required planned projections', () => {
|
||||
const names = PROJECTION_REGISTRY.map((projection) => projection.projectionName);
|
||||
assert.deepEqual(names, [
|
||||
'Timeline',
|
||||
'Calendar',
|
||||
'Dashboard',
|
||||
'Notification',
|
||||
'My Day',
|
||||
'Manager Workspace',
|
||||
'Executive Workspace',
|
||||
'Relationship Health',
|
||||
'Forecast'
|
||||
]);
|
||||
assert.equal(getProjectionDefinition('Timeline').idempotencyKey, 'consumerName + eventId');
|
||||
});
|
||||
|
||||
test('runtime processes skeleton consumers and records completed checkpoints', async () => {
|
||||
const store = new InMemoryProjectionRuntimeStore();
|
||||
const event = activityEvent();
|
||||
await store.persistEvent(event);
|
||||
|
||||
const runtime = new ProjectionConsumerRuntime({
|
||||
store,
|
||||
consumers: INITIAL_PROJECTION_CONSUMERS
|
||||
});
|
||||
|
||||
const result = await runtime.processEvent(event);
|
||||
|
||||
assert.equal(result.status, 'completed');
|
||||
assert.equal(result.consumerResults.length, 4);
|
||||
assert.equal([...store.checkpoints.values()].every((checkpoint) => checkpoint.status === 'completed'), true);
|
||||
});
|
||||
|
||||
test('outbox publisher persists event without running projection consumers synchronously', async () => {
|
||||
const store = new InMemoryProjectionRuntimeStore();
|
||||
const event = activityEvent();
|
||||
let handled = false;
|
||||
const consumer: ProjectionConsumer = {
|
||||
consumerName: 'sync-guard.consumer',
|
||||
projectionName: 'Sync Guard',
|
||||
supportedEventTypes: ['activity.completed'],
|
||||
supportedVersions: { 'activity.completed': [1] },
|
||||
async handle() {
|
||||
handled = true;
|
||||
return { status: 'processed' };
|
||||
}
|
||||
};
|
||||
const runtime = new ProjectionConsumerRuntime({ store, consumers: [consumer] });
|
||||
const publisher = new ProjectionOutboxBusinessEventPublisher({ runtime });
|
||||
|
||||
const result = await publisher.publish(event);
|
||||
|
||||
assert.equal(result.status, 'dispatched');
|
||||
assert.equal(store.deliveries.get(event.eventId)?.status, 'pending');
|
||||
assert.equal(handled, false);
|
||||
});
|
||||
|
||||
test('completed checkpoint prevents duplicate side effects', async () => {
|
||||
const store = new InMemoryProjectionRuntimeStore();
|
||||
const event = activityEvent();
|
||||
let count = 0;
|
||||
const consumer: ProjectionConsumer = {
|
||||
consumerName: 'counter.consumer',
|
||||
projectionName: 'Counter',
|
||||
supportedEventTypes: ['activity.completed'],
|
||||
supportedVersions: { 'activity.completed': [1] },
|
||||
async handle() {
|
||||
count += 1;
|
||||
return { status: 'processed' };
|
||||
}
|
||||
};
|
||||
|
||||
await store.persistEvent(event);
|
||||
const runtime = new ProjectionConsumerRuntime({ store, consumers: [consumer] });
|
||||
|
||||
await runtime.processEvent(event);
|
||||
const second = await runtime.processEvent(event);
|
||||
|
||||
assert.equal(count, 1);
|
||||
assert.equal(second.consumerResults[0]?.status, 'duplicate_completed');
|
||||
});
|
||||
|
||||
test('consumer failure schedules retry without throwing or blocking unrelated consumers', async () => {
|
||||
const store = new InMemoryProjectionRuntimeStore();
|
||||
const event = activityEvent();
|
||||
const handled: string[] = [];
|
||||
const failing: ProjectionConsumer = {
|
||||
consumerName: 'failing.consumer',
|
||||
projectionName: 'Failing',
|
||||
supportedEventTypes: ['activity.completed'],
|
||||
supportedVersions: { 'activity.completed': [1] },
|
||||
async handle() {
|
||||
throw new ProjectionConsumerError('storage unavailable customer=secret', {
|
||||
code: 'projection_storage_unavailable',
|
||||
retryable: true
|
||||
});
|
||||
}
|
||||
};
|
||||
const healthy: ProjectionConsumer = {
|
||||
consumerName: 'healthy.consumer',
|
||||
projectionName: 'Healthy',
|
||||
supportedEventTypes: ['activity.completed'],
|
||||
supportedVersions: { 'activity.completed': [1] },
|
||||
async handle() {
|
||||
handled.push('healthy');
|
||||
return { status: 'processed' };
|
||||
}
|
||||
};
|
||||
|
||||
await store.persistEvent(event);
|
||||
const runtime = new ProjectionConsumerRuntime({ store, consumers: [failing, healthy] });
|
||||
const result = await runtime.processEvent(event);
|
||||
|
||||
assert.equal(result.status, 'failed');
|
||||
assert.deepEqual(handled, ['healthy']);
|
||||
assert.equal(
|
||||
[...store.checkpoints.values()].some((checkpoint) => checkpoint.status === 'retry_scheduled'),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('unsupported event version is checkpointed as skipped', async () => {
|
||||
const store = new InMemoryProjectionRuntimeStore();
|
||||
const event = { ...activityEvent(), schemaVersion: 99 };
|
||||
const consumer: ProjectionConsumer = {
|
||||
consumerName: 'versioned.consumer',
|
||||
projectionName: 'Versioned',
|
||||
supportedEventTypes: ['activity.completed'],
|
||||
supportedVersions: { 'activity.completed': [1] },
|
||||
async handle() {
|
||||
return { status: 'processed' };
|
||||
}
|
||||
};
|
||||
|
||||
await store.persistEvent(event);
|
||||
const runtime = new ProjectionConsumerRuntime({ store, consumers: [consumer] });
|
||||
const result = await runtime.processEvent(event);
|
||||
|
||||
assert.equal(result.status, 'completed');
|
||||
assert.equal(result.consumerResults[0]?.status, 'skipped_unsupported_version');
|
||||
});
|
||||
|
||||
test('non-retryable failure creates dead letter', async () => {
|
||||
const store = new InMemoryProjectionRuntimeStore();
|
||||
const event = activityEvent();
|
||||
const consumer: ProjectionConsumer = {
|
||||
consumerName: 'dead.consumer',
|
||||
projectionName: 'Dead',
|
||||
supportedEventTypes: ['activity.completed'],
|
||||
supportedVersions: { 'activity.completed': [1] },
|
||||
async handle() {
|
||||
throw new ProjectionConsumerError('unsupported projection invariant', {
|
||||
code: 'projection_invariant_failed',
|
||||
retryable: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await store.persistEvent(event);
|
||||
const runtime = new ProjectionConsumerRuntime({ store, consumers: [consumer] });
|
||||
const result = await runtime.processEvent(event);
|
||||
|
||||
assert.equal(result.status, 'dead_letter');
|
||||
assert.equal(store.deadLetters.length, 1);
|
||||
assert.equal(store.deadLetters[0]?.failure.code, 'projection_invariant_failed');
|
||||
});
|
||||
225
src/features/foundation/projections/runtime.ts
Normal file
225
src/features/foundation/projections/runtime.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import type { BusinessEvent } from '../business-events/types.ts';
|
||||
import {
|
||||
DEFAULT_PROJECTION_RETRY_POLICY,
|
||||
sanitizeProjectionFailure,
|
||||
shouldDeadLetter,
|
||||
resolveNextRetryAt,
|
||||
type ProjectionRetryPolicy
|
||||
} from './retry-policy.ts';
|
||||
import type {
|
||||
ProjectionConsumer,
|
||||
ProjectionConsumerRuntimeResult,
|
||||
ProjectionRuntimeResult,
|
||||
ProjectionRuntimeStore
|
||||
} from './types.ts';
|
||||
|
||||
interface ProjectionRuntimeOptions {
|
||||
store: ProjectionRuntimeStore;
|
||||
consumers: ProjectionConsumer[];
|
||||
retryPolicy?: ProjectionRetryPolicy;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export class ProjectionConsumerRuntime {
|
||||
private readonly store: ProjectionRuntimeStore;
|
||||
private readonly consumers: ProjectionConsumer[];
|
||||
private readonly retryPolicy: ProjectionRetryPolicy;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(options: ProjectionRuntimeOptions) {
|
||||
this.store = options.store;
|
||||
this.consumers = options.consumers;
|
||||
this.retryPolicy = options.retryPolicy ?? DEFAULT_PROJECTION_RETRY_POLICY;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
async persistEvent(event: BusinessEvent) {
|
||||
return this.store.persistEvent(event);
|
||||
}
|
||||
|
||||
async processEvent(event: BusinessEvent): Promise<ProjectionRuntimeResult> {
|
||||
await this.store.markDeliveryProcessing(event.eventId, this.now());
|
||||
|
||||
const consumers = this.consumers.filter((consumer) =>
|
||||
consumer.supportedEventTypes.includes(event.eventType)
|
||||
);
|
||||
|
||||
if (consumers.length === 0) {
|
||||
await this.store.markDeliveryCompleted(event.eventId, this.now());
|
||||
return {
|
||||
eventId: event.eventId,
|
||||
eventType: event.eventType,
|
||||
status: 'completed',
|
||||
consumerResults: []
|
||||
};
|
||||
}
|
||||
|
||||
const consumerResults: ProjectionConsumerRuntimeResult[] = [];
|
||||
|
||||
for (const consumer of consumers) {
|
||||
consumerResults.push(await this.processConsumer(event, consumer));
|
||||
}
|
||||
|
||||
const hasDeadLetter = consumerResults.some((result) => result.status === 'dead_letter');
|
||||
const hasRetry = consumerResults.some((result) => result.status === 'retry_scheduled');
|
||||
const hasFailure = consumerResults.some((result) => result.status === 'failed');
|
||||
|
||||
if (hasDeadLetter) {
|
||||
await this.store.markDeliveryDeadLetter(
|
||||
event.eventId,
|
||||
consumerResults.find((result) => result.error)?.error ?? {
|
||||
code: 'projection_dead_letter',
|
||||
message: 'At least one projection consumer reached dead-letter state',
|
||||
retryable: false
|
||||
},
|
||||
this.now()
|
||||
);
|
||||
return { eventId: event.eventId, eventType: event.eventType, status: 'dead_letter', consumerResults };
|
||||
}
|
||||
|
||||
if (hasRetry || hasFailure) {
|
||||
await this.store.markDeliveryFailed(
|
||||
event.eventId,
|
||||
consumerResults.find((result) => result.error)?.error ?? {
|
||||
code: 'projection_retry_scheduled',
|
||||
message: 'At least one projection consumer is scheduled for retry',
|
||||
retryable: true
|
||||
},
|
||||
this.now()
|
||||
);
|
||||
return { eventId: event.eventId, eventType: event.eventType, status: 'failed', consumerResults };
|
||||
}
|
||||
|
||||
await this.store.markDeliveryCompleted(event.eventId, this.now());
|
||||
return { eventId: event.eventId, eventType: event.eventType, status: 'completed', consumerResults };
|
||||
}
|
||||
|
||||
private async processConsumer(
|
||||
event: BusinessEvent,
|
||||
consumer: ProjectionConsumer
|
||||
): Promise<ProjectionConsumerRuntimeResult> {
|
||||
const startedAt = performance.now();
|
||||
const supportedVersions = consumer.supportedVersions[event.eventType] ?? [];
|
||||
|
||||
if (!supportedVersions.includes(event.schemaVersion)) {
|
||||
await this.store.skipUnsupportedVersion({
|
||||
organizationId: event.organizationId,
|
||||
consumerName: consumer.consumerName,
|
||||
projectionName: consumer.projectionName,
|
||||
eventId: event.eventId,
|
||||
eventType: event.eventType,
|
||||
schemaVersion: event.schemaVersion,
|
||||
now: this.now()
|
||||
});
|
||||
|
||||
return {
|
||||
consumerName: consumer.consumerName,
|
||||
projectionName: consumer.projectionName,
|
||||
status: 'skipped_unsupported_version',
|
||||
durationMs: performance.now() - startedAt
|
||||
};
|
||||
}
|
||||
|
||||
const existing = await this.store.getCheckpoint(consumer.consumerName, event.eventId);
|
||||
if (existing?.status === 'completed') {
|
||||
return {
|
||||
consumerName: consumer.consumerName,
|
||||
projectionName: consumer.projectionName,
|
||||
status: 'duplicate_completed',
|
||||
durationMs: performance.now() - startedAt
|
||||
};
|
||||
}
|
||||
|
||||
const checkpoint = await this.store.beginCheckpoint({
|
||||
organizationId: event.organizationId,
|
||||
consumerName: consumer.consumerName,
|
||||
projectionName: consumer.projectionName,
|
||||
eventId: event.eventId,
|
||||
eventType: event.eventType,
|
||||
schemaVersion: event.schemaVersion,
|
||||
now: this.now()
|
||||
});
|
||||
|
||||
try {
|
||||
await consumer.handle(event);
|
||||
const durationMs = performance.now() - startedAt;
|
||||
await this.store.completeCheckpoint({
|
||||
consumerName: consumer.consumerName,
|
||||
eventId: event.eventId,
|
||||
durationMs,
|
||||
now: this.now()
|
||||
});
|
||||
await this.store.recordHealthSnapshot({
|
||||
organizationId: event.organizationId,
|
||||
projectionName: consumer.projectionName,
|
||||
consumerName: consumer.consumerName,
|
||||
status: 'healthy',
|
||||
lastProcessedEventId: event.eventId,
|
||||
lastSuccessfulProcessingAt: this.now().toISOString(),
|
||||
averageProcessingDurationMs: Math.round(durationMs)
|
||||
});
|
||||
|
||||
return {
|
||||
consumerName: consumer.consumerName,
|
||||
projectionName: consumer.projectionName,
|
||||
status: 'completed',
|
||||
durationMs
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = performance.now() - startedAt;
|
||||
const failure = sanitizeProjectionFailure(error);
|
||||
const attemptCount = checkpoint.attemptCount;
|
||||
const deadLetter = shouldDeadLetter(attemptCount, failure, this.retryPolicy);
|
||||
const nextRetryAt = deadLetter
|
||||
? null
|
||||
: resolveNextRetryAt(attemptCount, failure, this.now(), this.retryPolicy);
|
||||
|
||||
await this.store.failCheckpoint({
|
||||
organizationId: event.organizationId,
|
||||
consumerName: consumer.consumerName,
|
||||
projectionName: consumer.projectionName,
|
||||
eventId: event.eventId,
|
||||
eventType: event.eventType,
|
||||
schemaVersion: event.schemaVersion,
|
||||
failure,
|
||||
nextRetryAt,
|
||||
deadLetter,
|
||||
durationMs,
|
||||
now: this.now()
|
||||
});
|
||||
|
||||
if (deadLetter) {
|
||||
await this.store.createDeadLetter({
|
||||
organizationId: event.organizationId,
|
||||
consumerName: consumer.consumerName,
|
||||
projectionName: consumer.projectionName,
|
||||
eventId: event.eventId,
|
||||
eventType: event.eventType,
|
||||
schemaVersion: event.schemaVersion,
|
||||
attemptCount,
|
||||
failure,
|
||||
now: this.now()
|
||||
});
|
||||
}
|
||||
|
||||
await this.store.recordHealthSnapshot({
|
||||
organizationId: event.organizationId,
|
||||
projectionName: consumer.projectionName,
|
||||
consumerName: consumer.consumerName,
|
||||
status: deadLetter ? 'failed' : 'degraded',
|
||||
retryCount: deadLetter ? 0 : 1,
|
||||
deadLetterCount: deadLetter ? 1 : 0
|
||||
});
|
||||
|
||||
return {
|
||||
consumerName: consumer.consumerName,
|
||||
projectionName: consumer.projectionName,
|
||||
status: deadLetter ? 'dead_letter' : 'retry_scheduled',
|
||||
durationMs,
|
||||
retryAt: nextRetryAt?.toISOString(),
|
||||
error: failure
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
296
src/features/foundation/projections/server/store.ts
Normal file
296
src/features/foundation/projections/server/store.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import {
|
||||
businessEventOutbox,
|
||||
projectionConsumerCheckpoints,
|
||||
projectionDeadLetters,
|
||||
projectionHealthSnapshots
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import type { BusinessEvent } from '../../business-events/types.ts';
|
||||
import type {
|
||||
BeginProjectionCheckpointInput,
|
||||
BusinessEventDeliveryRecord,
|
||||
CompleteProjectionCheckpointInput,
|
||||
CreateProjectionDeadLetterInput,
|
||||
FailProjectionCheckpointInput,
|
||||
ProjectionCheckpointRecord,
|
||||
ProjectionRuntimeStore,
|
||||
UnsupportedProjectionCheckpointInput,
|
||||
ProjectionHealthSnapshotInput,
|
||||
ProjectionFailure
|
||||
} from '../types.ts';
|
||||
|
||||
export class DrizzleProjectionRuntimeStore implements ProjectionRuntimeStore {
|
||||
async persistEvent(event: BusinessEvent): Promise<BusinessEventDeliveryRecord> {
|
||||
const existing = await this.getDelivery(event.eventId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(businessEventOutbox)
|
||||
.values({
|
||||
id: event.eventId,
|
||||
organizationId: event.organizationId,
|
||||
eventType: event.eventType,
|
||||
schemaVersion: event.schemaVersion,
|
||||
entityType: event.entityType,
|
||||
entityId: event.entityId,
|
||||
correlationId: event.correlationId,
|
||||
causationId: event.causationId ?? null,
|
||||
eventEnvelope: event,
|
||||
deliveryStatus: 'pending',
|
||||
occurredAt: new Date(event.occurredAt)
|
||||
})
|
||||
.returning();
|
||||
|
||||
return mapDelivery(created);
|
||||
}
|
||||
|
||||
async markDeliveryProcessing(eventId: string, now: Date): Promise<void> {
|
||||
const existing = await this.getDelivery(eventId);
|
||||
await db
|
||||
.update(businessEventOutbox)
|
||||
.set({
|
||||
deliveryStatus: 'processing',
|
||||
attemptCount: (existing?.attemptCount ?? 0) + 1,
|
||||
processingStartedAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(businessEventOutbox.id, eventId));
|
||||
}
|
||||
|
||||
async markDeliveryCompleted(eventId: string, now: Date): Promise<void> {
|
||||
await updateDeliveryStatus(eventId, 'completed', now, { dispatchedAt: now });
|
||||
}
|
||||
|
||||
async markDeliveryFailed(eventId: string, error: ProjectionFailure, now: Date): Promise<void> {
|
||||
await updateDeliveryStatus(eventId, 'failed', now, {
|
||||
failedAt: now,
|
||||
lastErrorCode: error.code,
|
||||
lastErrorMessage: error.message
|
||||
});
|
||||
}
|
||||
|
||||
async markDeliveryDeadLetter(eventId: string, error: ProjectionFailure, now: Date): Promise<void> {
|
||||
await updateDeliveryStatus(eventId, 'dead_letter', now, {
|
||||
deadLetteredAt: now,
|
||||
lastErrorCode: error.code,
|
||||
lastErrorMessage: error.message
|
||||
});
|
||||
}
|
||||
|
||||
async getCheckpoint(
|
||||
consumerName: string,
|
||||
eventId: string
|
||||
): Promise<ProjectionCheckpointRecord | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(projectionConsumerCheckpoints)
|
||||
.where(
|
||||
and(
|
||||
eq(projectionConsumerCheckpoints.consumerName, consumerName),
|
||||
eq(projectionConsumerCheckpoints.eventId, eventId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return row ? mapCheckpoint(row) : null;
|
||||
}
|
||||
|
||||
async beginCheckpoint(input: BeginProjectionCheckpointInput): Promise<ProjectionCheckpointRecord> {
|
||||
const existing = await this.getCheckpoint(input.consumerName, input.eventId);
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(projectionConsumerCheckpoints)
|
||||
.set({
|
||||
processingStatus: 'processing',
|
||||
attemptCount: existing.attemptCount + 1,
|
||||
lastAttemptedAt: input.now,
|
||||
updatedAt: input.now
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(projectionConsumerCheckpoints.consumerName, input.consumerName),
|
||||
eq(projectionConsumerCheckpoints.eventId, input.eventId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
return mapCheckpoint(updated);
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(projectionConsumerCheckpoints)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: input.organizationId,
|
||||
consumerName: input.consumerName,
|
||||
projectionName: input.projectionName,
|
||||
eventId: input.eventId,
|
||||
eventType: input.eventType,
|
||||
schemaVersion: input.schemaVersion,
|
||||
processingStatus: 'processing',
|
||||
attemptCount: 1,
|
||||
firstAttemptedAt: input.now,
|
||||
lastAttemptedAt: input.now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return mapCheckpoint(created);
|
||||
}
|
||||
|
||||
async completeCheckpoint(input: CompleteProjectionCheckpointInput): Promise<void> {
|
||||
await db
|
||||
.update(projectionConsumerCheckpoints)
|
||||
.set({
|
||||
processingStatus: 'completed',
|
||||
completedAt: input.now,
|
||||
nextRetryAt: null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
processingDurationMs: Math.round(input.durationMs),
|
||||
updatedAt: input.now
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(projectionConsumerCheckpoints.consumerName, input.consumerName),
|
||||
eq(projectionConsumerCheckpoints.eventId, input.eventId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async failCheckpoint(input: FailProjectionCheckpointInput): Promise<void> {
|
||||
await db
|
||||
.update(projectionConsumerCheckpoints)
|
||||
.set({
|
||||
processingStatus: input.deadLetter ? 'dead_letter' : 'retry_scheduled',
|
||||
nextRetryAt: input.nextRetryAt,
|
||||
errorCode: input.failure.code,
|
||||
errorMessage: input.failure.message,
|
||||
processingDurationMs: Math.round(input.durationMs),
|
||||
updatedAt: input.now
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(projectionConsumerCheckpoints.consumerName, input.consumerName),
|
||||
eq(projectionConsumerCheckpoints.eventId, input.eventId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async skipUnsupportedVersion(input: UnsupportedProjectionCheckpointInput): Promise<void> {
|
||||
const existing = await this.getCheckpoint(input.consumerName, input.eventId);
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.insert(projectionConsumerCheckpoints).values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: input.organizationId,
|
||||
consumerName: input.consumerName,
|
||||
projectionName: input.projectionName,
|
||||
eventId: input.eventId,
|
||||
eventType: input.eventType,
|
||||
schemaVersion: input.schemaVersion,
|
||||
processingStatus: 'skipped_unsupported_version',
|
||||
attemptCount: 0,
|
||||
errorCode: 'unsupported_event_version',
|
||||
errorMessage: `Unsupported schema version ${input.schemaVersion}`
|
||||
});
|
||||
}
|
||||
|
||||
async createDeadLetter(input: CreateProjectionDeadLetterInput): Promise<void> {
|
||||
await db.insert(projectionDeadLetters).values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: input.organizationId,
|
||||
consumerName: input.consumerName,
|
||||
projectionName: input.projectionName,
|
||||
eventId: input.eventId,
|
||||
eventType: input.eventType,
|
||||
schemaVersion: input.schemaVersion,
|
||||
attemptCount: input.attemptCount,
|
||||
errorCode: input.failure.code,
|
||||
errorMessage: input.failure.message,
|
||||
failureMetadata: {
|
||||
retryable: input.failure.retryable
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async recordHealthSnapshot(input: ProjectionHealthSnapshotInput): Promise<void> {
|
||||
await db.insert(projectionHealthSnapshots).values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: input.organizationId,
|
||||
projectionName: input.projectionName,
|
||||
consumerName: input.consumerName,
|
||||
status: input.status,
|
||||
lastProcessedEventId: input.lastProcessedEventId ?? null,
|
||||
lastSuccessfulProcessingAt: input.lastSuccessfulProcessingAt
|
||||
? new Date(input.lastSuccessfulProcessingAt)
|
||||
: null,
|
||||
pendingCount: input.pendingCount ?? 0,
|
||||
retryCount: input.retryCount ?? 0,
|
||||
deadLetterCount: input.deadLetterCount ?? 0,
|
||||
unsupportedVersionCount: input.unsupportedVersionCount ?? 0,
|
||||
processingLagMs: input.processingLagMs ?? 0,
|
||||
averageProcessingDurationMs: input.averageProcessingDurationMs ?? 0,
|
||||
lastRebuildAt: input.lastRebuildAt ? new Date(input.lastRebuildAt) : null,
|
||||
rebuildSource: input.rebuildSource ?? null
|
||||
});
|
||||
}
|
||||
|
||||
private async getDelivery(eventId: string): Promise<BusinessEventDeliveryRecord | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(businessEventOutbox)
|
||||
.where(eq(businessEventOutbox.id, eventId))
|
||||
.limit(1);
|
||||
|
||||
return row ? mapDelivery(row) : null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDeliveryStatus(
|
||||
eventId: string,
|
||||
deliveryStatus: 'completed' | 'failed' | 'dead_letter',
|
||||
now: Date,
|
||||
values: Partial<typeof businessEventOutbox.$inferInsert>
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(businessEventOutbox)
|
||||
.set({ ...values, deliveryStatus, updatedAt: now })
|
||||
.where(eq(businessEventOutbox.id, eventId));
|
||||
}
|
||||
|
||||
function mapDelivery(row: typeof businessEventOutbox.$inferSelect): BusinessEventDeliveryRecord {
|
||||
return {
|
||||
eventId: row.id,
|
||||
organizationId: row.organizationId,
|
||||
eventType: row.eventType,
|
||||
schemaVersion: row.schemaVersion,
|
||||
status: row.deliveryStatus as BusinessEventDeliveryRecord['status'],
|
||||
attemptCount: row.attemptCount,
|
||||
availableAt: row.availableAt.toISOString(),
|
||||
event: row.eventEnvelope as BusinessEvent
|
||||
};
|
||||
}
|
||||
|
||||
function mapCheckpoint(
|
||||
row: typeof projectionConsumerCheckpoints.$inferSelect
|
||||
): ProjectionCheckpointRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
consumerName: row.consumerName,
|
||||
projectionName: row.projectionName,
|
||||
eventId: row.eventId,
|
||||
eventType: row.eventType,
|
||||
schemaVersion: row.schemaVersion,
|
||||
status: row.processingStatus as ProjectionCheckpointRecord['status'],
|
||||
attemptCount: row.attemptCount,
|
||||
nextRetryAt: row.nextRetryAt?.toISOString() ?? null,
|
||||
errorCode: row.errorCode,
|
||||
errorMessage: row.errorMessage
|
||||
};
|
||||
}
|
||||
214
src/features/foundation/projections/types.ts
Normal file
214
src/features/foundation/projections/types.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import type { BusinessEvent } from '../business-events/types.ts';
|
||||
|
||||
export type BusinessEventDeliveryStatus =
|
||||
| 'pending'
|
||||
| 'processing'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'dead_letter';
|
||||
|
||||
export type ProjectionCheckpointStatus =
|
||||
| 'pending'
|
||||
| 'processing'
|
||||
| 'completed'
|
||||
| 'retry_scheduled'
|
||||
| 'failed'
|
||||
| 'dead_letter'
|
||||
| 'skipped_unsupported_version';
|
||||
|
||||
export type ProjectionHealthStatus =
|
||||
| 'healthy'
|
||||
| 'lagging'
|
||||
| 'degraded'
|
||||
| 'failed'
|
||||
| 'rebuilding'
|
||||
| 'paused';
|
||||
|
||||
export type ProjectionRebuildStrategy = 'source-query' | 'event-replay' | 'hybrid';
|
||||
export type ProjectionPersistenceStrategy = 'query-time' | 'cached-read-model' | 'materialized';
|
||||
export type ProjectionImplementationStatus = 'contract-only' | 'active' | 'deprecated';
|
||||
export type ProjectionHandleStatus = 'processed' | 'skipped';
|
||||
|
||||
export interface ProjectionHandleResult {
|
||||
status: ProjectionHandleStatus;
|
||||
message?: string;
|
||||
sideEffectCount?: number;
|
||||
}
|
||||
|
||||
export interface ProjectionConsumer<TEvent extends BusinessEvent = BusinessEvent> {
|
||||
readonly consumerName: string;
|
||||
readonly projectionName: string;
|
||||
readonly supportedEventTypes: string[];
|
||||
readonly supportedVersions: Record<string, number[]>;
|
||||
handle(event: TEvent): Promise<ProjectionHandleResult>;
|
||||
}
|
||||
|
||||
export interface ProjectionDefinition {
|
||||
projectionName: string;
|
||||
consumerName: string;
|
||||
owningFeature: string;
|
||||
consumedEventTypes: string[];
|
||||
supportedVersions: Record<string, number[]>;
|
||||
rebuildStrategy: ProjectionRebuildStrategy;
|
||||
persistence: ProjectionPersistenceStrategy;
|
||||
securityClassification: string;
|
||||
idempotencyKey: string;
|
||||
retryPolicy: string;
|
||||
retentionPolicy: string;
|
||||
implementationStatus: ProjectionImplementationStatus;
|
||||
}
|
||||
|
||||
export interface BusinessEventDeliveryRecord {
|
||||
eventId: string;
|
||||
organizationId: string;
|
||||
eventType: string;
|
||||
schemaVersion: number;
|
||||
status: BusinessEventDeliveryStatus;
|
||||
attemptCount: number;
|
||||
availableAt: string;
|
||||
event: BusinessEvent;
|
||||
}
|
||||
|
||||
export interface ProjectionCheckpointRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
consumerName: string;
|
||||
projectionName: string;
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
schemaVersion: number;
|
||||
status: ProjectionCheckpointStatus;
|
||||
attemptCount: number;
|
||||
nextRetryAt?: string | null;
|
||||
errorCode?: string | null;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectionRuntimeStore {
|
||||
persistEvent(event: BusinessEvent): Promise<BusinessEventDeliveryRecord>;
|
||||
markDeliveryProcessing(eventId: string, now: Date): Promise<void>;
|
||||
markDeliveryCompleted(eventId: string, now: Date): Promise<void>;
|
||||
markDeliveryFailed(eventId: string, error: ProjectionFailure, now: Date): Promise<void>;
|
||||
markDeliveryDeadLetter(eventId: string, error: ProjectionFailure, now: Date): Promise<void>;
|
||||
getCheckpoint(consumerName: string, eventId: string): Promise<ProjectionCheckpointRecord | null>;
|
||||
beginCheckpoint(input: BeginProjectionCheckpointInput): Promise<ProjectionCheckpointRecord>;
|
||||
completeCheckpoint(input: CompleteProjectionCheckpointInput): Promise<void>;
|
||||
failCheckpoint(input: FailProjectionCheckpointInput): Promise<void>;
|
||||
skipUnsupportedVersion(input: UnsupportedProjectionCheckpointInput): Promise<void>;
|
||||
createDeadLetter(input: CreateProjectionDeadLetterInput): Promise<void>;
|
||||
recordHealthSnapshot(input: ProjectionHealthSnapshotInput): Promise<void>;
|
||||
}
|
||||
|
||||
export interface BeginProjectionCheckpointInput {
|
||||
organizationId: string;
|
||||
consumerName: string;
|
||||
projectionName: string;
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
schemaVersion: number;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface CompleteProjectionCheckpointInput {
|
||||
consumerName: string;
|
||||
eventId: string;
|
||||
durationMs: number;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface FailProjectionCheckpointInput {
|
||||
organizationId: string;
|
||||
consumerName: string;
|
||||
projectionName: string;
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
schemaVersion: number;
|
||||
failure: ProjectionFailure;
|
||||
nextRetryAt: Date | null;
|
||||
deadLetter: boolean;
|
||||
durationMs: number;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface UnsupportedProjectionCheckpointInput {
|
||||
organizationId: string;
|
||||
consumerName: string;
|
||||
projectionName: string;
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
schemaVersion: number;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface ProjectionFailure {
|
||||
code: string;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
export interface CreateProjectionDeadLetterInput {
|
||||
organizationId: string;
|
||||
consumerName: string;
|
||||
projectionName: string;
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
schemaVersion: number;
|
||||
attemptCount: number;
|
||||
failure: ProjectionFailure;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface ProjectionHealthSnapshotInput {
|
||||
organizationId: string;
|
||||
projectionName: string;
|
||||
consumerName: string;
|
||||
status: ProjectionHealthStatus;
|
||||
lastProcessedEventId?: string | null;
|
||||
lastSuccessfulProcessingAt?: string | null;
|
||||
pendingCount?: number;
|
||||
retryCount?: number;
|
||||
deadLetterCount?: number;
|
||||
unsupportedVersionCount?: number;
|
||||
processingLagMs?: number;
|
||||
averageProcessingDurationMs?: number;
|
||||
lastRebuildAt?: string | null;
|
||||
rebuildSource?: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectionRuntimeResult {
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
status: BusinessEventDeliveryStatus;
|
||||
consumerResults: ProjectionConsumerRuntimeResult[];
|
||||
}
|
||||
|
||||
export interface ProjectionConsumerRuntimeResult {
|
||||
consumerName: string;
|
||||
projectionName: string;
|
||||
status: ProjectionCheckpointStatus | 'duplicate_completed';
|
||||
durationMs: number;
|
||||
retryAt?: string;
|
||||
error?: ProjectionFailure;
|
||||
}
|
||||
|
||||
export interface ProjectionRebuildRequest {
|
||||
projectionName: string;
|
||||
organizationId: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
occurredFrom?: string;
|
||||
occurredTo?: string;
|
||||
dryRun?: boolean;
|
||||
resetExisting?: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectionRebuildResult {
|
||||
rebuildRunId: string;
|
||||
projectionName: string;
|
||||
organizationId: string;
|
||||
dryRun: boolean;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
processedCount: number;
|
||||
skippedCount: number;
|
||||
failedCount: number;
|
||||
}
|
||||
Reference in New Issue
Block a user