/** * @factory/shared — domain event catalog + envelope. * * Domain operations publish events onto the bus (Redis streams); workers * consume them for search indexing, webhooks, automations and notifications * (ARCHITECTURE.md invariant 5). The catalog below is the single source of * truth for event names, shared by the API (producers), the worker * (consumers), the webhook dispatcher (§22 catalog) and the UI (live * updates). */ import { randomUUID } from "node:crypto"; /** * All domain event names (dot-separated `entity.action`), matching * PRODUCT-SPEC §22/§23 and the webhook catalog. */ export const DomainEventName = { PAGE_CREATED: "page.created", PAGE_UPDATED: "page.updated", PAGE_DELETED: "page.deleted", PAGE_SHARED: "page.shared", DATABASE_CREATED: "database.created", DATABASE_UPDATED: "database.updated", RECORD_CREATED: "record.created", RECORD_UPDATED: "record.updated", COMMENT_CREATED: "comment.created", /** Comment thread resolved (the Phase-3 resolve toggle; TASK-0094). */ COMMENT_RESOLVED: "comment.resolved", /** Additive INTERNAL comment events (TASK-0094 R1): the comment panel's * full reactivity (reopened / reacted / deleted) rides these on the * `factory:events` stream; the realtime app maps them to `comment.event` * frame kinds (packages/shared/src/realtime/comment-events.ts). They are * stream-internal — deliberately NOT in WEBHOOK_EVENTS (the Phase-13 * public catalog stays the Phase-1 minimum). */ COMMENT_REOPENED: "comment.reopened", COMMENT_REACTION: "comment.reaction", COMMENT_DELETED: "comment.deleted", /** A Notification row was created (TASK-0094 R7): the realtime app maps * it to the `notification.push` frame on the recipient's control room. * Stream-internal (NOT a webhook catalog event). */ NOTIFICATION_CREATED: "notification.created", FORM_SUBMITTED: "form.submitted", AUTOMATION_EXECUTED: "automation.executed", FILE_UPLOADED: "file.uploaded", /** A file's pipeline scan flagged it infected (TASK-0102 §3). Stream-internal: * the worker notifies workspace admins on this event; the file is * quarantined (never downloaded/thumbnailized/indexed). NOT a webhook * catalog event (Phase-13 catalog stays the Phase-1 minimum). */ FILE_VIRUS_DETECTED: "file.virus_detected", /** A file was moved to the trash (soft delete, TASK-0104 §3). The search * index removes the doc (index sync); the object + version rows are * RETAINED until purge. NOT a webhook catalog event (internal housekeeping * — restore re-emits `file.uploaded` with `restored: true`; purge removes * the object + rows directly). */ FILE_DELETED: "file.deleted", /** A file's searchable fields changed — currently: rename (TASK-0104 §8). * The search index re-upserts the file doc so the title converges without * waiting for the 15-min reconcile. NOT a webhook catalog event (the * Phase-13 catalog stays the Phase-1 minimum; file content events are * internal to the platform). */ FILE_UPDATED: "file.updated", API_KEY_CREATED: "api_key.created", USER_LOGIN: "user.login", USER_LOGOUT: "user.logout", USER_REGISTER: "user.register", PERMISSION_CHANGED: "permission.changed", WORKSPACE_CREATED: "workspace.created", /** Phase 14 (TASK-0132) — SCIM 2.0 provisioning (stream-internal: the * IdP-facing SCIM surface is its own protocol, not a webhook event). */ SCIM_USER_CREATED: "scim.user.created", SCIM_USER_UPDATED: "scim.user.updated", SCIM_USER_DEACTIVATED: "scim.user.deactivated", SCIM_GROUP_CREATED: "scim.group.created", SCIM_GROUP_UPDATED: "scim.group.updated", SCIM_GROUP_DELETED: "scim.group.deleted", SCIM_TOKEN_CREATED: "scim.token.created", SCIM_TOKEN_DISABLED: "scim.token.disabled", } as const; export type DomainEventName = (typeof DomainEventName)[keyof typeof DomainEventName]; // The public webhook catalog lives in the dependency-free `webhook-events.ts` // and is re-exported here so existing import sites // (`import { WEBHOOK_EVENTS } from "../events.js"`) keep working. It cannot // be defined in THIS module: `dto/webhook.dto.ts` imports it, and that file // sits in the browser-safe `@factory/shared/dto` barrel, which must not // depend on this module's `node:crypto` import (it would break the web // build — UnhandledSchemeError). export { WEBHOOK_EVENTS, type WebhookEventName } from "./webhook-events.js"; /** * Standard event envelope. `ts` is an ISO-8601 string (JSON-safe); `id` is a * prefixed UUID ({@link generateId}). */ export interface DomainEvent { /** Globally unique event id, e.g. `evt_9f2c…`. */ id: string; name: DomainEventName; /** Tenant the event belongs to (required for tenant-scoped events). */ workspaceId?: string; /** Acting user (or api key / system actor id). */ actorId?: string; payload: T; /** ISO-8601 timestamp of when the event was created. */ ts: string; } /** * Small id generator: `_`. Used for event ids, share * tokens, reset tokens, etc. (cuid-like: prefixed, sortable-by-prefix, * collision-free for our purposes.) */ export function generateId(prefix: string): string { return `${prefix}_${randomUUID().replaceAll("-", "")}`; } export interface CreateDomainEventOptions { workspaceId?: string; actorId?: string; /** Override the timestamp (tests / replay). Defaults to now. */ ts?: string; } /** Build a fully-populated {@link DomainEvent} envelope. */ export function createDomainEvent( name: DomainEventName, payload: T, options: CreateDomainEventOptions = {}, ): DomainEvent { const event: DomainEvent = { id: generateId("evt"), name, payload, ts: options.ts ?? new Date().toISOString(), }; if (options.workspaceId !== undefined) event.workspaceId = options.workspaceId; if (options.actorId !== undefined) event.actorId = options.actorId; return event; } /** * The per-flush block change counts (TASK-0092 R3). The realtime * persister emits it on `page.updated` (the Phase-3 event shape; this * field is ADDITIVE — the Phase-8 indexer and the TASK-0086 * computed-recompute consumer both listen, and REST emitters that predate * Phase-7 simply omit it). Counts are the flush's minimal op diff vs the * last persisted block set, NOT the full row count. */ export interface BlockDelta { inserted: number; updated: number; moved: number; deleted: number; } /** * The `page.updated` event payload. REST emitters populate `pageId` (+ * optionally `blockIds` / `changed` / `fields`); the realtime flush * (TASK-0092) additionally populates `blockDelta` (the additive field) and * `version` (the new `pages.version`). Both emitters are the same domain * event; consumers key off the fields they understand. */ export interface PageUpdatedPayload { pageId: string; /** REST: which blocks changed (the audit's metadata); realtime: omitted. */ blockIds?: string[]; /** REST: a coarse change kind ("created" | "updated" | …); realtime: omitted. */ changed?: string; /** REST: the page fields touched by a page-level PATCH; realtime: omitted. */ fields?: string[]; /** The new `pages.version` after the flush's CAS (realtime flush only). */ version?: number; /** The flush's minimal op counts (the realtime flush only — additive). */ blockDelta?: BlockDelta; }