@decocms/runtime 1.0.0-alpha.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/events.ts ADDED
@@ -0,0 +1,472 @@
1
+ import type {
2
+ CloudEvent,
3
+ EventResult,
4
+ OnEventsOutput,
5
+ } from "@decocms/bindings";
6
+ import z from "zod";
7
+ import { isBinding } from "./bindings.ts";
8
+
9
+ // ============================================================================
10
+ // Types
11
+ // ============================================================================
12
+
13
+ export interface EventSubscription {
14
+ eventType: string;
15
+ publisher: string;
16
+ }
17
+
18
+ /**
19
+ * Per-event handler - handles events of a specific type
20
+ * Returns result for each event individually
21
+ */
22
+ export type PerEventHandler<TEnv> = (
23
+ context: { events: CloudEvent[] },
24
+ env: TEnv,
25
+ ) => EventResult | Promise<EventResult>;
26
+
27
+ /**
28
+ * Batch handler function - handles multiple events at once
29
+ * Can return batch result or per-event results
30
+ */
31
+ export type BatchHandlerFn<TEnv> = (
32
+ context: { events: CloudEvent[] },
33
+ env: TEnv,
34
+ ) => OnEventsOutput | Promise<OnEventsOutput>;
35
+
36
+ /**
37
+ * Batch handler with explicit event types for subscription
38
+ */
39
+ export interface BatchHandler<TEnv> {
40
+ /** Handler function */
41
+ handler: BatchHandlerFn<TEnv>;
42
+ /** Event types to subscribe to */
43
+ events: string[];
44
+ }
45
+
46
+ /**
47
+ * Binding-level handlers - either a batch handler with events or per-event handlers
48
+ *
49
+ * @example Per-event handlers (event types inferred from keys)
50
+ * ```ts
51
+ * { "order.created": handler, "order.updated": handler }
52
+ * ```
53
+ *
54
+ * @example Batch handler with explicit events
55
+ * ```ts
56
+ * { handler: fn, events: ["order.created", "order.updated"] }
57
+ * ```
58
+ */
59
+ export type BindingHandlers<TEnv, Binding = unknown> =
60
+ | BatchHandler<TEnv>
61
+ | (Record<string, PerEventHandler<TEnv>> & CronHandlers<Binding, TEnv>);
62
+
63
+ export type CronHandlers<Binding, Env = unknown> = Binding extends {
64
+ __type: "@deco/event-bus";
65
+ value: string;
66
+ }
67
+ ? {
68
+ [key in `cron/${string}`]: (env: Env) => Promise<void>;
69
+ }
70
+ : {};
71
+ /**
72
+ * EventHandlers type supports three granularity levels:
73
+ *
74
+ * @example Global handler with explicit events
75
+ * ```ts
76
+ * { handler: (ctx, env) => result, events: ["order.created"] }
77
+ * ```
78
+ *
79
+ * @example Per-binding batch handler
80
+ * ```ts
81
+ * { DATABASE: { handler: fn, events: ["order.created"] } }
82
+ * ```
83
+ *
84
+ * @example Per-event handlers (events inferred from keys)
85
+ * ```ts
86
+ * { DATABASE: { "order.created": (ctx, env) => result } }
87
+ * ```
88
+ */
89
+ export type EventHandlers<
90
+ Env = unknown,
91
+ TSchema extends z.ZodTypeAny = never,
92
+ > = [TSchema] extends [never]
93
+ ? Record<string, never>
94
+ :
95
+ | BatchHandler<z.infer<TSchema>> // Global handler with events
96
+ | {
97
+ [K in keyof z.infer<TSchema> as z.infer<TSchema>[K] extends {
98
+ __type: string;
99
+ value: string;
100
+ }
101
+ ? K
102
+ : never]?: BindingHandlers<Env, z.infer<TSchema>[K]>;
103
+ };
104
+
105
+ /**
106
+ * Extract only the keys from T where the value is a Binding shape.
107
+ * Filters out non-binding properties at the type level.
108
+ */
109
+ export type BindingKeysOf<T> = {
110
+ [K in keyof T]: T[K] extends { __type: string; value: string } ? K : never;
111
+ }[keyof T];
112
+
113
+ // ============================================================================
114
+ // Type Guards
115
+ // ============================================================================
116
+
117
+ /**
118
+ * Check if handlers is a global batch handler (has handler + events at top level)
119
+ */
120
+ const isGlobalHandler = <TEnv>(
121
+ handlers: EventHandlers<TEnv, z.ZodTypeAny>,
122
+ ): handlers is BatchHandler<TEnv> => {
123
+ return (
124
+ typeof handlers === "object" &&
125
+ handlers !== null &&
126
+ "handler" in handlers &&
127
+ "events" in handlers &&
128
+ typeof handlers.handler === "function" &&
129
+ Array.isArray(handlers.events)
130
+ );
131
+ };
132
+
133
+ /**
134
+ * Check if a binding handler is a batch handler (has handler + events) vs per-event handlers (object of functions)
135
+ */
136
+ const isBatchHandler = <TEnv>(
137
+ handler: BindingHandlers<TEnv>,
138
+ ): handler is BatchHandler<TEnv> => {
139
+ return (
140
+ typeof handler === "object" &&
141
+ handler !== null &&
142
+ "handler" in handler &&
143
+ "events" in handler &&
144
+ typeof handler.handler === "function" &&
145
+ Array.isArray(handler.events)
146
+ );
147
+ };
148
+
149
+ // ============================================================================
150
+ // Helper Functions
151
+ // ============================================================================
152
+
153
+ /**
154
+ * Get binding keys from event handlers object
155
+ */
156
+ const getBindingKeys = <TEnv, TSchema extends z.ZodTypeAny>(
157
+ handlers: EventHandlers<TEnv, TSchema>,
158
+ ): string[] => {
159
+ if (isGlobalHandler<TEnv>(handlers)) {
160
+ return [];
161
+ }
162
+ return Object.keys(handlers);
163
+ };
164
+
165
+ /**
166
+ * Get event types for a binding from handlers
167
+ */
168
+ const getEventTypesForBinding = <TEnv, TSchema extends z.ZodTypeAny>(
169
+ handlers: EventHandlers<TEnv, TSchema>,
170
+ binding: string,
171
+ ): string[] => {
172
+ if (isGlobalHandler<TEnv>(handlers)) {
173
+ return handlers.events;
174
+ }
175
+ const bindingHandler = handlers[binding as keyof typeof handlers];
176
+ if (!bindingHandler) {
177
+ return [];
178
+ }
179
+ if (isBatchHandler(bindingHandler)) {
180
+ // Batch handler - return explicit events array
181
+ return bindingHandler.events;
182
+ }
183
+ // Per-event handlers - event types are the keys
184
+ return Object.keys(bindingHandler);
185
+ };
186
+
187
+ /**
188
+ * Get subscriptions from event handlers and state
189
+ * Returns flat array of { eventType, publisher } for EVENT_SYNC_SUBSCRIPTIONS
190
+ */
191
+ const eventsSubscriptions = <TEnv, TSchema extends z.ZodTypeAny = never>(
192
+ handlers: EventHandlers<TEnv, TSchema>,
193
+ state: z.infer<TSchema>,
194
+ ): EventSubscription[] => {
195
+ if (isGlobalHandler<TEnv>(handlers)) {
196
+ // Global handler - subscribe to all bindings with the explicit events
197
+ const subscriptions: EventSubscription[] = [];
198
+ for (const [, value] of Object.entries(state)) {
199
+ if (isBinding(value)) {
200
+ for (const eventType of handlers.events) {
201
+ subscriptions.push({
202
+ eventType,
203
+ publisher: value.value,
204
+ });
205
+ }
206
+ }
207
+ }
208
+ return subscriptions;
209
+ }
210
+
211
+ const subscriptions: EventSubscription[] = [];
212
+ for (const binding of getBindingKeys(handlers)) {
213
+ const bindingValue = state[binding as keyof typeof state];
214
+ if (!isBinding(bindingValue)) continue;
215
+
216
+ const eventTypes = getEventTypesForBinding(handlers, binding);
217
+ for (const eventType of eventTypes) {
218
+ subscriptions.push({
219
+ eventType,
220
+ publisher: bindingValue.value,
221
+ });
222
+ }
223
+ }
224
+ return subscriptions;
225
+ };
226
+
227
+ // ============================================================================
228
+ // Event Execution
229
+ // ============================================================================
230
+
231
+ /**
232
+ * Group events by source (connection ID)
233
+ */
234
+ const groupEventsBySource = (
235
+ events: CloudEvent[],
236
+ ): Map<string, CloudEvent[]> => {
237
+ const grouped = new Map<string, CloudEvent[]>();
238
+ for (const event of events) {
239
+ const source = event.source;
240
+ const existing = grouped.get(source) || [];
241
+ existing.push(event);
242
+ grouped.set(source, existing);
243
+ }
244
+ return grouped;
245
+ };
246
+
247
+ /**
248
+ * Group events by type
249
+ */
250
+ const groupEventsByType = (events: CloudEvent[]): Map<string, CloudEvent[]> => {
251
+ const grouped = new Map<string, CloudEvent[]>();
252
+ for (const event of events) {
253
+ const type = event.type;
254
+ const existing = grouped.get(type) || [];
255
+ existing.push(event);
256
+ grouped.set(type, existing);
257
+ }
258
+ return grouped;
259
+ };
260
+
261
+ /**
262
+ * Merge multiple OnEventsOutput results into a single result
263
+ */
264
+ const mergeResults = (results: OnEventsOutput[]): OnEventsOutput => {
265
+ const merged: OnEventsOutput = {};
266
+ const allResults: Record<string, EventResult> = {};
267
+
268
+ let hasAnyFailure = false;
269
+ let totalProcessed = 0;
270
+ const errors: string[] = [];
271
+
272
+ for (const result of results) {
273
+ // Merge per-event results
274
+ if (result.results) {
275
+ Object.assign(allResults, result.results);
276
+ }
277
+
278
+ // Track batch-level status
279
+ if (result.success === false) {
280
+ hasAnyFailure = true;
281
+ if (result.error) {
282
+ errors.push(result.error);
283
+ }
284
+ }
285
+
286
+ if (result.processedCount !== undefined) {
287
+ totalProcessed += result.processedCount;
288
+ }
289
+ }
290
+
291
+ // Build merged result
292
+ if (Object.keys(allResults).length > 0) {
293
+ merged.results = allResults;
294
+ }
295
+
296
+ // Set batch-level success based on all results
297
+ merged.success = !hasAnyFailure;
298
+
299
+ if (errors.length > 0) {
300
+ merged.error = errors.join("; ");
301
+ }
302
+
303
+ if (totalProcessed > 0) {
304
+ merged.processedCount = totalProcessed;
305
+ }
306
+
307
+ return merged;
308
+ };
309
+
310
+ /**
311
+ * Execute event handlers and return merged result
312
+ *
313
+ * Supports three handler formats:
314
+ * 1. Global: `(context, env) => result` - handles all events
315
+ * 2. Per-binding: `{ BINDING: (context, env) => result }` - handles all events from binding
316
+ * 3. Per-event: `{ BINDING: { "event.type": (context, env) => result } }` - handles specific events
317
+ */
318
+ const executeEventHandlers = async <TEnv, TSchema extends z.ZodTypeAny>(
319
+ handlers: EventHandlers<TEnv, TSchema>,
320
+ events: CloudEvent[],
321
+ env: z.infer<TSchema>,
322
+ state: z.infer<TSchema>,
323
+ ): Promise<OnEventsOutput> => {
324
+ // Case 1: Global handler
325
+ if (isGlobalHandler<TEnv>(handlers)) {
326
+ try {
327
+ return await handlers.handler({ events }, env);
328
+ } catch (error) {
329
+ return {
330
+ success: false,
331
+ error: error instanceof Error ? error.message : String(error),
332
+ };
333
+ }
334
+ }
335
+
336
+ // Build a map from connectionId -> binding key
337
+ const connectionToBinding = new Map<string, string>();
338
+ for (const binding of getBindingKeys(handlers)) {
339
+ const bindingValue = state[binding as keyof typeof state];
340
+ if (isBinding(bindingValue)) {
341
+ connectionToBinding.set(bindingValue.value, binding);
342
+ }
343
+ }
344
+
345
+ // Group events by source
346
+ const eventsBySource = groupEventsBySource(events);
347
+
348
+ // Process each binding's events in parallel
349
+ const promises: Promise<OnEventsOutput>[] = [];
350
+
351
+ for (const [source, sourceEvents] of eventsBySource) {
352
+ const binding = connectionToBinding.get(source);
353
+ if (!binding) {
354
+ // No handler for this source - mark as success (ignore)
355
+ continue;
356
+ }
357
+
358
+ const bindingHandler = handlers[binding as keyof typeof handlers];
359
+ if (!bindingHandler) continue;
360
+
361
+ // Case 2: Per-binding batch handler
362
+ if (isBatchHandler(bindingHandler)) {
363
+ promises.push(
364
+ (async () => {
365
+ try {
366
+ return await bindingHandler.handler({ events: sourceEvents }, env);
367
+ } catch (error) {
368
+ // Mark all events from this binding as failed
369
+ const results: Record<string, EventResult> = {};
370
+ for (const event of sourceEvents) {
371
+ results[event.id] = {
372
+ success: false,
373
+ error: error instanceof Error ? error.message : String(error),
374
+ };
375
+ }
376
+ return { results };
377
+ }
378
+ })(),
379
+ );
380
+ continue;
381
+ }
382
+
383
+ // Case 3: Per-event handlers
384
+ const perEventHandlers = bindingHandler as Record<
385
+ string,
386
+ PerEventHandler<z.infer<TSchema>>
387
+ >;
388
+ const eventsByType = groupEventsByType(sourceEvents);
389
+
390
+ for (const [eventType, typedEvents] of eventsByType) {
391
+ const eventHandler = perEventHandlers[eventType];
392
+ if (!eventHandler) {
393
+ // No handler for this event type - mark as success (ignore)
394
+ continue;
395
+ }
396
+
397
+ // Case 3a: Cron handlers (event type starts with "cron/")
398
+ // - Handler signature: (env) => Promise<void>
399
+ // - Fire and forget (don't await)
400
+ // - Always return success immediately
401
+ if (eventType.startsWith("cron/")) {
402
+ const cronHandler = eventHandler as unknown as (
403
+ env: z.infer<TSchema>,
404
+ ) => Promise<void>;
405
+
406
+ // Fire and forget - don't await, just log errors
407
+ cronHandler(env).catch((error) => {
408
+ console.error(
409
+ `[Event] Cron handler error for ${eventType}:`,
410
+ error instanceof Error ? error.message : String(error),
411
+ );
412
+ });
413
+
414
+ // Immediately return success for all cron events
415
+ const results: Record<string, EventResult> = {};
416
+ for (const event of typedEvents) {
417
+ results[event.id] = { success: true };
418
+ }
419
+ promises.push(Promise.resolve({ results }));
420
+ continue;
421
+ }
422
+
423
+ // Case 3b: Regular per-event handlers
424
+ // Call handler for each event type (handler receives all events of that type)
425
+ promises.push(
426
+ (async () => {
427
+ try {
428
+ const result = await eventHandler({ events: typedEvents }, env);
429
+ // Convert per-event result to output with results for each event
430
+ const results: Record<string, EventResult> = {};
431
+ for (const event of typedEvents) {
432
+ results[event.id] = result;
433
+ }
434
+ return { results };
435
+ } catch (error) {
436
+ const results: Record<string, EventResult> = {};
437
+ for (const event of typedEvents) {
438
+ results[event.id] = {
439
+ success: false,
440
+ error: error instanceof Error ? error.message : String(error),
441
+ };
442
+ }
443
+ return { results };
444
+ }
445
+ })(),
446
+ );
447
+ }
448
+ }
449
+
450
+ // Wait for all handlers to complete
451
+ const results = await Promise.all(promises);
452
+
453
+ // If no handlers were called, return success
454
+ if (results.length === 0) {
455
+ return { success: true };
456
+ }
457
+
458
+ // Merge all results
459
+ return mergeResults(results);
460
+ };
461
+
462
+ // ============================================================================
463
+ // Exports
464
+ // ============================================================================
465
+
466
+ /**
467
+ * Event utilities for subscriptions and execution
468
+ */
469
+ export const Event = {
470
+ subscriptions: eventsSubscriptions,
471
+ execute: executeEventHandlers,
472
+ };