@pattern-stack/codegen 0.8.0 → 0.9.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 (118) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/runtime/subsystems/auth/controllers/auth.controller.d.ts +1 -0
  3. package/dist/runtime/subsystems/auth/index.d.ts +2 -0
  4. package/dist/runtime/subsystems/auth/index.js +55 -0
  5. package/dist/runtime/subsystems/auth/index.js.map +1 -1
  6. package/dist/runtime/subsystems/auth/middleware/requester-context.d.ts +81 -0
  7. package/dist/runtime/subsystems/auth/middleware/requester-context.js +60 -0
  8. package/dist/runtime/subsystems/auth/middleware/requester-context.js.map +1 -0
  9. package/dist/runtime/subsystems/auth/protocols/user-context.d.ts +18 -0
  10. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js.map +1 -1
  11. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js.map +1 -1
  12. package/dist/runtime/subsystems/bridge/bridge.module.d.ts +3 -0
  13. package/dist/runtime/subsystems/bridge/bridge.module.js +930 -275
  14. package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
  15. package/dist/runtime/subsystems/bridge/event-flow.service.js.map +1 -1
  16. package/dist/runtime/subsystems/bridge/index.d.ts +3 -0
  17. package/dist/runtime/subsystems/bridge/index.js +837 -182
  18. package/dist/runtime/subsystems/bridge/index.js.map +1 -1
  19. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +3 -1
  20. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +92 -1
  21. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js.map +1 -1
  22. package/dist/runtime/subsystems/events/event-bus.memory-backend.d.ts +3 -1
  23. package/dist/runtime/subsystems/events/event-bus.memory-backend.js +99 -0
  24. package/dist/runtime/subsystems/events/event-bus.memory-backend.js.map +1 -1
  25. package/dist/runtime/subsystems/events/event-bus.redis-backend.js.map +1 -1
  26. package/dist/runtime/subsystems/events/event-keyset-cursor.d.ts +32 -0
  27. package/dist/runtime/subsystems/events/event-keyset-cursor.js +38 -0
  28. package/dist/runtime/subsystems/events/event-keyset-cursor.js.map +1 -0
  29. package/dist/runtime/subsystems/events/event-read.protocol.d.ts +94 -0
  30. package/dist/runtime/subsystems/events/event-read.protocol.js +9 -0
  31. package/dist/runtime/subsystems/events/event-read.protocol.js.map +1 -0
  32. package/dist/runtime/subsystems/events/events.module.js +177 -3
  33. package/dist/runtime/subsystems/events/events.module.js.map +1 -1
  34. package/dist/runtime/subsystems/events/events.tokens.d.ts +16 -1
  35. package/dist/runtime/subsystems/events/events.tokens.js +2 -0
  36. package/dist/runtime/subsystems/events/events.tokens.js.map +1 -1
  37. package/dist/runtime/subsystems/events/generated/bus.js.map +1 -1
  38. package/dist/runtime/subsystems/events/generated/index.js.map +1 -1
  39. package/dist/runtime/subsystems/events/index.d.ts +2 -1
  40. package/dist/runtime/subsystems/events/index.js +178 -3
  41. package/dist/runtime/subsystems/events/index.js.map +1 -1
  42. package/dist/runtime/subsystems/index.d.ts +2 -0
  43. package/dist/runtime/subsystems/index.js +1198 -264
  44. package/dist/runtime/subsystems/index.js.map +1 -1
  45. package/dist/runtime/subsystems/jobs/bullmq.config.d.ts +98 -0
  46. package/dist/runtime/subsystems/jobs/bullmq.config.js +143 -0
  47. package/dist/runtime/subsystems/jobs/bullmq.config.js.map +1 -0
  48. package/dist/runtime/subsystems/jobs/index.d.ts +6 -2
  49. package/dist/runtime/subsystems/jobs/index.js +861 -201
  50. package/dist/runtime/subsystems/jobs/index.js.map +1 -1
  51. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.d.ts +107 -0
  52. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +922 -0
  53. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -0
  54. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.d.ts +52 -0
  55. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js +57 -0
  56. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js.map +1 -0
  57. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.d.ts +2 -1
  58. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +81 -1
  59. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js.map +1 -1
  60. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.d.ts +2 -1
  61. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +81 -0
  62. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js.map +1 -1
  63. package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +74 -1
  64. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.d.ts +48 -0
  65. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +374 -0
  66. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js.map +1 -0
  67. package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +42 -4
  68. package/dist/runtime/subsystems/jobs/job-worker.module.js +832 -178
  69. package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
  70. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +10 -1
  71. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +519 -20
  72. package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
  73. package/dist/runtime/subsystems/jobs/pool-config.loader.d.ts +9 -1
  74. package/dist/runtime/subsystems/jobs/pool-config.loader.js +4 -0
  75. package/dist/runtime/subsystems/jobs/pool-config.loader.js.map +1 -1
  76. package/dist/runtime/subsystems/observability/index.d.ts +4 -3
  77. package/dist/runtime/subsystems/observability/index.js +109 -2
  78. package/dist/runtime/subsystems/observability/index.js.map +1 -1
  79. package/dist/runtime/subsystems/observability/observability.module.js +109 -2
  80. package/dist/runtime/subsystems/observability/observability.module.js.map +1 -1
  81. package/dist/runtime/subsystems/observability/observability.protocol.d.ts +63 -2
  82. package/dist/runtime/subsystems/observability/observability.service.d.ts +21 -3
  83. package/dist/runtime/subsystems/observability/observability.service.js +109 -2
  84. package/dist/runtime/subsystems/observability/observability.service.js.map +1 -1
  85. package/dist/runtime/subsystems/observability/reporters/bridge-metrics.reporter.d.ts +1 -0
  86. package/dist/runtime/subsystems/observability/reporters/index.d.ts +1 -0
  87. package/dist/src/cli/index.js +43 -7
  88. package/dist/src/cli/index.js.map +1 -1
  89. package/package.json +1 -1
  90. package/runtime/subsystems/auth/index.ts +8 -0
  91. package/runtime/subsystems/auth/middleware/requester-context.ts +141 -0
  92. package/runtime/subsystems/auth/protocols/user-context.ts +17 -0
  93. package/runtime/subsystems/bridge/bridge.module.ts +5 -0
  94. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +109 -3
  95. package/runtime/subsystems/events/event-bus.memory-backend.ts +103 -1
  96. package/runtime/subsystems/events/event-keyset-cursor.ts +59 -0
  97. package/runtime/subsystems/events/event-read.protocol.ts +97 -0
  98. package/runtime/subsystems/events/events.module.ts +18 -2
  99. package/runtime/subsystems/events/events.tokens.ts +16 -0
  100. package/runtime/subsystems/events/index.ts +7 -0
  101. package/runtime/subsystems/jobs/bullmq.config.ts +125 -0
  102. package/runtime/subsystems/jobs/index.ts +22 -0
  103. package/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.ts +381 -0
  104. package/runtime/subsystems/jobs/job-run-keyset-cursor.ts +88 -0
  105. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +59 -1
  106. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +53 -0
  107. package/runtime/subsystems/jobs/job-run-service.protocol.ts +77 -0
  108. package/runtime/subsystems/jobs/job-worker.bullmq-backend.ts +311 -0
  109. package/runtime/subsystems/jobs/job-worker.module.ts +124 -10
  110. package/runtime/subsystems/jobs/jobs-domain.module.ts +40 -21
  111. package/runtime/subsystems/jobs/pool-config.loader.ts +11 -0
  112. package/runtime/subsystems/observability/index.ts +8 -0
  113. package/runtime/subsystems/observability/observability.protocol.ts +76 -0
  114. package/runtime/subsystems/observability/observability.service.ts +148 -1
  115. package/templates/entity/new/clean-lite-ps/prompt-extension.js +14 -12
  116. package/templates/relationship/new/prompt.js +8 -5
  117. package/templates/subsystem/jobs/worker.ejs.t +30 -7
  118. package/templates/subsystem/sync/sync-audit.schema.ejs.t +12 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pattern-stack/codegen",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Entity-driven code generation for full-stack TypeScript applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -112,5 +112,13 @@ export {
112
112
  // Controller
113
113
  export { AuthController } from './controllers/auth.controller';
114
114
 
115
+ // Middleware — RequesterContext boundary (bridges auth → ambient tenant scope)
116
+ export {
117
+ installRequesterContext,
118
+ makeRequesterContextMiddleware,
119
+ resolveRequesterContext,
120
+ type RequesterContextOptions,
121
+ } from './middleware/requester-context';
122
+
115
123
  // Module
116
124
  export { AuthModule, type AuthModuleOptions } from './auth.module';
@@ -0,0 +1,141 @@
1
+ /**
2
+ * RequesterContext boundary install — bridges authentication to ambient
3
+ * tenant scoping.
4
+ *
5
+ * This is the missing link that makes `BaseRepository`'s ambient scoping
6
+ * (see `base-classes/tenant-context.ts`) actually engage on HTTP requests:
7
+ * it reads the requester off each request (via the consumer-bound
8
+ * `IUserContext`) and runs the rest of the request inside `withRequester(...)`,
9
+ * so every downstream repository read/write is automatically scoped — no
10
+ * threaded `userId`.
11
+ *
12
+ * ## Wiring (one line in your bootstrap)
13
+ *
14
+ * In `main.ts`, after `NestFactory.create`:
15
+ *
16
+ * ```ts
17
+ * import { installRequesterContext } from './shared/subsystems/auth/middleware/requester-context';
18
+ * const app = await NestFactory.create(AppModule);
19
+ * installRequesterContext(app); // no-op + warn if AUTH_USER_CONTEXT is unbound
20
+ * ```
21
+ *
22
+ * `installRequesterContext` resolves `AUTH_USER_CONTEXT` from the root DI
23
+ * container (so it sees the binding the consumer provides in AppModule) and
24
+ * registers a global Express middleware. Pairs with Swagger's `@ApiBearerAuth`
25
+ * "Authorize" button: paste a token there and every request it sends now flows
26
+ * through this boundary into a scoped repository call.
27
+ *
28
+ * ## Trust + failure model
29
+ *
30
+ * - The middleware TRUSTS whatever `IUserContext` returns — authentication and
31
+ * authorization (validating the token, deciding which scope a requester may
32
+ * claim) are the `IUserContext` implementation's job, exactly as for a
33
+ * hand-threaded `userId`.
34
+ * - When the requester cannot be resolved (no/invalid credentials — e.g. a
35
+ * public route, or the OAuth callback itself), the request proceeds WITHOUT
36
+ * an ambient context (`onUnresolved: 'unscoped'`, the default). A
37
+ * `userTracking` repo in lenient mode then runs unscoped; in strict mode it
38
+ * throws downstream — which is correct: unauthenticated callers must not
39
+ * reach scoped data. Set `onUnresolved: 'reject'` to fail the request at the
40
+ * boundary instead.
41
+ */
42
+ import type { INestApplication } from '@nestjs/common';
43
+ import {
44
+ withRequester,
45
+ type RequesterContext,
46
+ } from '../../../base-classes/tenant-context';
47
+ import { AUTH_USER_CONTEXT } from '../auth.tokens';
48
+ import type { IUserContext } from '../protocols/user-context';
49
+
50
+ /** Minimal Express-style middleware signature (avoids an `express` dep). */
51
+ type NextFn = (err?: unknown) => void;
52
+ type RequestHandler = (req: unknown, res: unknown, next: NextFn) => void;
53
+
54
+ export interface RequesterContextOptions {
55
+ /**
56
+ * What to do when `IUserContext` cannot resolve a requester (throws, or
57
+ * returns no `userId`).
58
+ * - `'unscoped'` (default): proceed without a context — public routes work;
59
+ * scoped repos run unscoped (lenient) or throw downstream (strict).
60
+ * - `'reject'`: fail the request at the boundary (`next(error)`).
61
+ */
62
+ onUnresolved?: 'unscoped' | 'reject';
63
+ }
64
+
65
+ /**
66
+ * Resolve the ambient context for a request: prefer the richer
67
+ * `resolveRequester` (org/superuser), else derive plain `'user'` scope from
68
+ * `getCurrentUserId`. Returns `undefined` when no requester can be determined.
69
+ */
70
+ export async function resolveRequesterContext(
71
+ userContext: IUserContext,
72
+ req: unknown,
73
+ ): Promise<RequesterContext | undefined> {
74
+ if (typeof userContext.resolveRequester === 'function') {
75
+ const ctx = await userContext.resolveRequester(req);
76
+ return ctx?.userId ? ctx : undefined;
77
+ }
78
+ const userId = await userContext.getCurrentUserId(req);
79
+ return userId ? { userId, organizationId: null } : undefined;
80
+ }
81
+
82
+ /**
83
+ * Build the global middleware. Runs the remainder of the request inside
84
+ * `withRequester(...)` so the ambient context propagates through every `await`
85
+ * to downstream repositories.
86
+ */
87
+ export function makeRequesterContextMiddleware(
88
+ userContext: IUserContext,
89
+ options: RequesterContextOptions = {},
90
+ ): RequestHandler {
91
+ const onUnresolved = options.onUnresolved ?? 'unscoped';
92
+ return (req, _res, next) => {
93
+ resolveRequesterContext(userContext, req).then(
94
+ (ctx) => {
95
+ if (!ctx) {
96
+ next();
97
+ return;
98
+ }
99
+ // als.run executes its callback synchronously; Express dispatches the
100
+ // rest of the pipeline inside next(), so all downstream handlers (and
101
+ // their awaits) inherit this context.
102
+ withRequester(ctx, async () => {
103
+ next();
104
+ });
105
+ },
106
+ (err) => {
107
+ if (onUnresolved === 'reject') {
108
+ next(err);
109
+ return;
110
+ }
111
+ next();
112
+ },
113
+ );
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Register the requester-context boundary on a Nest app. Resolves
119
+ * `AUTH_USER_CONTEXT` from the root container (so it sees the consumer's
120
+ * AppModule binding) and installs the global middleware. No-ops with a warning
121
+ * when `AUTH_USER_CONTEXT` is not bound, so calling it unconditionally in
122
+ * bootstrap is safe.
123
+ */
124
+ export function installRequesterContext(
125
+ app: INestApplication,
126
+ options: RequesterContextOptions = {},
127
+ ): void {
128
+ const userContext = app.get<IUserContext>(AUTH_USER_CONTEXT, {
129
+ strict: false,
130
+ });
131
+ if (!userContext) {
132
+ // eslint-disable-next-line no-console
133
+ console.warn(
134
+ '[auth] installRequesterContext: AUTH_USER_CONTEXT is not bound — ' +
135
+ 'request scoping NOT installed. Provide an IUserContext under ' +
136
+ 'AUTH_USER_CONTEXT in your AppModule to enable ambient tenant scoping.',
137
+ );
138
+ return;
139
+ }
140
+ app.use(makeRequesterContextMiddleware(userContext, options));
141
+ }
@@ -17,6 +17,23 @@
17
17
  * dependency on `express` / `fastify` / NestJS request types. The concrete
18
18
  * adapter narrows it (e.g. via a `Request` import).
19
19
  */
20
+ import type { RequesterContext } from '../../../base-classes/tenant-context';
21
+
20
22
  export interface IUserContext {
21
23
  getCurrentUserId(req: unknown): Promise<string>;
24
+ /**
25
+ * Optional richer resolution of the full ambient requester context — the
26
+ * org/superuser dimensions on top of `userId`. When implemented, the
27
+ * `RequesterContextMiddleware` (see `../middleware/requester-context`) uses
28
+ * it verbatim to scope reads/writes; when omitted, the middleware falls back
29
+ * to `{ userId: await getCurrentUserId(req), organizationId: null }` (plain
30
+ * `'user'` scope).
31
+ *
32
+ * Implement this when the app supports org-shared (`'org'`) or admin
33
+ * (`'superuser'`) data visibility — resolve `organizationId` + the
34
+ * `orgUserIds` member list here, at the trust boundary, so repositories stay
35
+ * single-table. AUTHORIZATION (which scope a requester may claim) is the
36
+ * implementation's responsibility; the repo trusts what this returns.
37
+ */
38
+ resolveRequester?(req: unknown): Promise<RequesterContext>;
22
39
  }
@@ -149,6 +149,11 @@ export class BridgeModule implements OnModuleInit {
149
149
 
150
150
  async onModuleInit(): Promise<void> {
151
151
  if (!this.workerOpts) return;
152
+ // BULLMQ-1 Phase 1 — `allPools: true` activates every pool (reserved
153
+ // `events_*` included), so the reserved-pool guarantee holds by
154
+ // construction. Short-circuit pass without inspecting the (typically
155
+ // omitted) explicit `pools` list.
156
+ if (this.workerOpts.allPools) return;
152
157
  const activePools = this.workerOpts.pools ?? [];
153
158
  const missing = BRIDGE_RESERVED_POOLS.filter(
154
159
  (p) => !activePools.includes(p),
@@ -29,10 +29,20 @@
29
29
  * via EventsModule.forRoot({ backend: '...' }) without touching use cases.
30
30
  */
31
31
  import { Injectable, OnModuleDestroy, OnModuleInit, Inject, Logger, Optional } from '@nestjs/common';
32
- import { eq, and, inArray, asc, type SQL } from 'drizzle-orm';
32
+ import { eq, and, inArray, asc, desc, gte, lt, or, sql, type SQL } from 'drizzle-orm';
33
33
  import type { DomainEvent, DrizzleTransaction, IEventBus } from './event-bus.protocol';
34
+ import type {
35
+ EventPage,
36
+ IEventReadPort,
37
+ ListEventsQuery,
38
+ } from './event-read.protocol';
39
+ import {
40
+ clampEventLimit,
41
+ decodeEventCursor,
42
+ encodeEventCursor,
43
+ } from './event-keyset-cursor';
34
44
  import type { DrizzleClient } from '../../types/drizzle';
35
- import { domainEvents } from './domain-events.schema';
45
+ import { domainEvents, type DomainEventRecord } from './domain-events.schema';
36
46
  import { DRIZZLE } from '../../constants/tokens';
37
47
  import { EVENTS_MODULE_OPTIONS } from './events.tokens';
38
48
  import type { EventsModuleOptions } from './events.module';
@@ -76,8 +86,43 @@ function toInsertValues(event: DomainEvent) {
76
86
  };
77
87
  }
78
88
 
89
+ /**
90
+ * Project a raw `domain_events` row into the narrow `EventSummary` shape.
91
+ * Shared with the memory backend via this helper kept module-local to each
92
+ * backend (the events subsystem has no cross-backend projection file yet;
93
+ * the two are byte-identical and small).
94
+ */
95
+ function toEventSummary(r: DomainEventRecord) {
96
+ const metadata = (r.metadata ?? undefined) as
97
+ | Record<string, unknown>
98
+ | undefined;
99
+ const rootRunId = metadata?.['rootRunId'];
100
+ return {
101
+ id: r.id,
102
+ type: r.type,
103
+ aggregateId: r.aggregateId,
104
+ aggregateType: r.aggregateType,
105
+ status: r.status,
106
+ pool: r.pool,
107
+ direction: r.direction,
108
+ tier: r.tier,
109
+ rootRunId: typeof rootRunId === 'string' ? rootRunId : null,
110
+ tenantId: r.tenantId,
111
+ occurredAt:
112
+ r.occurredAt instanceof Date
113
+ ? r.occurredAt
114
+ : new Date(r.occurredAt as unknown as string),
115
+ processedAt:
116
+ r.processedAt == null
117
+ ? null
118
+ : r.processedAt instanceof Date
119
+ ? r.processedAt
120
+ : new Date(r.processedAt as unknown as string),
121
+ };
122
+ }
123
+
79
124
  @Injectable()
80
- export class DrizzleEventBus implements IEventBus, OnModuleInit, OnModuleDestroy {
125
+ export class DrizzleEventBus implements IEventBus, IEventReadPort, OnModuleInit, OnModuleDestroy {
81
126
  private readonly logger = new Logger(DrizzleEventBus.name);
82
127
  private polling = false;
83
128
  private pollTimer: ReturnType<typeof setTimeout> | null = null;
@@ -178,6 +223,67 @@ export class DrizzleEventBus implements IEventBus, OnModuleInit, OnModuleDestroy
178
223
  };
179
224
  }
180
225
 
226
+ // ============================================================================
227
+ // IEventReadPort (OBS-LIST-1)
228
+ // ============================================================================
229
+
230
+ async listEvents(query: ListEventsQuery = {}): Promise<EventPage> {
231
+ const limit = clampEventLimit(query.limit);
232
+ const conditions: SQL<unknown>[] = [];
233
+
234
+ if (query.poolId) conditions.push(eq(domainEvents.pool, query.poolId));
235
+ if (query.direction)
236
+ conditions.push(eq(domainEvents.direction, query.direction));
237
+ if (query.since) conditions.push(gte(domainEvents.occurredAt, query.since));
238
+ if (query.rootRunId) {
239
+ // Filter on the JSON correlation id: metadata->>'rootRunId'.
240
+ conditions.push(
241
+ sql`${domainEvents.metadata}->>'rootRunId' = ${query.rootRunId}`,
242
+ );
243
+ }
244
+ if (query.tenantId !== undefined) {
245
+ conditions.push(
246
+ query.tenantId === null
247
+ ? (sql`${domainEvents.tenantId} is null` as SQL<unknown>)
248
+ : eq(domainEvents.tenantId, query.tenantId),
249
+ );
250
+ }
251
+
252
+ // Keyset seek: WHERE (occurred_at, id) < (cursorOccurredAt, cursorId).
253
+ if (query.cursor) {
254
+ const keyset = decodeEventCursor(query.cursor);
255
+ if (keyset) {
256
+ conditions.push(
257
+ or(
258
+ lt(domainEvents.occurredAt, keyset.occurredAt),
259
+ and(
260
+ eq(domainEvents.occurredAt, keyset.occurredAt),
261
+ lt(domainEvents.id, keyset.id),
262
+ ),
263
+ )!,
264
+ );
265
+ }
266
+ }
267
+
268
+ const rows = (await this.db
269
+ .select()
270
+ .from(domainEvents)
271
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
272
+ .orderBy(desc(domainEvents.occurredAt), desc(domainEvents.id))
273
+ .limit(limit + 1)) as DomainEventRecord[];
274
+
275
+ const hasMore = rows.length > limit;
276
+ const page = hasMore ? rows.slice(0, limit) : rows;
277
+ const items = page.map(toEventSummary);
278
+ const last = page[page.length - 1];
279
+ const nextCursor =
280
+ hasMore && last
281
+ ? encodeEventCursor({ occurredAt: last.occurredAt, id: last.id })
282
+ : null;
283
+
284
+ return { items, nextCursor };
285
+ }
286
+
181
287
  // ============================================================================
182
288
  // Polling
183
289
  // ============================================================================
@@ -21,11 +21,52 @@
21
21
  */
22
22
  import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
23
23
  import type { DomainEvent, IEventBus } from './event-bus.protocol';
24
+ import type {
25
+ EventPage,
26
+ EventSummary,
27
+ IEventReadPort,
28
+ ListEventsQuery,
29
+ } from './event-read.protocol';
30
+ import {
31
+ clampEventLimit,
32
+ decodeEventCursor,
33
+ encodeEventCursor,
34
+ } from './event-keyset-cursor';
24
35
  import { EVENTS_MODULE_OPTIONS } from './events.tokens';
25
36
  import type { EventsModuleOptions } from './events.module';
26
37
 
38
+ /**
39
+ * Project an in-memory `DomainEvent` into the narrow `EventSummary` shape.
40
+ * The memory backend has no first-class columns, so `pool` / `direction` /
41
+ * `tier` / `tenantId` / `rootRunId` are read from `metadata` (mirroring how
42
+ * the Drizzle backend stamps them onto columns at publish time). `status`
43
+ * is reported as `'processed'` — the memory bus dispatches synchronously,
44
+ * so once an event is in `publishedEvents` it has been handled.
45
+ */
46
+ function toEventSummary(event: DomainEvent): EventSummary {
47
+ const metadata = event.metadata;
48
+ const str = (key: string): string | null => {
49
+ const v = metadata?.[key];
50
+ return typeof v === 'string' ? v : null;
51
+ };
52
+ return {
53
+ id: event.id,
54
+ type: event.type,
55
+ aggregateId: event.aggregateId,
56
+ aggregateType: event.aggregateType,
57
+ status: 'processed',
58
+ pool: str('pool'),
59
+ direction: str('direction'),
60
+ tier: str('tier') ?? 'domain',
61
+ rootRunId: str('rootRunId'),
62
+ tenantId: str('tenantId'),
63
+ occurredAt: event.occurredAt,
64
+ processedAt: event.occurredAt,
65
+ };
66
+ }
67
+
27
68
  @Injectable()
28
- export class MemoryEventBus implements IEventBus {
69
+ export class MemoryEventBus implements IEventBus, IEventReadPort {
29
70
  private readonly logger = new Logger(MemoryEventBus.name);
30
71
 
31
72
  /** All events published since construction (or last clear). */
@@ -86,6 +127,67 @@ export class MemoryEventBus implements IEventBus {
86
127
  };
87
128
  }
88
129
 
130
+ // ============================================================================
131
+ // IEventReadPort (OBS-LIST-1)
132
+ // ============================================================================
133
+
134
+ async listEvents(query: ListEventsQuery = {}): Promise<EventPage> {
135
+ const limit = clampEventLimit(query.limit);
136
+ const keyset = query.cursor ? decodeEventCursor(query.cursor) : null;
137
+
138
+ const str = (e: DomainEvent, key: string): string | null => {
139
+ const v = e.metadata?.[key];
140
+ return typeof v === 'string' ? v : null;
141
+ };
142
+
143
+ const matched = this.publishedEvents.filter((e) => {
144
+ if (query.poolId && str(e, 'pool') !== query.poolId) return false;
145
+ if (query.direction && str(e, 'direction') !== query.direction)
146
+ return false;
147
+ if (query.rootRunId && str(e, 'rootRunId') !== query.rootRunId)
148
+ return false;
149
+ if (query.since && e.occurredAt.getTime() < query.since.getTime())
150
+ return false;
151
+ if (query.tenantId !== undefined) {
152
+ const t = str(e, 'tenantId');
153
+ if (query.tenantId === null) {
154
+ if (t !== null) return false;
155
+ } else if (t !== query.tenantId) {
156
+ return false;
157
+ }
158
+ }
159
+ return true;
160
+ });
161
+
162
+ // Order occurred_at DESC, id DESC to match the Drizzle backend keyset.
163
+ matched.sort((a, b) => {
164
+ const dt = b.occurredAt.getTime() - a.occurredAt.getTime();
165
+ if (dt !== 0) return dt;
166
+ return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
167
+ });
168
+
169
+ const seeked = keyset
170
+ ? matched.filter((e) => {
171
+ const ct = e.occurredAt.getTime();
172
+ const kt = keyset.occurredAt.getTime();
173
+ if (ct < kt) return true;
174
+ if (ct > kt) return false;
175
+ return e.id < keyset.id;
176
+ })
177
+ : matched;
178
+
179
+ const hasMore = seeked.length > limit;
180
+ const page = hasMore ? seeked.slice(0, limit) : seeked;
181
+ const items = page.map(toEventSummary);
182
+ const last = page[page.length - 1];
183
+ const nextCursor =
184
+ hasMore && last
185
+ ? encodeEventCursor({ occurredAt: last.occurredAt, id: last.id })
186
+ : null;
187
+
188
+ return { items, nextCursor };
189
+ }
190
+
89
191
  /** Remove all published events and subscriptions. Useful in beforeEach. */
90
192
  clear(): void {
91
193
  this.publishedEvents.length = 0;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Keyset (seek) cursor codec for `IEventReadPort.listEvents` (OBS-LIST-1).
3
+ *
4
+ * The list is ordered `occurred_at DESC, id DESC`. The cursor encodes the
5
+ * `(occurredAt, id)` of the last row on the previous page so the next page
6
+ * seeks with `WHERE (occurred_at, id) < (cursorOccurredAt, cursorId)`.
7
+ *
8
+ * The cursor is opaque to consumers: a base64url-encoded JSON tuple. Shape
9
+ * is an implementation detail — never parse it outside this module.
10
+ *
11
+ * Mirrors the jobs keyset codec; kept separate because the events subsystem
12
+ * must not depend on `runtime/subsystems/jobs/`.
13
+ */
14
+
15
+ export interface EventKeyset {
16
+ occurredAt: Date;
17
+ id: string;
18
+ }
19
+
20
+ /** Default page size when `limit` is omitted. */
21
+ export const DEFAULT_EVENT_LIST_LIMIT = 50;
22
+ /** Hard upper bound on page size. */
23
+ export const MAX_EVENT_LIST_LIMIT = 200;
24
+
25
+ /** Clamp a caller-supplied `limit` into `[1, MAX_EVENT_LIST_LIMIT]`. */
26
+ export function clampEventLimit(limit: number | undefined): number {
27
+ if (typeof limit !== 'number' || !Number.isFinite(limit)) {
28
+ return DEFAULT_EVENT_LIST_LIMIT;
29
+ }
30
+ const floored = Math.floor(limit);
31
+ if (floored < 1) return 1;
32
+ if (floored > MAX_EVENT_LIST_LIMIT) return MAX_EVENT_LIST_LIMIT;
33
+ return floored;
34
+ }
35
+
36
+ export function encodeEventCursor(keyset: EventKeyset): string {
37
+ const tuple = [keyset.occurredAt.toISOString(), keyset.id];
38
+ return Buffer.from(JSON.stringify(tuple), 'utf8').toString('base64url');
39
+ }
40
+
41
+ /**
42
+ * Decode an opaque cursor back into its `(occurredAt, id)` keyset. Returns
43
+ * `null` for malformed input so user-supplied garbage is treated as "start
44
+ * from the beginning" rather than throwing.
45
+ */
46
+ export function decodeEventCursor(cursor: string): EventKeyset | null {
47
+ try {
48
+ const json = Buffer.from(cursor, 'base64url').toString('utf8');
49
+ const parsed = JSON.parse(json) as unknown;
50
+ if (!Array.isArray(parsed) || parsed.length !== 2) return null;
51
+ const [iso, id] = parsed;
52
+ if (typeof iso !== 'string' || typeof id !== 'string') return null;
53
+ const occurredAt = new Date(iso);
54
+ if (Number.isNaN(occurredAt.getTime())) return null;
55
+ return { occurredAt, id };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * IEventReadPort — read-side port over `domain_events` (OBS-LIST-1).
3
+ *
4
+ * The publish/subscribe `IEventBus` (EVENT_BUS) is a *write + dispatch*
5
+ * port; it deliberately does not expose tabular reads beyond `findById`.
6
+ * The observability combiner needs a paginated, filterable list of
7
+ * `domain_events` for its events viewer, so we add a dedicated read port
8
+ * rather than overloading `IEventBus`.
9
+ *
10
+ * Keeping reads on a separate port means:
11
+ * - the combiner can compose it `@Optional()` independently of EVENT_BUS;
12
+ * - the Redis backend (which retains no history) simply does not provide
13
+ * it — there is no "list" semantics to fake;
14
+ * - the write/dispatch surface stays minimal.
15
+ *
16
+ * Both `DrizzleEventBus` and `MemoryEventBus` implement this port (they
17
+ * already hold the rows / in-memory log); `EventsModule.forRoot` binds the
18
+ * `EVENT_READ_PORT` token to the same instance for drizzle/memory backends.
19
+ */
20
+
21
+ import type { DomainEvent } from './event-bus.protocol';
22
+
23
+ /**
24
+ * Filter + keyset-pagination input for `IEventReadPort.listEvents`.
25
+ *
26
+ * Ordered `occurred_at DESC, id DESC`. `rootRunId` filters on the JSON
27
+ * `metadata->>'rootRunId'` — the correlation id stamped by the jobs/bridge
28
+ * machinery so an event can be traced back to the run tree that emitted it.
29
+ */
30
+ export interface ListEventsQuery {
31
+ /** Filter on `metadata->>'rootRunId'` (correlation id). */
32
+ rootRunId?: string;
33
+ /** Filter on the first-class `pool` column. */
34
+ poolId?: string;
35
+ /** Filter on the first-class `direction` column (inbound|change|outbound). */
36
+ direction?: string;
37
+ /** Lower bound on `occurred_at` (inclusive). */
38
+ since?: Date;
39
+ /** Opaque keyset cursor from a previous page's `nextCursor`. */
40
+ cursor?: string;
41
+ /** Page size. Backend clamps to a sane default + max. */
42
+ limit?: number;
43
+ /**
44
+ * Multi-tenancy filter on the first-class `tenant_id` column. Only
45
+ * meaningful when the consumer publishes tenant-scoped events
46
+ * (`events.multi_tenant: true`); otherwise leave undefined.
47
+ * - `string` — filter `tenant_id = :tenantId`.
48
+ * - `null` — filter `tenant_id IS NULL`.
49
+ * - `undefined` — no tenant filter.
50
+ */
51
+ tenantId?: string | null;
52
+ }
53
+
54
+ /**
55
+ * Summary row for the events list. A narrow projection over `domain_events`
56
+ * carrying what the viewer + correlation timeline need. `rootRunId` is
57
+ * surfaced (lifted out of `metadata`) so the timeline can stitch without a
58
+ * second metadata dig.
59
+ */
60
+ export interface EventSummary {
61
+ id: string;
62
+ type: string;
63
+ aggregateId: string;
64
+ aggregateType: string;
65
+ status: string;
66
+ pool: string | null;
67
+ direction: string | null;
68
+ tier: string;
69
+ rootRunId: string | null;
70
+ tenantId: string | null;
71
+ occurredAt: Date;
72
+ processedAt: Date | null;
73
+ }
74
+
75
+ /**
76
+ * One page of `listEvents` results. `nextCursor` is `null` when there are
77
+ * no more rows.
78
+ */
79
+ export interface EventPage {
80
+ items: EventSummary[];
81
+ nextCursor: string | null;
82
+ }
83
+
84
+ export interface IEventReadPort {
85
+ /**
86
+ * Paginated, filterable list of `domain_events` (OBS-LIST-1). Newest
87
+ * first (`occurred_at` desc, `id` desc keyset tie-break). Returns an
88
+ * `EventPage` with an opaque `nextCursor` for keyset pagination.
89
+ */
90
+ listEvents(query?: ListEventsQuery): Promise<EventPage>;
91
+ }
92
+
93
+ /** A `DomainEvent` whose metadata may carry a `rootRunId` correlation id. */
94
+ export function rootRunIdOf(event: DomainEvent): string | null {
95
+ const v = event.metadata?.['rootRunId'];
96
+ return typeof v === 'string' ? v : null;
97
+ }
@@ -47,6 +47,7 @@
47
47
  import { Module, type DynamicModule, type Provider } from '@nestjs/common';
48
48
  import {
49
49
  EVENT_BUS,
50
+ EVENT_READ_PORT,
50
51
  EVENTS_MODULE_OPTIONS,
51
52
  EVENTS_MULTI_TENANT,
52
53
  REDIS_URL,
@@ -180,10 +181,21 @@ export class EventsModule {
180
181
  REDIS_URL,
181
182
  ],
182
183
  },
184
+ {
185
+ // Read port (OBS-LIST-1). Drizzle + memory backends implement
186
+ // IEventReadPort on the EVENT_BUS instance; the redis backend
187
+ // retains no history, so EVENT_READ_PORT resolves to `null` and
188
+ // optional consumers (the observability combiner) degrade to
189
+ // empty results.
190
+ provide: EVENT_READ_PORT,
191
+ useFactory: (options: EventsModuleOptions, bus: unknown) =>
192
+ options.backend === 'redis' ? null : bus,
193
+ inject: [EVENTS_MODULE_OPTIONS, EVENT_BUS],
194
+ },
183
195
  TypedEventBus,
184
196
  { provide: TYPED_EVENT_BUS, useExisting: TypedEventBus },
185
197
  ],
186
- exports: [EVENT_BUS, TYPED_EVENT_BUS, EVENTS_MULTI_TENANT],
198
+ exports: [EVENT_BUS, EVENT_READ_PORT, TYPED_EVENT_BUS, EVENTS_MULTI_TENANT],
187
199
  };
188
200
  }
189
201
 
@@ -222,9 +234,13 @@ export class EventsModule {
222
234
  providers: [
223
235
  { provide: EVENTS_MODULE_OPTIONS, useValue: options },
224
236
  provider,
237
+ // Read port (OBS-LIST-1): drizzle + memory backends implement
238
+ // IEventReadPort on the same instance as EVENT_BUS. The redis
239
+ // backend retains no history and does not provide this token.
240
+ { provide: EVENT_READ_PORT, useExisting: EVENT_BUS },
225
241
  ...buildTypedBusProviders(multiTenant),
226
242
  ],
227
- exports: [EVENT_BUS, TYPED_EVENT_BUS, EVENTS_MULTI_TENANT],
243
+ exports: [EVENT_BUS, EVENT_READ_PORT, TYPED_EVENT_BUS, EVENTS_MULTI_TENANT],
228
244
  };
229
245
  }
230
246
  }
@@ -11,6 +11,22 @@
11
11
  */
12
12
  export const EVENT_BUS = 'EVENT_BUS' as const;
13
13
 
14
+ /**
15
+ * Injection token for the read-side `IEventReadPort` over `domain_events`
16
+ * (OBS-LIST-1).
17
+ *
18
+ * Bound by `EventsModule.forRoot` to the same backend instance as
19
+ * `EVENT_BUS` for the `drizzle` and `memory` backends (both implement
20
+ * `IEventReadPort`). The `redis` backend retains no history and therefore
21
+ * does NOT provide this token — consumers composing it (e.g. the
22
+ * observability combiner) inject it `@Optional()` and degrade to empty
23
+ * results.
24
+ *
25
+ * String constant (not Symbol) so it matches by value across import
26
+ * boundaries — same convention as `EVENT_BUS`.
27
+ */
28
+ export const EVENT_READ_PORT = 'EVENT_READ_PORT' as const;
29
+
14
30
  /**
15
31
  * Injection token for the generated `TypedEventBus` facade.
16
32
  *