task ep.1.4.1

This commit is contained in:
phaichayon
2026-07-13 13:08:00 +07:00
parent 06af36156f
commit bba4c8d97d
18 changed files with 9388 additions and 192 deletions

View File

@@ -467,6 +467,9 @@ export const businessEventOutbox = pgTable(
deliveryStatus: text('delivery_status').default('pending').notNull(),
attemptCount: integer('attempt_count').default(0).notNull(),
availableAt: timestamp('available_at', { withTimezone: true }).defaultNow().notNull(),
claimedBy: text('claimed_by'),
claimedAt: timestamp('claimed_at', { withTimezone: true }),
claimExpiresAt: timestamp('claim_expires_at', { withTimezone: true }),
processingStartedAt: timestamp('processing_started_at', { withTimezone: true }),
dispatchedAt: timestamp('dispatched_at', { withTimezone: true }),
failedAt: timestamp('failed_at', { withTimezone: true }),
@@ -482,6 +485,10 @@ export const businessEventOutbox = pgTable(
table.deliveryStatus,
table.availableAt
),
claimLeaseIdx: index('business_event_outbox_claim_lease_idx').on(
table.deliveryStatus,
table.claimExpiresAt
),
organizationEventIdx: index('business_event_outbox_org_event_idx').on(
table.organizationId,
table.eventType

View File

@@ -1,9 +1,10 @@
import {
createBusinessEvent,
publishBusinessEvent,
type BusinessEvent
createBusinessEvent,
publishBusinessEvent,
type BusinessEvent
} from '../../../foundation/business-events/index.ts';
import type { ActivityRecord } from '../api/types.ts';
import type { ActivityRow } from './repository';
export type ActivityBusinessEventType =
| 'activity.created'
@@ -16,13 +17,90 @@ export type ActivityBusinessEventType =
| 'activity.deleted';
interface PublishActivityBusinessEventInput {
eventType: ActivityBusinessEventType;
activity: ActivityRecord;
eventType: ActivityBusinessEventType;
activity: ActivityRecord;
actorUserId: string;
correlationId?: string;
causationId?: string | null;
payload?: Record<string, unknown>;
previousActivity?: ActivityRecord | null;
previousActivity?: ActivityRecord | null;
}
interface EnqueueActivityBusinessEventInput extends PublishActivityBusinessEventInput {
client: Parameters<
import('../../../foundation/projections/server/transactional-publisher.ts').TransactionalBusinessEventPublisher['enqueue']
>[0];
}
function toIso(value: Date | null): string | null {
return value?.toISOString() ?? null;
}
function toMetadata(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export function activityRowToBusinessEventRecord(row: ActivityRow): ActivityRecord {
const metadata = toMetadata(row.metadata);
const scheduledStartAt = toIso(row.scheduledStartAt);
const dueAt = toIso(row.dueAt);
const completedAt = toIso(row.completedAt);
const cancelledAt = toIso(row.cancelledAt);
const skippedAt = toIso(row.skippedAt);
return {
id: row.id,
organizationId: row.organizationId,
primaryEntityType: row.primaryEntityType as ActivityRecord['primaryEntityType'],
primaryEntityId: row.primaryEntityId,
primaryRecordLabel: row.primaryRecordLabel,
relatedRecords: Array.isArray(row.relatedRecords)
? (row.relatedRecords as ActivityRecord['relatedRecords'])
: [],
customerId: row.customerId,
contactId: row.contactId,
leadId: row.leadId,
opportunityId: row.opportunityId,
quotationId: row.quotationId,
activityType: row.activityType as ActivityRecord['activityType'],
subject: row.subject,
description: row.description,
ownerId: row.ownerId,
assignedToId: row.assignedToId,
scheduledStartAt,
scheduledEndAt: toIso(row.scheduledEndAt),
dueAt,
completedAt,
cancelledAt,
skippedAt,
status: row.status as ActivityRecord['status'],
priority: row.priority as ActivityRecord['priority'],
outcomeSummary: row.outcomeSummary,
cancellationReason: row.cancellationReason,
skipReason: row.skipReason,
nextAction: row.nextAction,
branchId: row.branchId,
productType: row.productType,
isInternalOnly: row.isInternalOnly,
isPricingSensitive: row.isPricingSensitive,
parentActivityId: row.parentActivityId,
metadata,
createdBy: row.createdBy,
updatedBy: row.updatedBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: toIso(row.deletedAt),
activityCode: typeof metadata?.activityCode === 'string' ? metadata.activityCode : null,
effectiveStatus: row.status as ActivityRecord['effectiveStatus'],
ownerName: null,
assignedToName: null,
createdByName: null,
updatedByName: null,
canViewPricingSensitiveContent: false,
isContentRedacted: row.isPricingSensitive || row.isInternalOnly
};
}
function compactRelatedRecords(activity: ActivityRecord) {
@@ -112,3 +190,15 @@ export async function publishActivityBusinessEvent(
return event;
}
export async function enqueueActivityBusinessEvent(
input: EnqueueActivityBusinessEventInput
): Promise<BusinessEvent> {
const { transactionalBusinessEventPublisher } = await import(
'../../../foundation/projections/server/transactional-publisher.ts'
);
const event = createActivityBusinessEvent(input);
await transactionalBusinessEventPublisher.enqueue(input.client, event);
return event;
}

View File

@@ -5,6 +5,7 @@ import type { CrmActivityEntityType } from '@/features/crm/activity/types';
export type ActivityRow = typeof crmActivities.$inferSelect;
export type InsertActivityRow = typeof crmActivities.$inferInsert;
type ActivityDbClient = Pick<typeof db, 'insert' | 'update' | 'select'>;
export interface ActivityListQuery {
organizationId: string;
@@ -24,8 +25,11 @@ export interface ActivityContextQuery {
limit?: number;
}
export async function insertActivity(values: InsertActivityRow): Promise<ActivityRow> {
const [created] = await db.insert(crmActivities).values(values).returning();
export async function insertActivity(
values: InsertActivityRow,
client: ActivityDbClient = db
): Promise<ActivityRow> {
const [created] = await client.insert(crmActivities).values(values).returning();
return created;
}
@@ -33,9 +37,10 @@ export async function insertActivity(values: InsertActivityRow): Promise<Activit
export async function updateActivityRow(
id: string,
organizationId: string,
values: Partial<InsertActivityRow>
values: Partial<InsertActivityRow>,
client: ActivityDbClient = db
): Promise<ActivityRow> {
const [updated] = await db
const [updated] = await client
.update(crmActivities)
.set(values)
.where(and(eq(crmActivities.id, id), eq(crmActivities.organizationId, organizationId)))

View File

@@ -46,7 +46,10 @@ import type {
} from '../api/types';
import { getActivityRow, insertActivity, listActivityRows, updateActivityRow } from './repository';
import { hydrateActivityRecords } from './read-model';
import { publishActivityBusinessEvent } from './business-events';
import {
activityRowToBusinessEventRecord,
enqueueActivityBusinessEvent
} from './business-events';
type ActivityAccessContext = CrmSecurityContext;
@@ -608,63 +611,73 @@ export async function createActivity(
}
};
const created = await insertActivity({
id: crypto.randomUUID(),
organizationId,
primaryEntityType: values.primaryEntityType,
primaryEntityId: values.primaryEntityId,
primaryRecordLabel: values.primaryRecordLabel,
relatedRecords: values.relatedRecords,
customerId: values.customerId,
contactId: values.contactId,
leadId: values.leadId,
opportunityId: values.opportunityId,
quotationId: values.quotationId,
activityType: values.activityType,
subject: values.subject,
description: values.description,
ownerId: values.ownerId,
assignedToId: values.assignedToId,
scheduledStartAt: values.scheduledStartAt,
scheduledEndAt: values.scheduledEndAt,
dueAt: values.dueAt,
completedAt: values.status === 'completed' ? new Date() : null,
cancelledAt: values.status === 'cancelled' ? new Date() : null,
skippedAt: values.status === 'skipped' ? new Date() : null,
status: values.status,
priority: values.priority,
outcomeSummary: null,
cancellationReason: null,
skipReason: null,
nextAction: values.nextAction,
branchId: values.branchId,
productType: values.productType,
isInternalOnly: values.isInternalOnly,
isPricingSensitive: values.isPricingSensitive,
parentActivityId: null,
metadata,
createdBy: userId,
updatedBy: userId
const created = await db.transaction(async (tx) => {
const createdRow = await insertActivity(
{
id: crypto.randomUUID(),
organizationId,
primaryEntityType: values.primaryEntityType,
primaryEntityId: values.primaryEntityId,
primaryRecordLabel: values.primaryRecordLabel,
relatedRecords: values.relatedRecords,
customerId: values.customerId,
contactId: values.contactId,
leadId: values.leadId,
opportunityId: values.opportunityId,
quotationId: values.quotationId,
activityType: values.activityType,
subject: values.subject,
description: values.description,
ownerId: values.ownerId,
assignedToId: values.assignedToId,
scheduledStartAt: values.scheduledStartAt,
scheduledEndAt: values.scheduledEndAt,
dueAt: values.dueAt,
completedAt: values.status === 'completed' ? new Date() : null,
cancelledAt: values.status === 'cancelled' ? new Date() : null,
skippedAt: values.status === 'skipped' ? new Date() : null,
status: values.status,
priority: values.priority,
outcomeSummary: null,
cancellationReason: null,
skipReason: null,
nextAction: values.nextAction,
branchId: values.branchId,
productType: values.productType,
isInternalOnly: values.isInternalOnly,
isPricingSensitive: values.isPricingSensitive,
parentActivityId: null,
metadata,
createdBy: userId,
updatedBy: userId
},
tx
);
const eventActivity = activityRowToBusinessEventRecord(createdRow);
const correlationId = crypto.randomUUID();
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.created',
activity: eventActivity,
actorUserId: userId,
correlationId
});
if (eventActivity.assignedToId && eventActivity.assignedToId !== eventActivity.ownerId) {
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.assigned',
activity: eventActivity,
actorUserId: userId,
correlationId
});
}
return createdRow;
});
const activity = await getActivityById(created.id, organizationId, context);
const correlationId = crypto.randomUUID();
await publishActivityBusinessEvent({
eventType: 'activity.created',
activity,
actorUserId: userId,
correlationId
});
if (activity.assignedToId && activity.assignedToId !== activity.ownerId) {
await publishActivityBusinessEvent({
eventType: 'activity.assigned',
activity,
actorUserId: userId,
correlationId
});
}
return activity;
}
@@ -688,8 +701,9 @@ export async function updateActivity(
await assertActivityTypeValue(organizationId, values.activityType);
const nextStatus = values.status;
await updateActivityRow(id, organizationId, {
primaryEntityType: values.primaryEntityType,
const activityEventRecord = await db.transaction(async (tx) => {
const updatedRow = await updateActivityRow(id, organizationId, {
primaryEntityType: values.primaryEntityType,
primaryEntityId: values.primaryEntityId,
primaryRecordLabel: values.primaryRecordLabel,
relatedRecords: values.relatedRecords,
@@ -727,66 +741,76 @@ export async function updateActivity(
completedAt: nextStatus === 'completed' ? new Date() : null,
cancelledAt: nextStatus === 'cancelled' ? new Date() : null,
skippedAt: nextStatus === 'skipped' ? new Date() : null,
updatedAt: new Date(),
updatedBy: userId
});
updatedAt: new Date(),
updatedBy: userId
}, tx);
const activity = await getActivityById(id, organizationId, context);
const correlationId = crypto.randomUUID();
const nextActivity = activityRowToBusinessEventRecord(updatedRow);
const correlationId = crypto.randomUUID();
if (current.assignedToId !== activity.assignedToId) {
await publishActivityBusinessEvent({
eventType: current.assignedToId ? 'activity.reassigned' : 'activity.assigned',
activity,
previousActivity: current,
actorUserId: userId,
correlationId
if (current.assignedToId !== nextActivity.assignedToId) {
await enqueueActivityBusinessEvent({
client: tx,
eventType: current.assignedToId ? 'activity.reassigned' : 'activity.assigned',
activity: nextActivity,
previousActivity: current,
actorUserId: userId,
correlationId
});
}
if (
current.scheduledStartAt !== activity.scheduledStartAt ||
current.scheduledEndAt !== activity.scheduledEndAt ||
current.dueAt !== activity.dueAt
) {
await publishActivityBusinessEvent({
eventType: 'activity.rescheduled',
activity,
previousActivity: current,
actorUserId: userId,
correlationId
current.scheduledStartAt !== nextActivity.scheduledStartAt ||
current.scheduledEndAt !== nextActivity.scheduledEndAt ||
current.dueAt !== nextActivity.dueAt
) {
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.rescheduled',
activity: nextActivity,
previousActivity: current,
actorUserId: userId,
correlationId
});
}
if (current.status !== 'in_progress' && activity.status === 'in_progress') {
await publishActivityBusinessEvent({
eventType: 'activity.started',
activity,
previousActivity: current,
actorUserId: userId,
correlationId
if (current.status !== 'in_progress' && nextActivity.status === 'in_progress') {
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.started',
activity: nextActivity,
previousActivity: current,
actorUserId: userId,
correlationId
});
}
if (current.status !== 'completed' && activity.status === 'completed') {
await publishActivityBusinessEvent({
eventType: 'activity.completed',
activity,
previousActivity: current,
actorUserId: userId,
correlationId
if (current.status !== 'completed' && nextActivity.status === 'completed') {
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.completed',
activity: nextActivity,
previousActivity: current,
actorUserId: userId,
correlationId
});
}
if (current.status !== 'cancelled' && activity.status === 'cancelled') {
await publishActivityBusinessEvent({
eventType: 'activity.cancelled',
activity,
previousActivity: current,
actorUserId: userId,
correlationId
});
}
if (current.status !== 'cancelled' && nextActivity.status === 'cancelled') {
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.cancelled',
activity: nextActivity,
previousActivity: current,
actorUserId: userId,
correlationId
});
}
return nextActivity;
});
const activity = await getActivityById(activityEventRecord.id, organizationId, context);
return activity;
}
@@ -802,24 +826,28 @@ export async function completeActivity(
const current = await getHydratedActivityOrThrow(id, organizationId, context);
assertTransitionAllowed(current.status, 'completed');
await updateActivityRow(id, organizationId, {
status: 'completed',
outcomeSummary: payload.outcomeSummary.trim(),
nextAction: payload.nextAction?.trim() || null,
completedAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
});
const completedEventRecord = await db.transaction(async (tx) => {
const updatedRow = await updateActivityRow(id, organizationId, {
status: 'completed',
outcomeSummary: payload.outcomeSummary.trim(),
nextAction: payload.nextAction?.trim() || null,
completedAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
}, tx);
const nextActivity = activityRowToBusinessEventRecord(updatedRow);
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.completed',
activity: nextActivity,
previousActivity: current,
actorUserId: userId
});
return nextActivity;
});
const activity = await getActivityById(id, organizationId, context);
await publishActivityBusinessEvent({
eventType: 'activity.completed',
activity,
previousActivity: current,
actorUserId: userId
});
return activity;
const activity = await getActivityById(completedEventRecord.id, organizationId, context);
return activity;
}
export async function cancelActivity(
@@ -833,23 +861,27 @@ export async function cancelActivity(
const current = await getHydratedActivityOrThrow(id, organizationId, context);
assertTransitionAllowed(current.status, 'cancelled');
await updateActivityRow(id, organizationId, {
status: 'cancelled',
cancellationReason: payload.cancellationReason.trim(),
cancelledAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
});
const cancelledEventRecord = await db.transaction(async (tx) => {
const updatedRow = await updateActivityRow(id, organizationId, {
status: 'cancelled',
cancellationReason: payload.cancellationReason.trim(),
cancelledAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
}, tx);
const nextActivity = activityRowToBusinessEventRecord(updatedRow);
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.cancelled',
activity: nextActivity,
previousActivity: current,
actorUserId: userId
});
return nextActivity;
});
const activity = await getActivityById(id, organizationId, context);
await publishActivityBusinessEvent({
eventType: 'activity.cancelled',
activity,
previousActivity: current,
actorUserId: userId
});
return activity;
const activity = await getActivityById(cancelledEventRecord.id, organizationId, context);
return activity;
}
export async function assignActivity(
@@ -866,23 +898,27 @@ export async function assignActivity(
await assertMembershipUserBelongsToOrganization(payload.assignedToId, organizationId);
}
await updateActivityRow(id, organizationId, {
assignedToId: payload.assignedToId,
updatedAt: new Date(),
updatedBy: userId
});
const assignedEventRecord = await db.transaction(async (tx) => {
const updatedRow = await updateActivityRow(id, organizationId, {
assignedToId: payload.assignedToId,
updatedAt: new Date(),
updatedBy: userId
}, tx);
const nextActivity = activityRowToBusinessEventRecord(updatedRow);
if (current.assignedToId !== nextActivity.assignedToId) {
await enqueueActivityBusinessEvent({
client: tx,
eventType: current.assignedToId ? 'activity.reassigned' : 'activity.assigned',
activity: nextActivity,
previousActivity: current,
actorUserId: userId
});
}
return nextActivity;
});
const activity = await getActivityById(id, organizationId, context);
if (current.assignedToId !== activity.assignedToId) {
await publishActivityBusinessEvent({
eventType: current.assignedToId ? 'activity.reassigned' : 'activity.assigned',
activity,
previousActivity: current,
actorUserId: userId
});
}
return activity;
const activity = await getActivityById(assignedEventRecord.id, organizationId, context);
return activity;
}
export async function rescheduleActivity(
@@ -902,9 +938,10 @@ export async function rescheduleActivity(
const nextStatus = current.status === 'draft' ? 'scheduled' : current.status;
assertTransitionAllowed(current.status, nextStatus);
await updateActivityRow(id, organizationId, {
scheduledStartAt:
payload.scheduledStartAt === undefined
const rescheduledEventRecord = await db.transaction(async (tx) => {
const updatedRow = await updateActivityRow(id, organizationId, {
scheduledStartAt:
payload.scheduledStartAt === undefined
? toDateOrNull(current.scheduledStartAt)
: toDateOrNull(payload.scheduledStartAt),
scheduledEndAt:
@@ -912,20 +949,23 @@ export async function rescheduleActivity(
? toDateOrNull(current.scheduledEndAt)
: toDateOrNull(payload.scheduledEndAt),
dueAt: payload.dueAt === undefined ? toDateOrNull(current.dueAt) : toDateOrNull(payload.dueAt),
status: nextStatus,
updatedAt: new Date(),
updatedBy: userId
});
status: nextStatus,
updatedAt: new Date(),
updatedBy: userId
}, tx);
const nextActivity = activityRowToBusinessEventRecord(updatedRow);
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.rescheduled',
activity: nextActivity,
previousActivity: current,
actorUserId: userId
});
return nextActivity;
});
const activity = await getActivityById(id, organizationId, context);
await publishActivityBusinessEvent({
eventType: 'activity.rescheduled',
activity,
previousActivity: current,
actorUserId: userId
});
return activity;
const activity = await getActivityById(rescheduledEventRecord.id, organizationId, context);
return activity;
}
export async function deleteActivity(
@@ -937,15 +977,18 @@ export async function deleteActivity(
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.delete);
const current = await getHydratedActivityOrThrow(id, organizationId, context);
await updateActivityRow(id, organizationId, {
deletedAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
});
await db.transaction(async (tx) => {
await updateActivityRow(id, organizationId, {
deletedAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
}, tx);
await publishActivityBusinessEvent({
eventType: 'activity.deleted',
activity: current,
actorUserId: userId
});
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.deleted',
activity: current,
actorUserId: userId
});
});
}

View File

@@ -1,9 +1,11 @@
export * from './consumers.ts';
export * from './matrices.ts';
export * from './memory-store.ts';
export * from './operations.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';
export * from './worker.ts';

View File

@@ -2,6 +2,7 @@ import type { BusinessEvent } from '../business-events/types.ts';
import type {
BeginProjectionCheckpointInput,
BusinessEventDeliveryRecord,
ClaimBusinessEventDeliveriesInput,
CompleteProjectionCheckpointInput,
CreateProjectionDeadLetterInput,
FailProjectionCheckpointInput,
@@ -31,12 +32,47 @@ export class InMemoryProjectionRuntimeStore implements ProjectionRuntimeStore {
status: 'pending',
attemptCount: 0,
availableAt: new Date().toISOString(),
claimedBy: null,
claimedAt: null,
claimExpiresAt: null,
event
};
this.deliveries.set(event.eventId, record);
return record;
}
async claimAvailableEvents(
input: ClaimBusinessEventDeliveriesInput
): Promise<BusinessEventDeliveryRecord[]> {
const claimable = [...this.deliveries.values()]
.filter((delivery) => {
if (delivery.status === 'pending' || delivery.status === 'retry_scheduled') {
return new Date(delivery.availableAt).getTime() <= input.now.getTime();
}
if (delivery.status === 'claimed' || delivery.status === 'processing') {
return (
delivery.claimExpiresAt !== null &&
delivery.claimExpiresAt !== undefined &&
new Date(delivery.claimExpiresAt).getTime() <= input.now.getTime()
);
}
return false;
})
.toSorted((a, b) => a.availableAt.localeCompare(b.availableAt))
.slice(0, input.batchSize);
for (const delivery of claimable) {
delivery.status = 'claimed';
delivery.claimedBy = input.workerId;
delivery.claimedAt = input.now.toISOString();
delivery.claimExpiresAt = input.leaseExpiresAt.toISOString();
}
return claimable;
}
async markDeliveryProcessing(eventId: string): Promise<void> {
const delivery = this.requireDelivery(eventId);
delivery.status = 'processing';

View File

@@ -0,0 +1,101 @@
import { ProjectionConsumerRuntime } from './runtime.ts';
import { ProjectionOutboxWorker, type ProjectionWorkerDrainResult } from './worker.ts';
import type { BusinessEvent } from '../business-events/types.ts';
import type { ProjectionConsumer, ProjectionRuntimeStore } from './types.ts';
export interface ProjectionManualActor {
userId: string;
organizationId: string;
systemRole?: string;
}
export interface ProjectionManualRetryResult {
status: 'accepted' | 'completed';
eventId?: string;
consumerName?: string;
message: string;
}
export class ProjectionOperationsService {
private readonly store: ProjectionRuntimeStore;
private readonly consumers: ProjectionConsumer[];
constructor(options: { store: ProjectionRuntimeStore; consumers: ProjectionConsumer[] }) {
this.store = options.store;
this.consumers = options.consumers;
}
async drainOutbox(worker: ProjectionOutboxWorker): Promise<ProjectionWorkerDrainResult> {
return worker.drainOnce();
}
async retryEvent(event: BusinessEvent, actor: ProjectionManualActor): Promise<ProjectionManualRetryResult> {
assertProjectionOperator(actor);
const runtime = new ProjectionConsumerRuntime({
store: this.store,
consumers: this.consumers
});
await runtime.processEvent(event);
return {
status: 'completed',
eventId: event.eventId,
message: 'Retry completed for incomplete projection consumers'
};
}
async retryConsumer(
event: BusinessEvent,
consumerName: string,
actor: ProjectionManualActor
): Promise<ProjectionManualRetryResult> {
assertProjectionOperator(actor);
const consumer = this.consumers.find((item) => item.consumerName === consumerName);
if (!consumer) {
throw new Error(`Unknown projection consumer: ${consumerName}`);
}
const runtime = new ProjectionConsumerRuntime({
store: this.store,
consumers: [consumer]
});
await runtime.processEvent(event);
return {
status: 'completed',
eventId: event.eventId,
consumerName,
message: 'Retry completed for selected projection consumer'
};
}
async retryDeadLetter(deadLetterId: string, actor: ProjectionManualActor): Promise<ProjectionManualRetryResult> {
assertProjectionOperator(actor);
return {
status: 'accepted',
message: `Dead-letter retry accepted for ${deadLetterId}`
};
}
async resolveDeadLetter(
deadLetterId: string,
reason: string,
actor: ProjectionManualActor
): Promise<ProjectionManualRetryResult> {
assertProjectionOperator(actor);
if (!reason.trim()) {
throw new Error('Dead-letter resolution reason is required');
}
return {
status: 'accepted',
message: `Dead-letter ${deadLetterId} resolved`
};
}
}
function assertProjectionOperator(actor: ProjectionManualActor): void {
if (actor.systemRole !== 'super_admin') {
throw new Error('Projection operation requires system administration access');
}
}

View File

@@ -8,6 +8,7 @@ import { ProjectionOutboxBusinessEventPublisher } from './publisher.ts';
import { ProjectionConsumerError } from './retry-policy.ts';
import { ProjectionConsumerRuntime } from './runtime.ts';
import type { ProjectionConsumer } from './types.ts';
import { ProjectionOutboxWorker } from './worker.ts';
function activityEvent(overrides: Record<string, unknown> = {}) {
return createBusinessEvent({
@@ -135,7 +136,7 @@ test('consumer failure schedules retry without throwing or blocking unrelated co
const runtime = new ProjectionConsumerRuntime({ store, consumers: [failing, healthy] });
const result = await runtime.processEvent(event);
assert.equal(result.status, 'failed');
assert.equal(result.status, 'retry_scheduled');
assert.deepEqual(handled, ['healthy']);
assert.equal(
[...store.checkpoints.values()].some((checkpoint) => checkpoint.status === 'retry_scheduled'),
@@ -188,3 +189,57 @@ test('non-retryable failure creates dead letter', async () => {
assert.equal(store.deadLetters.length, 1);
assert.equal(store.deadLetters[0]?.failure.code, 'projection_invariant_failed');
});
test('worker claims available events and drains through runtime', async () => {
const store = new InMemoryProjectionRuntimeStore();
const event = activityEvent();
await store.persistEvent(event);
const worker = new ProjectionOutboxWorker({
store,
consumers: INITIAL_PROJECTION_CONSUMERS,
config: {
enabled: false,
workerId: 'worker-a',
batchSize: 10,
claimLeaseMs: 60_000
}
});
const result = await worker.drainOnce();
assert.equal(result.claimedCount, 1);
assert.equal(result.processedCount, 1);
assert.equal(store.deliveries.get(event.eventId)?.claimedBy, 'worker-a');
assert.equal(store.deliveries.get(event.eventId)?.status, 'completed');
});
test('worker claim honors active lease and recovers stale lease', async () => {
const store = new InMemoryProjectionRuntimeStore();
const event = activityEvent();
await store.persistEvent(event);
const firstClaim = await store.claimAvailableEvents({
workerId: 'worker-a',
batchSize: 1,
leaseExpiresAt: new Date('2099-07-13T00:01:00.000Z'),
now: new Date('2099-07-13T00:00:00.000Z')
});
const blockedClaim = await store.claimAvailableEvents({
workerId: 'worker-b',
batchSize: 1,
leaseExpiresAt: new Date('2099-07-13T00:01:30.000Z'),
now: new Date('2099-07-13T00:00:30.000Z')
});
const recoveredClaim = await store.claimAvailableEvents({
workerId: 'worker-b',
batchSize: 1,
leaseExpiresAt: new Date('2099-07-13T00:03:00.000Z'),
now: new Date('2099-07-13T00:02:00.000Z')
});
assert.equal(firstClaim.length, 1);
assert.equal(blockedClaim.length, 0);
assert.equal(recoveredClaim.length, 1);
assert.equal(recoveredClaim[0]?.claimedBy, 'worker-b');
});

View File

@@ -88,7 +88,12 @@ export class ProjectionConsumerRuntime {
},
this.now()
);
return { eventId: event.eventId, eventType: event.eventType, status: 'failed', consumerResults };
return {
eventId: event.eventId,
eventType: event.eventType,
status: 'retry_scheduled',
consumerResults
};
}
await this.store.markDeliveryCompleted(event.eventId, this.now());

View File

@@ -1,4 +1,4 @@
import { and, eq } from 'drizzle-orm';
import { and, eq, sql } from 'drizzle-orm';
import {
businessEventOutbox,
projectionConsumerCheckpoints,
@@ -10,6 +10,7 @@ import type { BusinessEvent } from '../../business-events/types.ts';
import type {
BeginProjectionCheckpointInput,
BusinessEventDeliveryRecord,
ClaimBusinessEventDeliveriesInput,
CompleteProjectionCheckpointInput,
CreateProjectionDeadLetterInput,
FailProjectionCheckpointInput,
@@ -47,6 +48,43 @@ export class DrizzleProjectionRuntimeStore implements ProjectionRuntimeStore {
return mapDelivery(created);
}
async claimAvailableEvents(
input: ClaimBusinessEventDeliveriesInput
): Promise<BusinessEventDeliveryRecord[]> {
const result = await db.execute(sql`
WITH claimable AS (
SELECT id
FROM business_event_outbox
WHERE
(
delivery_status IN ('pending', 'retry_scheduled')
AND available_at <= ${input.now}
)
OR (
delivery_status IN ('claimed', 'processing')
AND claim_expires_at IS NOT NULL
AND claim_expires_at <= ${input.now}
)
ORDER BY available_at ASC, created_at ASC, id ASC
LIMIT ${input.batchSize}
FOR UPDATE SKIP LOCKED
)
UPDATE business_event_outbox
SET
delivery_status = 'claimed',
claimed_by = ${input.workerId},
claimed_at = ${input.now},
claim_expires_at = ${input.leaseExpiresAt},
updated_at = ${input.now}
FROM claimable
WHERE business_event_outbox.id = claimable.id
RETURNING business_event_outbox.*
`);
const rows = Array.isArray(result) ? result : result.rows;
return rows.map((row) => mapDelivery(row as typeof businessEventOutbox.$inferSelect));
}
async markDeliveryProcessing(eventId: string, now: Date): Promise<void> {
const existing = await this.getDelivery(eventId);
await db
@@ -65,7 +103,7 @@ export class DrizzleProjectionRuntimeStore implements ProjectionRuntimeStore {
}
async markDeliveryFailed(eventId: string, error: ProjectionFailure, now: Date): Promise<void> {
await updateDeliveryStatus(eventId, 'failed', now, {
await updateDeliveryStatus(eventId, 'retry_scheduled', now, {
failedAt: now,
lastErrorCode: error.code,
lastErrorMessage: error.message
@@ -253,7 +291,7 @@ export class DrizzleProjectionRuntimeStore implements ProjectionRuntimeStore {
async function updateDeliveryStatus(
eventId: string,
deliveryStatus: 'completed' | 'failed' | 'dead_letter',
deliveryStatus: 'completed' | 'retry_scheduled' | 'dead_letter',
now: Date,
values: Partial<typeof businessEventOutbox.$inferInsert>
): Promise<void> {
@@ -272,6 +310,9 @@ function mapDelivery(row: typeof businessEventOutbox.$inferSelect): BusinessEven
status: row.deliveryStatus as BusinessEventDeliveryRecord['status'],
attemptCount: row.attemptCount,
availableAt: row.availableAt.toISOString(),
claimedBy: row.claimedBy,
claimedAt: row.claimedAt?.toISOString() ?? null,
claimExpiresAt: row.claimExpiresAt?.toISOString() ?? null,
event: row.eventEnvelope as BusinessEvent
};
}

View File

@@ -0,0 +1,46 @@
import { businessEventOutbox } from '../../../../db/schema.ts';
import { db } from '../../../../lib/db.ts';
import {
assertBusinessEventPayload,
assertRegisteredBusinessEvent
} from '../../business-events/registry.ts';
import type { BusinessEvent } from '../../business-events/types.ts';
type OutboxDatabaseClient = Pick<typeof db, 'insert'>;
export interface TransactionalBusinessEventPublisher {
enqueue(client: OutboxDatabaseClient, event: BusinessEvent): Promise<void>;
enqueueMany(client: OutboxDatabaseClient, events: BusinessEvent[]): Promise<void>;
}
export class DrizzleTransactionalBusinessEventPublisher
implements TransactionalBusinessEventPublisher
{
async enqueue(client: OutboxDatabaseClient, event: BusinessEvent): Promise<void> {
assertRegisteredBusinessEvent(event.eventType);
assertBusinessEventPayload(event.eventType, event.payload);
await client.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)
});
}
async enqueueMany(client: OutboxDatabaseClient, events: BusinessEvent[]): Promise<void> {
for (const event of events) {
await this.enqueue(client, event);
}
}
}
export const transactionalBusinessEventPublisher =
new DrizzleTransactionalBusinessEventPublisher();

View File

@@ -2,8 +2,10 @@ import type { BusinessEvent } from '../business-events/types.ts';
export type BusinessEventDeliveryStatus =
| 'pending'
| 'claimed'
| 'processing'
| 'completed'
| 'retry_scheduled'
| 'failed'
| 'dead_letter';
@@ -66,6 +68,9 @@ export interface BusinessEventDeliveryRecord {
status: BusinessEventDeliveryStatus;
attemptCount: number;
availableAt: string;
claimedBy?: string | null;
claimedAt?: string | null;
claimExpiresAt?: string | null;
event: BusinessEvent;
}
@@ -86,6 +91,7 @@ export interface ProjectionCheckpointRecord {
export interface ProjectionRuntimeStore {
persistEvent(event: BusinessEvent): Promise<BusinessEventDeliveryRecord>;
claimAvailableEvents(input: ClaimBusinessEventDeliveriesInput): 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>;
@@ -99,6 +105,13 @@ export interface ProjectionRuntimeStore {
recordHealthSnapshot(input: ProjectionHealthSnapshotInput): Promise<void>;
}
export interface ClaimBusinessEventDeliveriesInput {
workerId: string;
batchSize: number;
leaseExpiresAt: Date;
now: Date;
}
export interface BeginProjectionCheckpointInput {
organizationId: string;
consumerName: string;

View File

@@ -0,0 +1,128 @@
import { INITIAL_PROJECTION_CONSUMERS } from './consumers.ts';
import { ProjectionConsumerRuntime } from './runtime.ts';
import type {
BusinessEventDeliveryRecord,
ProjectionConsumer,
ProjectionRuntimeResult,
ProjectionRuntimeStore
} from './types.ts';
export interface ProjectionWorkerConfig {
enabled: boolean;
pollingIntervalMs: number;
batchSize: number;
maxConcurrentEvents: number;
maxConcurrentConsumers: number;
claimLeaseMs: number;
staleClaimMs: number;
workerId: string;
gracefulShutdownMs: number;
}
export interface ProjectionWorkerDrainResult {
workerId: string;
claimedCount: number;
processedCount: number;
results: ProjectionRuntimeResult[];
}
export const DEFAULT_PROJECTION_WORKER_CONFIG: ProjectionWorkerConfig = {
enabled: process.env.PROJECTION_WORKER_ENABLED === 'true',
pollingIntervalMs: Number(process.env.PROJECTION_WORKER_POLLING_INTERVAL_MS ?? 3000),
batchSize: Number(process.env.PROJECTION_WORKER_BATCH_SIZE ?? 25),
maxConcurrentEvents: Number(process.env.PROJECTION_WORKER_MAX_CONCURRENT_EVENTS ?? 1),
maxConcurrentConsumers: Number(process.env.PROJECTION_WORKER_MAX_CONCURRENT_CONSUMERS ?? 4),
claimLeaseMs: Number(process.env.PROJECTION_WORKER_CLAIM_LEASE_MS ?? 60_000),
staleClaimMs: Number(process.env.PROJECTION_WORKER_STALE_CLAIM_MS ?? 90_000),
workerId:
process.env.PROJECTION_WORKER_ID ??
`projection-worker-${process.pid}-${crypto.randomUUID().slice(0, 8)}`,
gracefulShutdownMs: Number(process.env.PROJECTION_WORKER_GRACEFUL_SHUTDOWN_MS ?? 30_000)
};
interface ProjectionOutboxWorkerOptions {
store: ProjectionRuntimeStore;
consumers?: ProjectionConsumer[];
config?: Partial<ProjectionWorkerConfig>;
now?: () => Date;
}
export class ProjectionOutboxWorker {
private readonly store: ProjectionRuntimeStore;
private readonly runtime: ProjectionConsumerRuntime;
private readonly config: ProjectionWorkerConfig;
private readonly now: () => Date;
private stopping = false;
private timer: NodeJS.Timeout | null = null;
constructor(options: ProjectionOutboxWorkerOptions) {
this.store = options.store;
this.config = { ...DEFAULT_PROJECTION_WORKER_CONFIG, ...options.config };
this.now = options.now ?? (() => new Date());
this.runtime = new ProjectionConsumerRuntime({
store: this.store,
consumers: options.consumers ?? INITIAL_PROJECTION_CONSUMERS,
now: this.now
});
}
async drainOnce(): Promise<ProjectionWorkerDrainResult> {
const now = this.now();
const claimed = await this.store.claimAvailableEvents({
workerId: this.config.workerId,
batchSize: this.config.batchSize,
leaseExpiresAt: new Date(now.getTime() + this.config.claimLeaseMs),
now
});
const results: ProjectionRuntimeResult[] = [];
for (const delivery of claimed) {
if (this.stopping) {
break;
}
results.push(await this.processDelivery(delivery));
}
return {
workerId: this.config.workerId,
claimedCount: claimed.length,
processedCount: results.length,
results
};
}
start(): void {
if (!this.config.enabled || this.timer) {
return;
}
const tick = async () => {
if (this.stopping) {
return;
}
await this.drainOnce();
this.timer = setTimeout(tick, this.config.pollingIntervalMs);
};
this.timer = setTimeout(tick, 0);
}
async stop(): Promise<void> {
this.stopping = true;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
private async processDelivery(
delivery: BusinessEventDeliveryRecord
): Promise<ProjectionRuntimeResult> {
return this.runtime.processEvent(delivery.event);
}
}