@l-etabli/events 0.1.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.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/dist/adapters/in-memory/InMemoryEventBus.cjs +128 -0
  4. package/dist/adapters/in-memory/InMemoryEventBus.cjs.map +1 -0
  5. package/dist/adapters/in-memory/InMemoryEventBus.d.cts +16 -0
  6. package/dist/adapters/in-memory/InMemoryEventBus.d.ts +16 -0
  7. package/dist/adapters/in-memory/InMemoryEventBus.mjs +104 -0
  8. package/dist/adapters/in-memory/InMemoryEventBus.mjs.map +1 -0
  9. package/dist/adapters/in-memory/InMemoryEventQueries.cjs +44 -0
  10. package/dist/adapters/in-memory/InMemoryEventQueries.cjs.map +1 -0
  11. package/dist/adapters/in-memory/InMemoryEventQueries.d.cts +10 -0
  12. package/dist/adapters/in-memory/InMemoryEventQueries.d.ts +10 -0
  13. package/dist/adapters/in-memory/InMemoryEventQueries.mjs +20 -0
  14. package/dist/adapters/in-memory/InMemoryEventQueries.mjs.map +1 -0
  15. package/dist/adapters/in-memory/InMemoryEventRepository.cjs +68 -0
  16. package/dist/adapters/in-memory/InMemoryEventRepository.cjs.map +1 -0
  17. package/dist/adapters/in-memory/InMemoryEventRepository.d.cts +16 -0
  18. package/dist/adapters/in-memory/InMemoryEventRepository.d.ts +16 -0
  19. package/dist/adapters/in-memory/InMemoryEventRepository.mjs +43 -0
  20. package/dist/adapters/in-memory/InMemoryEventRepository.mjs.map +1 -0
  21. package/dist/adapters/in-memory/index.cjs +43 -0
  22. package/dist/adapters/in-memory/index.cjs.map +1 -0
  23. package/dist/adapters/in-memory/index.d.cts +18 -0
  24. package/dist/adapters/in-memory/index.d.ts +18 -0
  25. package/dist/adapters/in-memory/index.mjs +18 -0
  26. package/dist/adapters/in-memory/index.mjs.map +1 -0
  27. package/dist/adapters/kysely/KyselyEventQueries.cjs +47 -0
  28. package/dist/adapters/kysely/KyselyEventQueries.cjs.map +1 -0
  29. package/dist/adapters/kysely/KyselyEventQueries.d.cts +8 -0
  30. package/dist/adapters/kysely/KyselyEventQueries.d.ts +8 -0
  31. package/dist/adapters/kysely/KyselyEventQueries.mjs +23 -0
  32. package/dist/adapters/kysely/KyselyEventQueries.mjs.map +1 -0
  33. package/dist/adapters/kysely/KyselyEventRepository.cjs +53 -0
  34. package/dist/adapters/kysely/KyselyEventRepository.cjs.map +1 -0
  35. package/dist/adapters/kysely/KyselyEventRepository.d.cts +8 -0
  36. package/dist/adapters/kysely/KyselyEventRepository.d.ts +8 -0
  37. package/dist/adapters/kysely/KyselyEventRepository.mjs +29 -0
  38. package/dist/adapters/kysely/KyselyEventRepository.mjs.map +1 -0
  39. package/dist/adapters/kysely/index.cjs +32 -0
  40. package/dist/adapters/kysely/index.cjs.map +1 -0
  41. package/dist/adapters/kysely/index.d.cts +7 -0
  42. package/dist/adapters/kysely/index.d.ts +7 -0
  43. package/dist/adapters/kysely/index.mjs +7 -0
  44. package/dist/adapters/kysely/index.mjs.map +1 -0
  45. package/dist/adapters/kysely/migration.cjs +38 -0
  46. package/dist/adapters/kysely/migration.cjs.map +1 -0
  47. package/dist/adapters/kysely/migration.d.cts +6 -0
  48. package/dist/adapters/kysely/migration.d.ts +6 -0
  49. package/dist/adapters/kysely/migration.mjs +13 -0
  50. package/dist/adapters/kysely/migration.mjs.map +1 -0
  51. package/dist/adapters/kysely/types.cjs +17 -0
  52. package/dist/adapters/kysely/types.cjs.map +1 -0
  53. package/dist/adapters/kysely/types.d.cts +34 -0
  54. package/dist/adapters/kysely/types.d.ts +34 -0
  55. package/dist/adapters/kysely/types.mjs +1 -0
  56. package/dist/adapters/kysely/types.mjs.map +1 -0
  57. package/dist/createEventCrawler.cjs +102 -0
  58. package/dist/createEventCrawler.cjs.map +1 -0
  59. package/dist/createEventCrawler.d.cts +56 -0
  60. package/dist/createEventCrawler.d.ts +56 -0
  61. package/dist/createEventCrawler.mjs +78 -0
  62. package/dist/createEventCrawler.mjs.map +1 -0
  63. package/dist/createNewEvent.cjs +43 -0
  64. package/dist/createNewEvent.cjs.map +1 -0
  65. package/dist/createNewEvent.d.cts +61 -0
  66. package/dist/createNewEvent.d.ts +61 -0
  67. package/dist/createNewEvent.mjs +19 -0
  68. package/dist/createNewEvent.mjs.map +1 -0
  69. package/dist/index.cjs +27 -0
  70. package/dist/index.cjs.map +1 -0
  71. package/dist/index.d.cts +10 -0
  72. package/dist/index.d.ts +10 -0
  73. package/dist/index.mjs +4 -0
  74. package/dist/index.mjs.map +1 -0
  75. package/dist/ports/EventBus.cjs +17 -0
  76. package/dist/ports/EventBus.cjs.map +1 -0
  77. package/dist/ports/EventBus.d.cts +35 -0
  78. package/dist/ports/EventBus.d.ts +35 -0
  79. package/dist/ports/EventBus.mjs +1 -0
  80. package/dist/ports/EventBus.mjs.map +1 -0
  81. package/dist/ports/EventQueries.cjs +17 -0
  82. package/dist/ports/EventQueries.cjs.map +1 -0
  83. package/dist/ports/EventQueries.d.cts +24 -0
  84. package/dist/ports/EventQueries.d.ts +24 -0
  85. package/dist/ports/EventQueries.mjs +1 -0
  86. package/dist/ports/EventQueries.mjs.map +1 -0
  87. package/dist/ports/EventRepository.cjs +17 -0
  88. package/dist/ports/EventRepository.cjs.map +1 -0
  89. package/dist/ports/EventRepository.d.cts +43 -0
  90. package/dist/ports/EventRepository.d.ts +43 -0
  91. package/dist/ports/EventRepository.mjs +1 -0
  92. package/dist/ports/EventRepository.mjs.map +1 -0
  93. package/dist/types.cjs +17 -0
  94. package/dist/types.cjs.map +1 -0
  95. package/dist/types.d.cts +84 -0
  96. package/dist/types.d.ts +84 -0
  97. package/dist/types.mjs +1 -0
  98. package/dist/types.mjs.map +1 -0
  99. package/package.json +68 -0
  100. package/src/adapters/in-memory/InMemoryEventBus.ts +165 -0
  101. package/src/adapters/in-memory/InMemoryEventQueries.ts +30 -0
  102. package/src/adapters/in-memory/InMemoryEventRepository.ts +61 -0
  103. package/src/adapters/in-memory/index.ts +19 -0
  104. package/src/adapters/kysely/KyselyEventQueries.ts +35 -0
  105. package/src/adapters/kysely/KyselyEventRepository.ts +44 -0
  106. package/src/adapters/kysely/index.ts +3 -0
  107. package/src/adapters/kysely/migration.ts +39 -0
  108. package/src/adapters/kysely/types.ts +37 -0
  109. package/src/createEventCrawler.ts +139 -0
  110. package/src/createNewEvent.ts +87 -0
  111. package/src/index.ts +7 -0
  112. package/src/ports/EventBus.ts +37 -0
  113. package/src/ports/EventQueries.ts +25 -0
  114. package/src/ports/EventRepository.ts +49 -0
  115. package/src/types.ts +100 -0
@@ -0,0 +1,165 @@
1
+ import { makeCreateNewEvent } from "../../createNewEvent.ts";
2
+ import type { EventBus } from "../../ports/EventBus.ts";
3
+ import type { WithEventsUow } from "../../ports/EventRepository.ts";
4
+ import type {
5
+ DefaultContext,
6
+ EventId,
7
+ EventPublication,
8
+ GenericEvent,
9
+ SubscriptionId,
10
+ } from "../../types.ts";
11
+
12
+ type SubscriptionsForTopic = Record<
13
+ string,
14
+ (event: GenericEvent<string, unknown, DefaultContext>) => Promise<void>
15
+ >;
16
+
17
+ type CreateInMemoryEventBusOptions = {
18
+ maxRetries?: number;
19
+ getNow?: () => Date;
20
+ generateId?: () => EventId;
21
+ };
22
+
23
+ export const createInMemoryEventBus = <
24
+ Event extends GenericEvent<string, unknown, DefaultContext>,
25
+ >(
26
+ withUow: WithEventsUow<Event>,
27
+ options: CreateInMemoryEventBusOptions = {},
28
+ ) => {
29
+ const maxRetries = options.maxRetries ?? 3;
30
+ const createNewEvent = makeCreateNewEvent<Event>({
31
+ getNow: options.getNow,
32
+ generateId: options.generateId,
33
+ });
34
+ const subscriptions: Partial<Record<string, SubscriptionsForTopic>> = {};
35
+
36
+ const executeCallback = async (
37
+ event: Event,
38
+ subscriptionId: string,
39
+ callback: (
40
+ event: GenericEvent<string, unknown, DefaultContext>,
41
+ ) => Promise<void>,
42
+ ): Promise<
43
+ { subscriptionId: string; errorMessage: string; stack?: string } | undefined
44
+ > => {
45
+ try {
46
+ await callback(event);
47
+ } catch (error) {
48
+ return {
49
+ subscriptionId,
50
+ errorMessage: error instanceof Error ? error.message : String(error),
51
+ stack: error instanceof Error ? error.stack : undefined,
52
+ };
53
+ }
54
+ };
55
+
56
+ const getSubscriptionIdsToPublish = (
57
+ event: Event,
58
+ callbacksBySubscriptionId: SubscriptionsForTopic,
59
+ ): string[] => {
60
+ const allSubscriptionIds = Object.keys(callbacksBySubscriptionId);
61
+
62
+ if (event.publications.length === 0 || event.status === "to-republish") {
63
+ return allSubscriptionIds;
64
+ }
65
+
66
+ const lastPublication = event.publications.reduce((latest, current) =>
67
+ current.publishedAt > latest.publishedAt ? current : latest,
68
+ );
69
+ const failedSubscriptionIds = lastPublication.failures.map(
70
+ (failure) => failure.subscriptionId,
71
+ );
72
+
73
+ return allSubscriptionIds.filter((id) =>
74
+ failedSubscriptionIds.includes(id),
75
+ );
76
+ };
77
+
78
+ const eventBus: EventBus<Event> = {
79
+ publish: async (event) => {
80
+ const publishedAt = new Date();
81
+ const topic = event.topic;
82
+
83
+ const callbacksBySubscriptionSlug = subscriptions[topic];
84
+
85
+ if (!callbacksBySubscriptionSlug) {
86
+ event.publications.push({
87
+ publishedAt,
88
+ publishedSubscribers: [],
89
+ failures: [],
90
+ });
91
+ event.status = "published";
92
+ await withUow(async (uow) => {
93
+ await uow.eventRepository.save(event);
94
+ });
95
+ return;
96
+ }
97
+
98
+ const subscriptionIdsToPublish = getSubscriptionIdsToPublish(
99
+ event,
100
+ callbacksBySubscriptionSlug,
101
+ );
102
+
103
+ const failuresOrUndefined = await Promise.all(
104
+ subscriptionIdsToPublish.map((subscriptionId) =>
105
+ executeCallback(
106
+ event,
107
+ subscriptionId,
108
+ callbacksBySubscriptionSlug[subscriptionId],
109
+ ),
110
+ ),
111
+ );
112
+
113
+ const failures = failuresOrUndefined.filter(
114
+ (
115
+ f,
116
+ ): f is {
117
+ subscriptionId: string;
118
+ errorMessage: string;
119
+ stack?: string;
120
+ } => f !== undefined,
121
+ );
122
+
123
+ const publications: EventPublication[] = [
124
+ ...event.publications,
125
+ {
126
+ publishedAt,
127
+ publishedSubscribers: subscriptionIdsToPublish.map(
128
+ (id) => id as SubscriptionId,
129
+ ),
130
+ failures,
131
+ },
132
+ ];
133
+
134
+ if (failures.length === 0) {
135
+ event.status = "published";
136
+ } else {
137
+ const wasMaxNumberOfErrorsReached = publications.length >= maxRetries;
138
+ event.status = wasMaxNumberOfErrorsReached
139
+ ? "quarantined"
140
+ : "failed-but-will-retry";
141
+ }
142
+
143
+ event.publications = publications;
144
+
145
+ await withUow(async (uow) => {
146
+ await uow.eventRepository.save(event);
147
+ });
148
+ },
149
+
150
+ subscribe: ({ topic, subscriptionId, callBack }) => {
151
+ if (!subscriptions[topic]) {
152
+ subscriptions[topic] = {};
153
+ }
154
+
155
+ const subscriptionsForTopic = subscriptions[topic];
156
+ if (subscriptionsForTopic) {
157
+ subscriptionsForTopic[subscriptionId] = callBack as (
158
+ event: GenericEvent<string, unknown, DefaultContext>,
159
+ ) => Promise<void>;
160
+ }
161
+ },
162
+ };
163
+
164
+ return { eventBus, createNewEvent };
165
+ };
@@ -0,0 +1,30 @@
1
+ import type { EventQueries } from "../../ports/EventQueries.ts";
2
+ import type { DefaultContext, GenericEvent } from "../../types.ts";
3
+ import type { InMemoryEventRepositoryHelpers } from "./InMemoryEventRepository.ts";
4
+
5
+ export const createInMemoryEventQueries = <
6
+ Event extends GenericEvent<string, unknown, DefaultContext>,
7
+ >(
8
+ helpers: InMemoryEventRepositoryHelpers<Event>,
9
+ ): { eventQueries: EventQueries<Event> } => ({
10
+ eventQueries: {
11
+ getEvents: async ({ filters, limit }) => {
12
+ const matchesContext = (event: Event): boolean => {
13
+ if (!filters.context) return true;
14
+ if (!event.context) return false;
15
+
16
+ return Object.entries(filters.context).every(
17
+ ([key, value]) => event.context?.[key] === value,
18
+ );
19
+ };
20
+
21
+ return helpers
22
+ .getAllEvents()
23
+ .filter(
24
+ (event) =>
25
+ filters.statuses.includes(event.status) && matchesContext(event),
26
+ )
27
+ .slice(0, limit);
28
+ },
29
+ },
30
+ });
@@ -0,0 +1,61 @@
1
+ import type {
2
+ EventRepository,
3
+ WithEventsUow,
4
+ } from "../../ports/EventRepository.ts";
5
+ import type { DefaultContext, GenericEvent } from "../../types.ts";
6
+
7
+ export type InMemoryEventRepositoryHelpers<
8
+ Event extends GenericEvent<string, unknown, DefaultContext>,
9
+ > = { getAllEvents: () => Event[]; setEvents: (events: Event[]) => void };
10
+
11
+ export const createInMemoryEventRepository = <
12
+ Event extends GenericEvent<string, unknown, DefaultContext>,
13
+ >(): {
14
+ eventRepository: EventRepository<Event>;
15
+ helpers: InMemoryEventRepositoryHelpers<Event>;
16
+ } => {
17
+ const eventById: Record<string, Event> = {};
18
+
19
+ const eventRepository: EventRepository<Event> = {
20
+ save: async (event) => {
21
+ eventById[event.id] = event;
22
+ },
23
+ saveNewEventsBatch: async (events) => {
24
+ events.forEach((event) => {
25
+ eventById[event.id] = event;
26
+ });
27
+ },
28
+ markEventsAsInProcess: async (events) => {
29
+ events.forEach((event) => {
30
+ eventById[event.id] = { ...event, status: "in-process" };
31
+ });
32
+ },
33
+ };
34
+
35
+ return {
36
+ eventRepository,
37
+ helpers: {
38
+ getAllEvents: () => Object.values(eventById),
39
+ setEvents: (events) => {
40
+ Object.keys(eventById).forEach((key) => {
41
+ delete eventById[key];
42
+ });
43
+
44
+ events.forEach((event) => {
45
+ eventById[event.id] = event;
46
+ });
47
+ },
48
+ },
49
+ };
50
+ };
51
+
52
+ export const createInMemoryWithUow = <
53
+ Event extends GenericEvent<string, unknown, DefaultContext>,
54
+ >(
55
+ eventRepository: EventRepository<Event>,
56
+ ): { withUow: WithEventsUow<Event> } => {
57
+ const withUow: WithEventsUow<Event> = async (fn) => {
58
+ await fn({ eventRepository });
59
+ };
60
+ return { withUow };
61
+ };
@@ -0,0 +1,19 @@
1
+ import type { DefaultContext, GenericEvent } from "../../types.ts";
2
+ import { createInMemoryEventQueries } from "./InMemoryEventQueries.ts";
3
+ import {
4
+ createInMemoryEventRepository,
5
+ createInMemoryWithUow,
6
+ } from "./InMemoryEventRepository.ts";
7
+
8
+ export * from "./InMemoryEventBus.ts";
9
+ export * from "./InMemoryEventQueries.ts";
10
+ export * from "./InMemoryEventRepository.ts";
11
+
12
+ export const createInMemoryEventRepositoryAndQueries = <
13
+ Event extends GenericEvent<string, unknown, DefaultContext>,
14
+ >() => {
15
+ const { eventRepository, helpers } = createInMemoryEventRepository<Event>();
16
+ const { eventQueries } = createInMemoryEventQueries<Event>(helpers);
17
+ const { withUow } = createInMemoryWithUow<Event>(eventRepository);
18
+ return { eventRepository, eventQueries, helpers, withUow };
19
+ };
@@ -0,0 +1,35 @@
1
+ import type { Kysely, SqlBool } from "kysely";
2
+ import { sql } from "kysely";
3
+ import type { EventQueries } from "../../ports/EventQueries.ts";
4
+ import type { DefaultContext, GenericEvent } from "../../types.ts";
5
+ import type { EventsTable } from "./types.ts";
6
+
7
+ export const createKyselyEventQueries = <
8
+ Event extends GenericEvent<string, unknown, DefaultContext>,
9
+ >(
10
+ db: Kysely<EventsTable>,
11
+ ): EventQueries<Event> => ({
12
+ getEvents: async ({ filters, limit }) => {
13
+ let query = db
14
+ .selectFrom("events")
15
+ .selectAll()
16
+ .where("status", "in", filters.statuses)
17
+ .limit(limit);
18
+
19
+ if (filters.context) {
20
+ for (const [key, value] of Object.entries(filters.context)) {
21
+ query = query.where(sql<SqlBool>`context->>${key} = ${value}`);
22
+ }
23
+ }
24
+
25
+ const rows = await query.execute();
26
+ return rows.map(
27
+ (row) =>
28
+ ({
29
+ ...row,
30
+ context: row.context ?? undefined,
31
+ priority: row.priority ?? undefined,
32
+ }) as Event,
33
+ );
34
+ },
35
+ });
@@ -0,0 +1,44 @@
1
+ import type { Kysely } from "kysely";
2
+ import type { EventRepository } from "../../ports/EventRepository.ts";
3
+ import type { DefaultContext, GenericEvent } from "../../types.ts";
4
+ import type { EventsTable } from "./types.ts";
5
+
6
+ export const createKyselyEventRepository = <
7
+ Event extends GenericEvent<string, unknown, DefaultContext>,
8
+ >(
9
+ db: Kysely<EventsTable>,
10
+ ): EventRepository<Event> => ({
11
+ save: async (event) => {
12
+ await db
13
+ .insertInto("events")
14
+ .values(event)
15
+ .onConflict((oc) =>
16
+ oc.column("id").doUpdateSet({
17
+ topic: event.topic,
18
+ payload: event.payload,
19
+ context: event.context,
20
+ status: event.status,
21
+ triggeredByUserId: event.triggeredByUserId,
22
+ occurredAt: event.occurredAt,
23
+ publications: event.publications,
24
+ priority: event.priority,
25
+ }),
26
+ )
27
+ .execute();
28
+ },
29
+
30
+ saveNewEventsBatch: async (events) => {
31
+ if (events.length === 0) return;
32
+ await db.insertInto("events").values(events).execute();
33
+ },
34
+
35
+ markEventsAsInProcess: async (events) => {
36
+ if (events.length === 0) return;
37
+ const ids = events.map((e) => e.id);
38
+ await db
39
+ .updateTable("events")
40
+ .set({ status: "in-process" })
41
+ .where("id", "in", ids)
42
+ .execute();
43
+ },
44
+ });
@@ -0,0 +1,3 @@
1
+ export { createKyselyEventQueries } from "./KyselyEventQueries.ts";
2
+ export { createKyselyEventRepository } from "./KyselyEventRepository.ts";
3
+ export type { EventsTable, TypedEventsTable } from "./types.ts";
@@ -0,0 +1,39 @@
1
+ import type { Kysely } from "kysely";
2
+
3
+ export async function up(db: Kysely<unknown>): Promise<void> {
4
+ await db.schema
5
+ .createTable("events")
6
+ .addColumn("id", "text", (col) => col.primaryKey())
7
+ .addColumn("topic", "text", (col) => col.notNull())
8
+ .addColumn("payload", "jsonb", (col) => col.notNull())
9
+ .addColumn("context", "jsonb")
10
+ .addColumn("status", "text", (col) => col.notNull())
11
+ .addColumn("triggeredByUserId", "text", (col) => col.notNull())
12
+ .addColumn("occurredAt", "timestamptz", (col) => col.notNull())
13
+ .addColumn("publications", "jsonb", (col) => col.notNull().defaultTo("[]"))
14
+ .addColumn("priority", "integer")
15
+ .execute();
16
+
17
+ await db.schema
18
+ .createIndex("events_status_idx")
19
+ .on("events")
20
+ .column("status")
21
+ .execute();
22
+
23
+ await db.schema
24
+ .createIndex("events_topic_idx")
25
+ .on("events")
26
+ .column("topic")
27
+ .execute();
28
+
29
+ // Add B-tree indexes for context keys you query frequently.
30
+ // Kysely's .column() doesn't support expressions, use raw SQL:
31
+ //
32
+ // await sql`CREATE INDEX events_context_user_id_idx ON events ((context->>'userId'))`.execute(db);
33
+ // await sql`CREATE INDEX events_context_tenant_id_idx ON events ((context->>'tenantId'))`.execute(db);
34
+ // await sql`CREATE INDEX events_context_project_id_idx ON events ((context->>'projectId'))`.execute(db);
35
+ }
36
+
37
+ export async function down(db: Kysely<unknown>): Promise<void> {
38
+ await db.schema.dropTable("events").execute();
39
+ }
@@ -0,0 +1,37 @@
1
+ import type {
2
+ DefaultContext,
3
+ EventPublication,
4
+ EventStatus,
5
+ GenericEvent,
6
+ } from "../../types.ts";
7
+
8
+ export type EventsTable = {
9
+ events: {
10
+ id: string;
11
+ topic: string;
12
+ payload: unknown;
13
+ context: unknown;
14
+ status: EventStatus;
15
+ triggeredByUserId: string;
16
+ occurredAt: Date;
17
+ publications: EventPublication[];
18
+ priority: number | null;
19
+ };
20
+ };
21
+
22
+ export type TypedEventsTable<
23
+ Event extends GenericEvent<string, unknown, DefaultContext>,
24
+ Topic extends Event["topic"] = Event["topic"],
25
+ > = {
26
+ events: {
27
+ id: string;
28
+ topic: Topic;
29
+ payload: Extract<Event, { topic: Topic }>["payload"];
30
+ context: Extract<Event, { topic: Topic }>["context"] | null;
31
+ status: EventStatus;
32
+ triggeredByUserId: string;
33
+ occurredAt: Date;
34
+ publications: EventPublication[];
35
+ priority: number | null;
36
+ };
37
+ };
@@ -0,0 +1,139 @@
1
+ import type { EventBus } from "./ports/EventBus.ts";
2
+ import type { EventQueries } from "./ports/EventQueries.ts";
3
+ import type { WithEventsUow } from "./ports/EventRepository.ts";
4
+ import type { DefaultContext, GenericEvent } from "./types.ts";
5
+
6
+ /** Configuration options for the event crawler. */
7
+ type CreateEventCrawlerOptions = {
8
+ /** Max events to fetch per batch (default: 100). */
9
+ batchSize?: number;
10
+ /** Max events to publish in parallel (default: 1). */
11
+ maxParallelProcessing?: number;
12
+ /** Interval for processing new events in ms (default: 10000). */
13
+ newEventsIntervalMs?: number;
14
+ /** Interval for retrying failed events in ms (default: 60000). */
15
+ failedEventsIntervalMs?: number;
16
+ };
17
+
18
+ const splitIntoChunks = <T>(array: T[], chunkSize: number): T[][] => {
19
+ const chunks: T[][] = [];
20
+ for (let i = 0; i < array.length; i += chunkSize) {
21
+ chunks.push(array.slice(i, i + chunkSize));
22
+ }
23
+ return chunks;
24
+ };
25
+
26
+ /**
27
+ * Creates a background event crawler that processes and publishes events.
28
+ *
29
+ * The crawler runs two loops:
30
+ * 1. Process new events: polls for "never-published" events and publishes them
31
+ * 2. Retry failed events: polls for failed events and retries them
32
+ *
33
+ * @returns Object with:
34
+ * - `start()`: Start the background polling loops
35
+ * - `processNewEvents()`: Manually trigger new event processing
36
+ * - `retryFailedEvents()`: Manually trigger failed event retry
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * const crawler = createEventCrawler({
41
+ * withUow,
42
+ * eventQueries,
43
+ * eventBus,
44
+ * options: { batchSize: 50, newEventsIntervalMs: 5000 },
45
+ * });
46
+ *
47
+ * // Start background processing
48
+ * crawler.start();
49
+ *
50
+ * // Or trigger manually (useful for testing)
51
+ * await crawler.processNewEvents();
52
+ * ```
53
+ */
54
+ export const createEventCrawler = <
55
+ Event extends GenericEvent<string, unknown, DefaultContext>,
56
+ >({
57
+ withUow,
58
+ eventQueries,
59
+ eventBus,
60
+ options = {},
61
+ }: {
62
+ withUow: WithEventsUow<Event>;
63
+ eventQueries: EventQueries<Event>;
64
+ eventBus: EventBus<Event>;
65
+ options?: CreateEventCrawlerOptions;
66
+ }) => {
67
+ const batchSize = options.batchSize ?? 100;
68
+ const maxParallelProcessing = options.maxParallelProcessing ?? 1;
69
+ const newEventsIntervalMs = options.newEventsIntervalMs ?? 10_000;
70
+ const failedEventsIntervalMs = options.failedEventsIntervalMs ?? 60_000;
71
+
72
+ const publishEventsInParallel = async (events: Event[]) => {
73
+ const eventChunks = splitIntoChunks(events, maxParallelProcessing);
74
+ for (const chunk of eventChunks) {
75
+ await Promise.all(chunk.map((event) => eventBus.publish(event)));
76
+ }
77
+ };
78
+
79
+ const processNewEvents = async (): Promise<void> => {
80
+ const events = await eventQueries.getEvents({
81
+ filters: { statuses: ["never-published"] },
82
+ limit: batchSize,
83
+ });
84
+
85
+ if (events.length === 0) return;
86
+
87
+ await withUow(async (uow) => {
88
+ await uow.eventRepository.markEventsAsInProcess(events);
89
+ });
90
+
91
+ await publishEventsInParallel(events);
92
+ };
93
+
94
+ const retryFailedEvents = async (): Promise<void> => {
95
+ const events = await eventQueries.getEvents({
96
+ filters: { statuses: ["to-republish", "failed-but-will-retry"] },
97
+ limit: batchSize,
98
+ });
99
+
100
+ if (events.length === 0) return;
101
+
102
+ await publishEventsInParallel(events);
103
+ };
104
+
105
+ const start = () => {
106
+ const scheduleProcessNewEvents = () => {
107
+ setTimeout(async () => {
108
+ try {
109
+ await processNewEvents();
110
+ } catch (error) {
111
+ console.error("Error processing new events:", error);
112
+ } finally {
113
+ scheduleProcessNewEvents();
114
+ }
115
+ }, newEventsIntervalMs);
116
+ };
117
+
118
+ const scheduleRetryFailedEvents = () => {
119
+ setTimeout(async () => {
120
+ try {
121
+ await retryFailedEvents();
122
+ } catch (error) {
123
+ console.error("Error retrying failed events:", error);
124
+ } finally {
125
+ scheduleRetryFailedEvents();
126
+ }
127
+ }, failedEventsIntervalMs);
128
+ };
129
+
130
+ scheduleProcessNewEvents();
131
+ scheduleRetryFailedEvents();
132
+ };
133
+
134
+ return {
135
+ processNewEvents,
136
+ retryFailedEvents,
137
+ start,
138
+ };
139
+ };
@@ -0,0 +1,87 @@
1
+ import type { DefaultContext, EventId, GenericEvent, UserId } from "./types.ts";
2
+
3
+ type MakeCreateNewEventOptions = {
4
+ getNow?: () => Date;
5
+ generateId?: () => EventId;
6
+ };
7
+
8
+ type ContextParam<
9
+ Event extends GenericEvent<string, unknown, DefaultContext>,
10
+ Topic extends Event["topic"],
11
+ > = Extract<Event, { topic: Topic }>["context"] extends undefined
12
+ ? { context?: undefined }
13
+ : { context: Extract<Event, { topic: Topic }>["context"] };
14
+
15
+ type CreateNewEventParams<
16
+ Event extends GenericEvent<string, unknown, DefaultContext>,
17
+ Topic extends Event["topic"],
18
+ > = {
19
+ topic: Topic;
20
+ payload: Extract<Event, { topic: Topic }>["payload"];
21
+ triggeredByUserId: UserId;
22
+ id?: EventId;
23
+ occurredAt?: Date;
24
+ priority?: number;
25
+ } & ContextParam<Event, Topic>;
26
+
27
+ /**
28
+ * Creates a typed event creator factory for your event union.
29
+ * Provides type-safe event creation where topic constrains payload type.
30
+ *
31
+ * @param options.getNow - Function to get current time (default: `() => new Date()`)
32
+ * @param options.generateId - Function to generate event IDs (default: `() => crypto.randomUUID()`)
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * type MyEvents =
37
+ * | GenericEvent<"UserCreated", { email: string }>
38
+ * | GenericEvent<"OrderPlaced", { orderId: string }>;
39
+ *
40
+ * // Standalone usage:
41
+ * const createEvent = makeCreateNewEvent<MyEvents>();
42
+ *
43
+ * // Or get it from createInMemoryEventBus (recommended):
44
+ * const { eventBus, createEvent } = createInMemoryEventBus<MyEvents>(withUow);
45
+ *
46
+ * // Type-safe: payload must match topic
47
+ * createEvent({ topic: "UserCreated", payload: { email: "a@b.com" }, triggeredByUserId: "u1" }); // OK
48
+ * createEvent({ topic: "UserCreated", payload: { orderId: "123" }, triggeredByUserId: "u1" }); // Error!
49
+ *
50
+ * // For testing, inject deterministic functions:
51
+ * const createEvent = makeCreateNewEvent<MyEvents>({
52
+ * getNow: () => new Date("2024-01-01"),
53
+ * generateId: () => "test-id",
54
+ * });
55
+ * ```
56
+ */
57
+
58
+ export type CreateNewEvent<
59
+ Event extends GenericEvent<string, unknown, DefaultContext>,
60
+ > = <Topic extends Event["topic"]>(
61
+ params: CreateNewEventParams<Event, Topic>,
62
+ ) => Extract<Event, { topic: Topic }>;
63
+
64
+ export const makeCreateNewEvent = <
65
+ Event extends GenericEvent<string, unknown, DefaultContext>,
66
+ >(
67
+ options: MakeCreateNewEventOptions = {},
68
+ ): CreateNewEvent<Event> => {
69
+ const getNow = options.getNow ?? (() => new Date());
70
+ const generateId =
71
+ options.generateId ?? (() => crypto.randomUUID() as EventId);
72
+
73
+ return <Topic extends Event["topic"]>(
74
+ params: CreateNewEventParams<Event, Topic>,
75
+ ): Extract<Event, { topic: Topic }> =>
76
+ ({
77
+ id: params.id ?? generateId(),
78
+ topic: params.topic,
79
+ payload: params.payload,
80
+ triggeredByUserId: params.triggeredByUserId,
81
+ occurredAt: params.occurredAt ?? getNow(),
82
+ status: "never-published",
83
+ publications: [],
84
+ priority: params.priority,
85
+ context: params.context,
86
+ }) as unknown as Extract<Event, { topic: Topic }>;
87
+ };
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from "./adapters/in-memory/index.ts";
2
+ export * from "./createEventCrawler.ts";
3
+ export * from "./createNewEvent.ts";
4
+ export type * from "./ports/EventBus.ts";
5
+ export type * from "./ports/EventQueries.ts";
6
+ export type * from "./ports/EventRepository.ts";
7
+ export type * from "./types.ts";