1/** 2 * @factory/shared — domain event catalog + envelope. 3 * 4 * Domain operations publish events onto the bus (Redis streams); workers 5 * consume them for search indexing, webhooks, automations and notifications 6 * (ARCHITECTURE.md invariant 5). The catalog below is the single source of 7 * truth for event names, shared by the API (producers), the worker 8 * (consumers), the webhook dispatcher (§22 catalog) and the UI (live 9 * updates). 10 */ 11 12import { randomUUID } from "node:crypto"; 13 14/** 15 * All domain event names (dot-separated `entity.action`), matching 16 * PRODUCT-SPEC §22/§23 and the webhook catalog. 17 */ 18export const DomainEventName = { 19 PAGE_CREATED: "page.created", 20 PAGE_UPDATED: "page.updated", 21 PAGE_DELETED: "page.deleted", 22 PAGE_SHARED: "page.shared", 23 DATABASE_CREATED: "database.created", 24 DATABASE_UPDATED: "database.updated", 25 RECORD_CREATED: "record.created", 26 RECORD_UPDATED: "record.updated", 27 COMMENT_CREATED: "comment.created", 28 /** Comment thread resolved (the Phase-3 resolve toggle; TASK-0094). */ 29 COMMENT_RESOLVED: "comment.resolved", 30 /** Additive INTERNAL comment events (TASK-0094 R1): the comment panel's 31 * full reactivity (reopened / reacted / deleted) rides these on the 32 * `factory:events` stream; the realtime app maps them to `comment.event` 33 * frame kinds (packages/shared/src/realtime/comment-events.ts). They are 34 * stream-internal — deliberately NOT in WEBHOOK_EVENTS (the Phase-13 35 * public catalog stays the Phase-1 minimum). */ 36 COMMENT_REOPENED: "comment.reopened", 37 COMMENT_REACTION: "comment.reaction", 38 COMMENT_DELETED: "comment.deleted", 39 /** A Notification row was created (TASK-0094 R7): the realtime app maps 40 * it to the `notification.push` frame on the recipient's control room. 41 * Stream-internal (NOT a webhook catalog event). */ 42 NOTIFICATION_CREATED: "notification.created", 43 FORM_SUBMITTED: "form.submitted", 44 AUTOMATION_EXECUTED: "automation.executed", 45 FILE_UPLOADED: "file.uploaded", 46 /** A file's pipeline scan flagged it infected (TASK-0102 §3). Stream-internal: 47 * the worker notifies workspace admins on this event; the file is 48 * quarantined (never downloaded/thumbnailized/indexed). NOT a webhook 49 * catalog event (Phase-13 catalog stays the Phase-1 minimum). */ 50 FILE_VIRUS_DETECTED: "file.virus_detected", 51 /** A file was moved to the trash (soft delete, TASK-0104 §3). The search 52 * index removes the doc (index sync); the object + version rows are 53 * RETAINED until purge. NOT a webhook catalog event (internal housekeeping 54 * — restore re-emits `file.uploaded` with `restored: true`; purge removes 55 * the object + rows directly). */ 56 FILE_DELETED: "file.deleted", 57 /** A file's searchable fields changed — currently: rename (TASK-0104 §8). 58 * The search index re-upserts the file doc so the title converges without 59 * waiting for the 15-min reconcile. NOT a webhook catalog event (the 60 * Phase-13 catalog stays the Phase-1 minimum; file content events are 61 * internal to the platform). */ 62 FILE_UPDATED: "file.updated", 63 API_KEY_CREATED: "api_key.created", 64 USER_LOGIN: "user.login", 65 USER_LOGOUT: "user.logout", 66 USER_REGISTER: "user.register", 67 PERMISSION_CHANGED: "permission.changed", 68 WORKSPACE_CREATED: "workspace.created", 69 /** Phase 14 (TASK-0132) — SCIM 2.0 provisioning (stream-internal: the 70 * IdP-facing SCIM surface is its own protocol, not a webhook event). */ 71 SCIM_USER_CREATED: "scim.user.created", 72 SCIM_USER_UPDATED: "scim.user.updated", 73 SCIM_USER_DEACTIVATED: "scim.user.deactivated", 74 SCIM_GROUP_CREATED: "scim.group.created", 75 SCIM_GROUP_UPDATED: "scim.group.updated", 76 SCIM_GROUP_DELETED: "scim.group.deleted", 77 SCIM_TOKEN_CREATED: "scim.token.created", 78 SCIM_TOKEN_DISABLED: "scim.token.disabled", 79} as const; 80 81export type DomainEventName = (typeof DomainEventName)[keyof typeof DomainEventName]; 82 83// The public webhook catalog lives in the dependency-free `webhook-events.ts` 84// and is re-exported here so existing import sites 85// (`import { WEBHOOK_EVENTS } from "../events.js"`) keep working. It cannot 86// be defined in THIS module: `dto/webhook.dto.ts` imports it, and that file 87// sits in the browser-safe `@factory/shared/dto` barrel, which must not 88// depend on this module's `node:crypto` import (it would break the web 89// build — UnhandledSchemeError). 90export { WEBHOOK_EVENTS, type WebhookEventName } from "./webhook-events.js"; 91 92/** 93 * Standard event envelope. `ts` is an ISO-8601 string (JSON-safe); `id` is a 94 * prefixed UUID ({@link generateId}). 95 */ 96export interface DomainEvent<T = unknown> { 97 /** Globally unique event id, e.g. `evt_9f2c…`. */ 98 id: string; 99 name: DomainEventName; 100 /** Tenant the event belongs to (required for tenant-scoped events). */ 101 workspaceId?: string; 102 /** Acting user (or api key / system actor id). */ 103 actorId?: string; 104 payload: T; 105 /** ISO-8601 timestamp of when the event was created. */ 106 ts: string; 107} 108 109/** 110 * Small id generator: `<prefix>_<uuid v4>`. Used for event ids, share 111 * tokens, reset tokens, etc. (cuid-like: prefixed, sortable-by-prefix, 112 * collision-free for our purposes.) 113 */ 114export function generateId(prefix: string): string { 115 return `${prefix}_${randomUUID().replaceAll("-", "")}`; 116} 117 118export interface CreateDomainEventOptions { 119 workspaceId?: string; 120 actorId?: string; 121 /** Override the timestamp (tests / replay). Defaults to now. */ 122 ts?: string; 123} 124 125/** Build a fully-populated {@link DomainEvent} envelope. */ 126export function createDomainEvent<T>( 127 name: DomainEventName, 128 payload: T, 129 options: CreateDomainEventOptions = {}, 130): DomainEvent<T> { 131 const event: DomainEvent<T> = { 132 id: generateId("evt"), 133 name, 134 payload, 135 ts: options.ts ?? new Date().toISOString(), 136 }; 137 if (options.workspaceId !== undefined) event.workspaceId = options.workspaceId; 138 if (options.actorId !== undefined) event.actorId = options.actorId; 139 return event; 140} 141 142/** 143 * The per-flush block change counts (TASK-0092 R3). The realtime 144 * persister emits it on `page.updated` (the Phase-3 event shape; this 145 * field is ADDITIVE — the Phase-8 indexer and the TASK-0086 146 * computed-recompute consumer both listen, and REST emitters that predate 147 * Phase-7 simply omit it). Counts are the flush's minimal op diff vs the 148 * last persisted block set, NOT the full row count. 149 */ 150export interface BlockDelta { 151 inserted: number; 152 updated: number; 153 moved: number; 154 deleted: number; 155} 156 157/** 158 * The `page.updated` event payload. REST emitters populate `pageId` (+ 159 * optionally `blockIds` / `changed` / `fields`); the realtime flush 160 * (TASK-0092) additionally populates `blockDelta` (the additive field) and 161 * `version` (the new `pages.version`). Both emitters are the same domain 162 * event; consumers key off the fields they understand. 163 */ 164export interface PageUpdatedPayload { 165 pageId: string; 166 /** REST: which blocks changed (the audit's metadata); realtime: omitted. */ 167 blockIds?: string[]; 168 /** REST: a coarse change kind ("created" | "updated" | …); realtime: omitted. */ 169 changed?: string; 170 /** REST: the page fields touched by a page-level PATCH; realtime: omitted. */ 171 fields?: string[]; 172 /** The new `pages.version` after the flush's CAS (realtime flush only). */ 173 version?: number; 174 /** The flush's minimal op counts (the realtime flush only — additive). */ 175 blockDelta?: BlockDelta; 176} 177