@pattern-stack/codegen 0.4.1 → 0.4.3

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 (158) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/runtime/subsystems/bridge/bridge.module.d.ts +1 -0
  3. package/dist/runtime/subsystems/bridge/bridge.module.js +38 -21
  4. package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
  5. package/dist/runtime/subsystems/bridge/index.d.ts +1 -0
  6. package/dist/runtime/subsystems/bridge/index.js +29 -12
  7. package/dist/runtime/subsystems/bridge/index.js.map +1 -1
  8. package/dist/runtime/subsystems/index.js +31 -14
  9. package/dist/runtime/subsystems/index.js.map +1 -1
  10. package/dist/runtime/subsystems/jobs/index.d.ts +1 -0
  11. package/dist/runtime/subsystems/jobs/index.js +27 -10
  12. package/dist/runtime/subsystems/jobs/index.js.map +1 -1
  13. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.d.ts +3 -1
  14. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +9 -4
  15. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js.map +1 -1
  16. package/dist/runtime/subsystems/jobs/job-worker.d.ts +3 -1
  17. package/dist/runtime/subsystems/jobs/job-worker.js +6 -2
  18. package/dist/runtime/subsystems/jobs/job-worker.js.map +1 -1
  19. package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +3 -1
  20. package/dist/runtime/subsystems/jobs/job-worker.module.js +27 -10
  21. package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
  22. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +9 -4
  23. package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
  24. package/dist/src/cli/index.js +29 -2
  25. package/dist/src/cli/index.js.map +1 -1
  26. package/package.json +2 -1
  27. package/runtime/analytics/index.ts +31 -0
  28. package/runtime/analytics/metrics.ts +85 -0
  29. package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
  30. package/runtime/analytics/packs/index.ts +5 -0
  31. package/runtime/analytics/packs/monetary-measures.ts +20 -0
  32. package/runtime/analytics/specs.ts +54 -0
  33. package/runtime/analytics/types.ts +105 -0
  34. package/runtime/base-classes/activity-entity-repository.ts +50 -0
  35. package/runtime/base-classes/activity-entity-service.ts +48 -0
  36. package/runtime/base-classes/base-read-use-cases.ts +88 -0
  37. package/runtime/base-classes/base-repository.ts +289 -0
  38. package/runtime/base-classes/base-service.ts +183 -0
  39. package/runtime/base-classes/index.ts +38 -0
  40. package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
  41. package/runtime/base-classes/knowledge-entity-service.ts +14 -0
  42. package/runtime/base-classes/lifecycle-events.ts +152 -0
  43. package/runtime/base-classes/metadata-entity-repository.ts +80 -0
  44. package/runtime/base-classes/metadata-entity-service.ts +48 -0
  45. package/runtime/base-classes/synced-entity-repository.ts +57 -0
  46. package/runtime/base-classes/synced-entity-service.ts +50 -0
  47. package/runtime/base-classes/with-analytics.ts +22 -0
  48. package/runtime/constants/tokens.ts +29 -0
  49. package/runtime/eav-helpers.ts +74 -0
  50. package/runtime/pipes/zod-validation.pipe.ts +64 -0
  51. package/runtime/shared/openapi/error-response.dto.ts +24 -0
  52. package/runtime/shared/openapi/errors.ts +39 -0
  53. package/runtime/shared/openapi/index.ts +20 -0
  54. package/runtime/shared/openapi/registry.tokens.ts +13 -0
  55. package/runtime/shared/openapi/registry.ts +151 -0
  56. package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
  57. package/runtime/subsystems/analytics/analytics.module.ts +64 -0
  58. package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
  59. package/runtime/subsystems/analytics/cube-backend.ts +75 -0
  60. package/runtime/subsystems/analytics/index.ts +15 -0
  61. package/runtime/subsystems/analytics/noop-backend.ts +27 -0
  62. package/runtime/subsystems/auth/auth.module.ts +91 -0
  63. package/runtime/subsystems/auth/auth.tokens.ts +27 -0
  64. package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
  65. package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
  66. package/runtime/subsystems/auth/index.ts +77 -0
  67. package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
  68. package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
  69. package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
  70. package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
  71. package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
  72. package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
  73. package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
  74. package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
  75. package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
  76. package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
  77. package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
  78. package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
  79. package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
  80. package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
  81. package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
  82. package/runtime/subsystems/bridge/bridge.module.ts +160 -0
  83. package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
  84. package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
  85. package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
  86. package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
  87. package/runtime/subsystems/bridge/generated/registry.ts +6 -0
  88. package/runtime/subsystems/bridge/index.ts +84 -0
  89. package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
  90. package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
  91. package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
  92. package/runtime/subsystems/cache/cache.module.ts +115 -0
  93. package/runtime/subsystems/cache/cache.protocol.ts +45 -0
  94. package/runtime/subsystems/cache/cache.schema.ts +27 -0
  95. package/runtime/subsystems/cache/cache.tokens.ts +17 -0
  96. package/runtime/subsystems/cache/index.ts +22 -0
  97. package/runtime/subsystems/events/domain-events.schema.ts +77 -0
  98. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
  99. package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
  100. package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
  101. package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
  102. package/runtime/subsystems/events/events-errors.ts +30 -0
  103. package/runtime/subsystems/events/events.module.ts +230 -0
  104. package/runtime/subsystems/events/events.tokens.ts +62 -0
  105. package/runtime/subsystems/events/generated/bus.ts +103 -0
  106. package/runtime/subsystems/events/generated/index.ts +7 -0
  107. package/runtime/subsystems/events/generated/registry.ts +84 -0
  108. package/runtime/subsystems/events/generated/schemas.ts +59 -0
  109. package/runtime/subsystems/events/generated/types.ts +94 -0
  110. package/runtime/subsystems/events/index.ts +21 -0
  111. package/runtime/subsystems/index.ts +63 -0
  112. package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
  113. package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
  114. package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
  115. package/runtime/subsystems/jobs/index.ts +120 -0
  116. package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
  117. package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
  118. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
  119. package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +860 -0
  120. package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
  121. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
  122. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
  123. package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
  124. package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
  125. package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
  126. package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
  127. package/runtime/subsystems/jobs/job-worker.module.ts +312 -0
  128. package/runtime/subsystems/jobs/job-worker.ts +624 -0
  129. package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
  130. package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
  131. package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
  132. package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
  133. package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
  134. package/runtime/subsystems/storage/index.ts +18 -0
  135. package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
  136. package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
  137. package/runtime/subsystems/storage/storage.module.ts +60 -0
  138. package/runtime/subsystems/storage/storage.protocol.ts +78 -0
  139. package/runtime/subsystems/storage/storage.tokens.ts +9 -0
  140. package/runtime/subsystems/storage/storage.utils.ts +20 -0
  141. package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
  142. package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
  143. package/runtime/subsystems/sync/index.ts +98 -0
  144. package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
  145. package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
  146. package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
  147. package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
  148. package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
  149. package/runtime/subsystems/sync/sync-errors.ts +54 -0
  150. package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
  151. package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
  152. package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
  153. package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
  154. package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
  155. package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
  156. package/runtime/subsystems/sync/sync.module.ts +156 -0
  157. package/runtime/subsystems/sync/sync.tokens.ts +57 -0
  158. package/runtime/types/drizzle.ts +23 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * CacheModule — DynamicModule factory for the cache subsystem.
3
+ *
4
+ * Usage in AppModule:
5
+ * ```typescript
6
+ * CacheModule.forRoot({ backend: 'drizzle', defaultTtl: 300 })
7
+ * ```
8
+ *
9
+ * Usage in tests:
10
+ * ```typescript
11
+ * CacheModule.forRoot({ backend: 'memory' })
12
+ * ```
13
+ *
14
+ * `global: true` means any module that needs ICacheService can inject CACHE
15
+ * directly without importing CacheModule. Register once in AppModule.
16
+ *
17
+ * The drizzle backend requires DRIZZLE to be provided globally (e.g., via DatabaseModule).
18
+ *
19
+ * Async configuration (`forRootAsync`):
20
+ * The async factory returns `CacheModuleOptions`; the CACHE provider then
21
+ * receives DRIZZLE (for the drizzle backend) through Nest DI rather than
22
+ * hand-constructing with `null` — see issue #108 which flagged the same
23
+ * shape in `EventsModule.forRootAsync`. DRIZZLE is injected as optional
24
+ * so memory-backend consumers are not required to wire DatabaseModule.
25
+ */
26
+ import { Module, type DynamicModule } from '@nestjs/common';
27
+ import { CACHE, CACHE_DEFAULT_TTL } from './cache.tokens';
28
+ import { DRIZZLE } from '../../constants/tokens';
29
+ import type { DrizzleClient } from '../../types/drizzle';
30
+ import { DrizzleCacheService } from './cache.drizzle-backend';
31
+ import { MemoryCacheService } from './cache.memory-backend';
32
+
33
+ export interface CacheModuleOptions {
34
+ backend: 'drizzle' | 'memory';
35
+ /** Default TTL in seconds for entries that don't specify their own TTL. Null = no expiry. */
36
+ defaultTtl?: number;
37
+ }
38
+
39
+ export interface CacheModuleAsyncOptions {
40
+ useFactory: (...args: unknown[]) => Promise<CacheModuleOptions> | CacheModuleOptions;
41
+ inject?: unknown[];
42
+ imports?: unknown[];
43
+ }
44
+
45
+ /** String token for the resolved CacheModuleOptions in the async path. */
46
+ const CACHE_MODULE_OPTIONS = 'CACHE_MODULE_OPTIONS' as const;
47
+
48
+ function buildCacheAsync(
49
+ options: CacheModuleOptions,
50
+ db: DrizzleClient | null,
51
+ ): DrizzleCacheService | MemoryCacheService {
52
+ const defaultTtl = options.defaultTtl ?? null;
53
+ if (options.backend === 'drizzle') {
54
+ if (!db) {
55
+ throw new Error(
56
+ "CacheModule.forRootAsync: backend: 'drizzle' selected but DRIZZLE provider is not available. " +
57
+ 'Ensure DatabaseModule (or another provider exposing DRIZZLE) is imported before CacheModule.forRootAsync.',
58
+ );
59
+ }
60
+ return new DrizzleCacheService(db, defaultTtl);
61
+ }
62
+ return new MemoryCacheService(defaultTtl);
63
+ }
64
+
65
+ @Module({})
66
+ export class CacheModule {
67
+ static forRootAsync(asyncOptions: CacheModuleAsyncOptions): DynamicModule {
68
+ return {
69
+ module: CacheModule,
70
+ global: true,
71
+ imports: (asyncOptions.imports ?? []) as Parameters<typeof Module>[0]['imports'],
72
+ providers: [
73
+ {
74
+ provide: CACHE_MODULE_OPTIONS,
75
+ useFactory: asyncOptions.useFactory,
76
+ inject: (asyncOptions.inject ?? []) as (string | symbol | Function)[],
77
+ },
78
+ {
79
+ provide: CACHE,
80
+ useFactory: (options: CacheModuleOptions, db: DrizzleClient | null) =>
81
+ buildCacheAsync(options, db),
82
+ inject: [CACHE_MODULE_OPTIONS, { token: DRIZZLE, optional: true }],
83
+ },
84
+ // Alias the concrete classes to CACHE for typed injection.
85
+ { provide: DrizzleCacheService, useExisting: CACHE },
86
+ { provide: MemoryCacheService, useExisting: CACHE },
87
+ ],
88
+ exports: [CACHE],
89
+ };
90
+ }
91
+
92
+ static forRoot(options: CacheModuleOptions = { backend: 'drizzle' }): DynamicModule {
93
+ const ConcreteClass = options.backend === 'drizzle' ? DrizzleCacheService : MemoryCacheService;
94
+
95
+ const providers = options.defaultTtl !== undefined
96
+ ? [
97
+ // Register the concrete class as the canonical instance
98
+ ConcreteClass,
99
+ { provide: CACHE_DEFAULT_TTL, useValue: options.defaultTtl },
100
+ // CACHE token points to the same instance — no duplicate
101
+ { provide: CACHE, useExisting: ConcreteClass },
102
+ ]
103
+ : [
104
+ ConcreteClass,
105
+ { provide: CACHE, useExisting: ConcreteClass },
106
+ ];
107
+
108
+ return {
109
+ module: CacheModule,
110
+ global: true,
111
+ providers,
112
+ exports: [CACHE],
113
+ };
114
+ }
115
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * ICacheService — the cache port.
3
+ *
4
+ * Per ADR-003:
5
+ * - Cache reads (get, has) are NOT side effects — services may call them.
6
+ * - Cache writes (set, delete, invalidateByPrefix) ARE side effects — use cases only.
7
+ *
8
+ * Error behavior:
9
+ * - get() returns null on any error (cache miss semantics; never throws for reads).
10
+ * - has() returns false on any error.
11
+ * - set(), delete(), invalidateByPrefix() throw on failure.
12
+ */
13
+ export interface ICacheService {
14
+ /** Read a cached value. Returns null on miss or error. */
15
+ get<T = unknown>(key: string): Promise<T | null>;
16
+
17
+ /** Write a value to cache with an optional TTL in seconds. */
18
+ set<T = unknown>(key: string, value: T, ttlSeconds?: number): Promise<void>;
19
+
20
+ /** Delete a single cache entry by key. */
21
+ delete(key: string): Promise<void>;
22
+
23
+ /**
24
+ * Delete all entries whose key starts with the given prefix.
25
+ * Returns the number of entries deleted.
26
+ *
27
+ * Example: invalidateByPrefix('contact:') removes all contact cache entries.
28
+ */
29
+ invalidateByPrefix(prefix: string): Promise<number>;
30
+
31
+ /** Check whether a non-expired entry exists for the given key. Returns false on error. */
32
+ has(key: string): Promise<boolean>;
33
+
34
+ /**
35
+ * Return the cached value for `key`, or compute it via `factory` and store it.
36
+ *
37
+ * Stampede protection: concurrent calls for the same key that miss the cache
38
+ * will share the same in-flight promise — the factory is invoked only once.
39
+ *
40
+ * @param key - Cache key
41
+ * @param factory - Async function that computes the value on cache miss
42
+ * @param ttlSeconds - Optional TTL; falls back to the module-configured default
43
+ */
44
+ getOrSet<T = unknown>(key: string, factory: () => Promise<T>, ttlSeconds?: number): Promise<T>;
45
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Drizzle schema for the cache_entries table.
3
+ *
4
+ * This table backs the DrizzleCacheService. TTL is enforced by filtering
5
+ * on expiresAt at read time; a periodic cleanup job removes stale rows.
6
+ *
7
+ * Indexes:
8
+ * - PRIMARY KEY on key (point-lookup)
9
+ * - (expiresAt) for the cleanup query
10
+ */
11
+ import { pgTable, text, jsonb, timestamp } from 'drizzle-orm/pg-core';
12
+ import type { InferSelectModel } from 'drizzle-orm';
13
+
14
+ export const cacheEntries = pgTable(
15
+ 'cache_entries',
16
+ {
17
+ /** Cache key — primary key, text (not uuid) to support arbitrary key namespacing. */
18
+ key: text('key').primaryKey(),
19
+ /** Cached value serialised as JSONB. */
20
+ value: jsonb('value').notNull(),
21
+ /** NULL means the entry never expires. */
22
+ expiresAt: timestamp('expires_at', { withTimezone: true }),
23
+ },
24
+ // Index: add (expires_at) via migration for cleanup queries
25
+ );
26
+
27
+ export type CacheEntry = InferSelectModel<typeof cacheEntries>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Injection token for the cache service.
3
+ *
4
+ * Usage in use cases:
5
+ * ```typescript
6
+ * constructor(@Inject(CACHE) private readonly cache: ICacheService) {}
7
+ * ```
8
+ *
9
+ * Services may also inject CACHE for reads (get, has) per ADR-003.
10
+ */
11
+ export const CACHE = Symbol('CACHE');
12
+
13
+ /**
14
+ * Injection token for the default TTL (in seconds) passed from CacheModule.forRoot().
15
+ * Optional — omit for no-expiry behavior.
16
+ */
17
+ export const CACHE_DEFAULT_TTL = Symbol('CACHE_DEFAULT_TTL');
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Cache subsystem public API.
3
+ *
4
+ * Import the token and protocol in use cases and services:
5
+ * ```typescript
6
+ * import { CACHE, type ICacheService } from '@shared/subsystems/cache';
7
+ * ```
8
+ *
9
+ * Import the module in AppModule:
10
+ * ```typescript
11
+ * import { CacheModule } from '@shared/subsystems/cache';
12
+ * CacheModule.forRoot({ backend: 'drizzle', defaultTtl: 300 })
13
+ * ```
14
+ */
15
+ export { CACHE } from './cache.tokens';
16
+ export type { ICacheService } from './cache.protocol';
17
+ export { CacheModule } from './cache.module';
18
+ export type { CacheModuleOptions } from './cache.module';
19
+ export { cacheEntries } from './cache.schema';
20
+ export type { CacheEntry } from './cache.schema';
21
+ export { DrizzleCacheService, CACHE_DEFAULT_TTL } from './cache.drizzle-backend';
22
+ export { MemoryCacheService } from './cache.memory-backend';
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Drizzle schema for the domain_events outbox table.
3
+ *
4
+ * This table backs the DrizzleEventBus. Events are inserted within the
5
+ * same database transaction as the domain write (outbox pattern). A
6
+ * polling process reads unprocessed rows and dispatches to subscribers.
7
+ *
8
+ * First-class routing columns (EVT-1):
9
+ * - `pool` — populated by DrizzleEventBus.publish() (EVT-4); enables
10
+ * pool-filtered drain queries without unpacking metadata JSON.
11
+ * - `direction` — `inbound` | `change` | `outbound`; mirrors the routing
12
+ * dimension used by jobs' reserved `events_inbound` /
13
+ * `events_change` / `events_outbound` pools.
14
+ * - `tenant_id` — conditional: emitted only when `events.multi_tenant: true`
15
+ * in `codegen.config.yaml`. The runtime source declares it
16
+ * unconditionally; EVT-8's scaffold template handles the
17
+ * config-driven include/exclude.
18
+ *
19
+ * The `metadata` JSON column continues to carry these values for protocol
20
+ * stability; the first-class columns are an optimization for drain filtering.
21
+ *
22
+ * Indexes (declared below in the index callback):
23
+ * - (status, occurred_at) — polling drain filter
24
+ * - (aggregate_id, aggregate_type) — event replay per aggregate
25
+ * - (pool, status, occurred_at) — per-pool drain filter (EVT-1)
26
+ */
27
+ import {
28
+ index,
29
+ jsonb,
30
+ pgTable,
31
+ text,
32
+ timestamp,
33
+ uuid,
34
+ } from 'drizzle-orm/pg-core';
35
+ import type { InferSelectModel } from 'drizzle-orm';
36
+
37
+ export const domainEvents = pgTable(
38
+ 'domain_events',
39
+ {
40
+ id: uuid('id').primaryKey(),
41
+ type: text('type').notNull(),
42
+ aggregateId: text('aggregate_id').notNull(),
43
+ aggregateType: text('aggregate_type').notNull(),
44
+ payload: jsonb('payload').notNull().$type<Record<string, unknown>>(),
45
+ occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
46
+ processedAt: timestamp('processed_at', { withTimezone: true }),
47
+ /** Lifecycle status: pending | processed | failed */
48
+ status: text('status').notNull().default('pending'),
49
+ /** Error message from the last failed dispatch attempt. */
50
+ error: text('error'),
51
+ metadata: jsonb('metadata').$type<Record<string, unknown>>(),
52
+ /** Routing pool (e.g. `events_inbound`, `events_change`, `events_outbound`). Populated by DrizzleEventBus.publish() in EVT-4. */
53
+ pool: text('pool'),
54
+ /** Routing direction: `inbound` | `change` | `outbound`. Populated by DrizzleEventBus.publish() in EVT-4. */
55
+ direction: text('direction'),
56
+ // conditional: emitted only when events.multi_tenant: true
57
+ tenantId: text('tenant_id'),
58
+ },
59
+ (t) => ({
60
+ /** Polling drain filter (existing — promoted from comment to declaration in EVT-1). */
61
+ idxDomainEventsStatusOccurredAt: index('idx_domain_events_status_occurred_at').on(
62
+ t.status,
63
+ t.occurredAt,
64
+ ),
65
+ /** Event replay per aggregate (existing — promoted from comment to declaration in EVT-1). */
66
+ idxDomainEventsAggregate: index('idx_domain_events_aggregate').on(
67
+ t.aggregateId,
68
+ t.aggregateType,
69
+ ),
70
+ /** Per-pool drain filter (EVT-1). Enables DrizzleEventBus to drain a single pool without scanning all events. */
71
+ idxDomainEventsPoolStatusOccurredAt: index(
72
+ 'idx_domain_events_pool_status_occurred_at',
73
+ ).on(t.pool, t.status, t.occurredAt),
74
+ }),
75
+ );
76
+
77
+ export type DomainEventRecord = InferSelectModel<typeof domainEvents>;
@@ -0,0 +1,327 @@
1
+ /**
2
+ * DrizzleEventBus — Postgres-backed event bus using the transactional outbox pattern.
3
+ *
4
+ * Events are inserted into the `domain_events` table within the caller's
5
+ * Drizzle transaction. A background polling loop (started on module init)
6
+ * reads unprocessed events and dispatches them to registered subscribers.
7
+ *
8
+ * When the transaction rolls back, the event is never persisted — no
9
+ * phantom events.
10
+ *
11
+ * Pool awareness (EVT-4):
12
+ * - On `publish`/`publishMany` the backend writes `metadata.pool`,
13
+ * `metadata.direction`, and `metadata.tenantId` into the first-class
14
+ * `pool` / `direction` / `tenant_id` columns (metadata JSON is still
15
+ * written unchanged for protocol stability).
16
+ * - The drain loop filters by `opts.pools` when provided, so separate
17
+ * processes (e.g. one per `events_inbound` / `events_change` /
18
+ * `events_outbound`) can claim only their own lane. `pools: undefined`
19
+ * drains all pending rows (backwards-compatible behaviour).
20
+ *
21
+ * EVT-Q7: No stale-event sweeper. `FOR UPDATE SKIP LOCKED` is
22
+ * self-healing — the row is only locked for the duration of the
23
+ * enclosing polling transaction; the `status='processed'` update happens
24
+ * within that same transaction. There is no `claimed_at` semantic (unlike
25
+ * jobs), so no stale rows can exist.
26
+ *
27
+ * This backend is suitable until you need real-time fan-out or very high
28
+ * throughput. At that point, swap the backend for Redis Streams or similar
29
+ * via EventsModule.forRoot({ backend: '...' }) without touching use cases.
30
+ */
31
+ import { Injectable, OnModuleDestroy, OnModuleInit, Inject, Logger, Optional } from '@nestjs/common';
32
+ import { eq, and, inArray, asc, type SQL } from 'drizzle-orm';
33
+ import type { DomainEvent, DrizzleTransaction, IEventBus } from './event-bus.protocol';
34
+ import type { DrizzleClient } from '../../types/drizzle';
35
+ import { domainEvents } from './domain-events.schema';
36
+ import { DRIZZLE } from '../../constants/tokens';
37
+ import { EVENTS_MODULE_OPTIONS } from './events.tokens';
38
+ import type { EventsModuleOptions } from './events.module';
39
+ import { BRIDGE_OUTBOX_DRAIN_HOOK } from '../bridge/bridge.tokens';
40
+ import type { IBridgeOutboxDrainHook } from '../bridge/bridge.protocol';
41
+
42
+ /** How long to wait between polling cycles (ms). */
43
+ const POLL_INTERVAL_MS = 1_000;
44
+ /** Max events claimed per polling cycle to bound memory usage. */
45
+ const POLL_BATCH_SIZE = 50;
46
+
47
+ /**
48
+ * Row shape built from `metadata` for writing into `domain_events`. Keeps
49
+ * the per-event extraction logic in one place so publish/publishMany stay
50
+ * in sync.
51
+ */
52
+ function toInsertValues(event: DomainEvent) {
53
+ const metadata = event.metadata ?? undefined;
54
+ const pool = (metadata?.['pool'] as string | undefined) ?? null;
55
+ const direction = (metadata?.['direction'] as string | undefined) ?? null;
56
+ const tenantId = (metadata?.['tenantId'] as string | undefined) ?? null;
57
+ return {
58
+ id: event.id,
59
+ type: event.type,
60
+ aggregateId: event.aggregateId,
61
+ aggregateType: event.aggregateType,
62
+ payload: event.payload,
63
+ occurredAt: event.occurredAt,
64
+ processedAt: null,
65
+ status: 'pending' as const,
66
+ metadata: event.metadata,
67
+ pool,
68
+ direction,
69
+ tenantId,
70
+ };
71
+ }
72
+
73
+ @Injectable()
74
+ export class DrizzleEventBus implements IEventBus, OnModuleInit, OnModuleDestroy {
75
+ private readonly logger = new Logger(DrizzleEventBus.name);
76
+ private polling = false;
77
+ private pollTimer: ReturnType<typeof setTimeout> | null = null;
78
+ private readonly handlers = new Map<string, Set<(event: DomainEvent) => Promise<void>>>();
79
+ private readonly opts: EventsModuleOptions;
80
+
81
+ constructor(
82
+ @Inject(DRIZZLE) private readonly db: DrizzleClient,
83
+ @Optional() @Inject(EVENTS_MODULE_OPTIONS) opts?: EventsModuleOptions,
84
+ /**
85
+ * Bridge subsystem hook (BRIDGE-4). Optional — when the bridge
86
+ * subsystem is not installed in the consuming app, this token is
87
+ * undefined and the drain skips the bridge block entirely (preserves
88
+ * EVT-4 baseline behaviour).
89
+ *
90
+ * When provided, `processEvent` is invoked once per drained event
91
+ * INSIDE the per-event tx, before `processed_at` is stamped. The
92
+ * hook owns all knowledge of `bridge_delivery + wrapper job_run`
93
+ * shapes; the events subsystem stays unaware of bridge schemas.
94
+ */
95
+ @Optional()
96
+ @Inject(BRIDGE_OUTBOX_DRAIN_HOOK)
97
+ private readonly bridgeHook: IBridgeOutboxDrainHook | null = null,
98
+ ) {
99
+ // Default so direct construction (e.g. integration tests not going
100
+ // through Nest DI) keeps working without an explicit options object.
101
+ this.opts = opts ?? { backend: 'drizzle' };
102
+ }
103
+
104
+ // ============================================================================
105
+ // Lifecycle
106
+ // ============================================================================
107
+
108
+ async onModuleInit(): Promise<void> {
109
+ this.polling = true;
110
+ this.schedulePoll();
111
+ }
112
+
113
+ async onModuleDestroy(): Promise<void> {
114
+ this.polling = false;
115
+ if (this.pollTimer) {
116
+ clearTimeout(this.pollTimer);
117
+ this.pollTimer = null;
118
+ }
119
+ }
120
+
121
+ // ============================================================================
122
+ // IEventBus
123
+ // ============================================================================
124
+
125
+ async publish(event: DomainEvent, tx?: DrizzleTransaction): Promise<void> {
126
+ const client = (tx ?? this.db) as DrizzleClient;
127
+ await client.insert(domainEvents).values(toInsertValues(event));
128
+ }
129
+
130
+ async publishMany(events: DomainEvent[], tx?: DrizzleTransaction): Promise<void> {
131
+ if (events.length === 0) return;
132
+ const client = (tx ?? this.db) as DrizzleClient;
133
+ await client.insert(domainEvents).values(events.map(toInsertValues));
134
+ }
135
+
136
+ async findById(eventId: string): Promise<DomainEvent | null> {
137
+ const rows = await this.db
138
+ .select()
139
+ .from(domainEvents)
140
+ .where(eq(domainEvents.id, eventId))
141
+ .limit(1);
142
+ const row = rows[0];
143
+ if (!row) return null;
144
+ return {
145
+ id: row.id,
146
+ type: row.type,
147
+ aggregateId: row.aggregateId,
148
+ aggregateType: row.aggregateType,
149
+ payload: row.payload as Record<string, unknown>,
150
+ occurredAt:
151
+ row.occurredAt instanceof Date
152
+ ? row.occurredAt
153
+ : new Date(row.occurredAt as unknown as string),
154
+ metadata: (row.metadata ?? undefined) as
155
+ | Record<string, unknown>
156
+ | undefined,
157
+ };
158
+ }
159
+
160
+ subscribe<T extends DomainEvent = DomainEvent>(
161
+ eventType: string,
162
+ handler: (event: T) => Promise<void>,
163
+ ): () => void {
164
+ if (!this.handlers.has(eventType)) {
165
+ this.handlers.set(eventType, new Set());
166
+ }
167
+ const set = this.handlers.get(eventType)!;
168
+ const h = handler as (event: DomainEvent) => Promise<void>;
169
+ set.add(h);
170
+ return () => {
171
+ set.delete(h);
172
+ };
173
+ }
174
+
175
+ // ============================================================================
176
+ // Polling
177
+ // ============================================================================
178
+
179
+ /**
180
+ * Test-only hook. Runs exactly one drain cycle and returns. Production
181
+ * code goes through `onModuleInit` → `schedulePoll`, which calls the
182
+ * same `processBatch` under a timer.
183
+ */
184
+ async drainOnce(): Promise<void> {
185
+ await this.processBatch();
186
+ }
187
+
188
+ private schedulePoll(): void {
189
+ if (!this.polling) return;
190
+ this.pollTimer = setTimeout(async () => {
191
+ try {
192
+ await this.processBatch();
193
+ } catch (err) {
194
+ this.logger.error(`Poll cycle error: ${err}`);
195
+ } finally {
196
+ this.schedulePoll();
197
+ }
198
+ }, POLL_INTERVAL_MS);
199
+ }
200
+
201
+ /**
202
+ * Drain one batch (BRIDGE-4 restructure of EVT-4).
203
+ *
204
+ * Two-phase per drained event:
205
+ *
206
+ * 1. **Per-event transaction** — bridge fanout (`bridgeHook.processEvent`)
207
+ * + `processed_at` stamp. Both write through the same `tx`. A throw
208
+ * inside the tx (only infra-level failures should reach here, since
209
+ * the hook tolerates null direction and registry misses inline)
210
+ * rolls back the bridge inserts AND the `processed_at` stamp; the
211
+ * event re-claims on the next drain cycle. Bridge `UNIQUE
212
+ * (event_id, trigger_id)` makes the retry idempotent.
213
+ *
214
+ * 2. **After commit** — dispatch in-process subscribers (`IEventBus.subscribe`
215
+ * handlers). This deliberately runs OUTSIDE the per-event tx (lead
216
+ * decision 2026-04-22): subscribers are best-effort and must not
217
+ * gate forward progress or roll back bridge fanout. Subscriber
218
+ * errors are caught + logged; `processed_at` is already committed.
219
+ * The old `MAX_RETRIES=3` in-process retry loop and the
220
+ * `failed`-stamping path were removed in BRIDGE-4 along with their
221
+ * coupling.
222
+ *
223
+ * The `processed_at` UPDATE carries `AND status='pending'` (BRIDGE-4
224
+ * tightening — without it, a hypothetical double-claim could double-stamp
225
+ * the timestamp). The per-event tx + `FOR UPDATE SKIP LOCKED` claim
226
+ * make this defensive belt-and-suspenders.
227
+ */
228
+ private async processBatch(): Promise<void> {
229
+ const pools = this.opts.pools;
230
+
231
+ // Build WHERE: status='pending' [AND pool IN (...)]
232
+ const whereClause: SQL<unknown> = pools && pools.length > 0
233
+ ? (and(eq(domainEvents.status, 'pending'), inArray(domainEvents.pool, pools)) as SQL<unknown>)
234
+ : eq(domainEvents.status, 'pending');
235
+
236
+ // Claim a batch with FOR UPDATE SKIP LOCKED so multiple pollers don't
237
+ // double-dispatch. The lock is released when the outer transaction
238
+ // commits — which is fine because the immediately-following per-event
239
+ // tx flips status='processed' under its own `AND status='pending'`
240
+ // guard, so a re-claim of the same row in a subsequent batch is a
241
+ // no-op UPDATE.
242
+ const rows = await this.db.transaction(async (tx) => {
243
+ return tx
244
+ .select()
245
+ .from(domainEvents)
246
+ .where(whereClause)
247
+ .orderBy(asc(domainEvents.occurredAt))
248
+ .limit(POLL_BATCH_SIZE)
249
+ .for('update', { skipLocked: true });
250
+ }) as Array<typeof domainEvents.$inferSelect>;
251
+
252
+ for (const row of rows) {
253
+ const event: DomainEvent = {
254
+ id: row.id,
255
+ type: row.type,
256
+ aggregateId: row.aggregateId,
257
+ aggregateType: row.aggregateType,
258
+ payload: row.payload as Record<string, unknown>,
259
+ occurredAt: row.occurredAt instanceof Date ? row.occurredAt : new Date(row.occurredAt as unknown as string),
260
+ metadata: (row.metadata ?? undefined) as Record<string, unknown> | undefined,
261
+ };
262
+
263
+ // Phase 1 — per-event tx: bridge fanout + processed_at stamp.
264
+ try {
265
+ await this.db.transaction(async (tx) => {
266
+ if (this.bridgeHook) {
267
+ await this.bridgeHook.processEvent(event, tx);
268
+ }
269
+ await tx
270
+ .update(domainEvents)
271
+ .set({ status: 'processed', processedAt: new Date() })
272
+ .where(
273
+ and(
274
+ eq(domainEvents.id, event.id),
275
+ eq(domainEvents.status, 'pending'),
276
+ ),
277
+ );
278
+ });
279
+ } catch (err) {
280
+ // Infra-level failure inside the per-event tx — bridge inserts
281
+ // and processed_at both rolled back. Log and move on; the next
282
+ // drain cycle re-claims the row. UNIQUE on bridge_delivery makes
283
+ // the retry idempotent.
284
+ this.logger.error(
285
+ `Per-event tx failed for event id=${event.id} type=${event.type}: ${err}`,
286
+ );
287
+ continue;
288
+ }
289
+
290
+ // Phase 2 — best-effort subscriber dispatch. Errors are logged
291
+ // and discarded; processed_at is already committed. Subscribers
292
+ // are observability + cache-busts + small ancillary work; they
293
+ // must not gate forward progress.
294
+ try {
295
+ await this.dispatch(event);
296
+ } catch (err) {
297
+ this.logger.error(
298
+ `Subscriber dispatch failed for event id=${event.id} type=${event.type} ` +
299
+ `(processed_at already committed; failure does not retry): ${err}`,
300
+ );
301
+ }
302
+ }
303
+ }
304
+
305
+ private async dispatch(event: DomainEvent): Promise<void> {
306
+ const set = this.handlers.get(event.type);
307
+ if (!set) return;
308
+
309
+ let firstError: unknown;
310
+ for (const handler of set) {
311
+ try {
312
+ await handler(event);
313
+ } catch (err) {
314
+ this.logger.error(
315
+ `Handler error for event type "${event.type}" (id: ${event.id}): ${err}`,
316
+ );
317
+ if (firstError === undefined) {
318
+ firstError = err;
319
+ }
320
+ }
321
+ }
322
+
323
+ if (firstError !== undefined) {
324
+ throw firstError;
325
+ }
326
+ }
327
+ }