task ep.1.4

This commit is contained in:
phaichayon
2026-07-13 12:48:33 +07:00
parent d78c56148e
commit 06af36156f
18 changed files with 10513 additions and 0 deletions

View File

@@ -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',
{

View 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
];

View 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';

View 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;

View 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}`;
}

View 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;
}
}

View 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;

View 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;
}

View 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);
}

View 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');
});

View 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
};
}
}
}

View 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
};
}

View 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;
}