task ep.1.4.1
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
101
src/features/foundation/projections/operations.ts
Normal file
101
src/features/foundation/projections/operations.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
128
src/features/foundation/projections/worker.ts
Normal file
128
src/features/foundation/projections/worker.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user