@pattern-stack/codegen 0.4.0 → 0.4.2

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 (136) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/src/cli/index.js +1616 -1070
  3. package/dist/src/cli/index.js.map +1 -1
  4. package/package.json +3 -1
  5. package/runtime/analytics/index.ts +31 -0
  6. package/runtime/analytics/metrics.ts +85 -0
  7. package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
  8. package/runtime/analytics/packs/index.ts +5 -0
  9. package/runtime/analytics/packs/monetary-measures.ts +20 -0
  10. package/runtime/analytics/specs.ts +54 -0
  11. package/runtime/analytics/types.ts +105 -0
  12. package/runtime/base-classes/activity-entity-repository.ts +50 -0
  13. package/runtime/base-classes/activity-entity-service.ts +48 -0
  14. package/runtime/base-classes/base-read-use-cases.ts +88 -0
  15. package/runtime/base-classes/base-repository.ts +289 -0
  16. package/runtime/base-classes/base-service.ts +183 -0
  17. package/runtime/base-classes/index.ts +38 -0
  18. package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
  19. package/runtime/base-classes/knowledge-entity-service.ts +14 -0
  20. package/runtime/base-classes/lifecycle-events.ts +152 -0
  21. package/runtime/base-classes/metadata-entity-repository.ts +80 -0
  22. package/runtime/base-classes/metadata-entity-service.ts +48 -0
  23. package/runtime/base-classes/synced-entity-repository.ts +57 -0
  24. package/runtime/base-classes/synced-entity-service.ts +50 -0
  25. package/runtime/base-classes/with-analytics.ts +22 -0
  26. package/runtime/constants/tokens.ts +29 -0
  27. package/runtime/eav-helpers.ts +74 -0
  28. package/runtime/pipes/zod-validation.pipe.ts +64 -0
  29. package/runtime/shared/openapi/error-response.dto.ts +24 -0
  30. package/runtime/shared/openapi/errors.ts +39 -0
  31. package/runtime/shared/openapi/index.ts +20 -0
  32. package/runtime/shared/openapi/registry.tokens.ts +13 -0
  33. package/runtime/shared/openapi/registry.ts +151 -0
  34. package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
  35. package/runtime/subsystems/analytics/analytics.module.ts +64 -0
  36. package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
  37. package/runtime/subsystems/analytics/cube-backend.ts +75 -0
  38. package/runtime/subsystems/analytics/index.ts +15 -0
  39. package/runtime/subsystems/analytics/noop-backend.ts +27 -0
  40. package/runtime/subsystems/auth/auth.module.ts +91 -0
  41. package/runtime/subsystems/auth/auth.tokens.ts +27 -0
  42. package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
  43. package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
  44. package/runtime/subsystems/auth/index.ts +77 -0
  45. package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
  46. package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
  47. package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
  48. package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
  49. package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
  50. package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
  51. package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
  52. package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
  53. package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
  54. package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
  55. package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
  56. package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
  57. package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
  58. package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
  59. package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
  60. package/runtime/subsystems/bridge/bridge.module.ts +160 -0
  61. package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
  62. package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
  63. package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
  64. package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
  65. package/runtime/subsystems/bridge/generated/registry.ts +6 -0
  66. package/runtime/subsystems/bridge/index.ts +84 -0
  67. package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
  68. package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
  69. package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
  70. package/runtime/subsystems/cache/cache.module.ts +115 -0
  71. package/runtime/subsystems/cache/cache.protocol.ts +45 -0
  72. package/runtime/subsystems/cache/cache.schema.ts +27 -0
  73. package/runtime/subsystems/cache/cache.tokens.ts +17 -0
  74. package/runtime/subsystems/cache/index.ts +22 -0
  75. package/runtime/subsystems/events/domain-events.schema.ts +77 -0
  76. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
  77. package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
  78. package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
  79. package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
  80. package/runtime/subsystems/events/events-errors.ts +30 -0
  81. package/runtime/subsystems/events/events.module.ts +230 -0
  82. package/runtime/subsystems/events/events.tokens.ts +62 -0
  83. package/runtime/subsystems/events/generated/bus.ts +103 -0
  84. package/runtime/subsystems/events/generated/index.ts +7 -0
  85. package/runtime/subsystems/events/generated/registry.ts +84 -0
  86. package/runtime/subsystems/events/generated/schemas.ts +59 -0
  87. package/runtime/subsystems/events/generated/types.ts +94 -0
  88. package/runtime/subsystems/events/index.ts +21 -0
  89. package/runtime/subsystems/index.ts +63 -0
  90. package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
  91. package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
  92. package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
  93. package/runtime/subsystems/jobs/index.ts +120 -0
  94. package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
  95. package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
  96. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
  97. package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +850 -0
  98. package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
  99. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
  100. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
  101. package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
  102. package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
  103. package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
  104. package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
  105. package/runtime/subsystems/jobs/job-worker.module.ts +302 -0
  106. package/runtime/subsystems/jobs/job-worker.ts +615 -0
  107. package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
  108. package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
  109. package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
  110. package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
  111. package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
  112. package/runtime/subsystems/storage/index.ts +18 -0
  113. package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
  114. package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
  115. package/runtime/subsystems/storage/storage.module.ts +60 -0
  116. package/runtime/subsystems/storage/storage.protocol.ts +78 -0
  117. package/runtime/subsystems/storage/storage.tokens.ts +9 -0
  118. package/runtime/subsystems/storage/storage.utils.ts +20 -0
  119. package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
  120. package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
  121. package/runtime/subsystems/sync/index.ts +98 -0
  122. package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
  123. package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
  124. package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
  125. package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
  126. package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
  127. package/runtime/subsystems/sync/sync-errors.ts +54 -0
  128. package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
  129. package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
  130. package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
  131. package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
  132. package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
  133. package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
  134. package/runtime/subsystems/sync/sync.module.ts +156 -0
  135. package/runtime/subsystems/sync/sync.tokens.ts +57 -0
  136. package/runtime/types/drizzle.ts +23 -0
@@ -0,0 +1,175 @@
1
+ /**
2
+ * BridgeOutboxDrainHook — drains-time bridge fanout writer (BRIDGE-4,
3
+ * ADR-023 Phase 2).
4
+ *
5
+ * Implements `IBridgeOutboxDrainHook`. Called by `DrizzleEventBus`'s
6
+ * modified `processBatch` once per drained event, INSIDE the per-event
7
+ * transaction. For every trigger registered against the event's type in
8
+ * the codegen-emitted `bridgeRegistry`, writes:
9
+ *
10
+ * 1. `bridge_delivery` ledger row — `INSERT … ON CONFLICT (event_id,
11
+ * trigger_id) DO NOTHING RETURNING id`. Empty result ⇒ Case B
12
+ * facade-eager pre-write OR drain-replay collision; skip wrapper
13
+ * insert for that trigger; sibling triggers still fire.
14
+ * 2. `job_run` wrapper row — `type='@framework/bridge_delivery'`,
15
+ * `pool='events_<direction>'`, `input={ deliveryId }`,
16
+ * `trigger_source='event'`, `trigger_ref=event.id`. The wrapper is
17
+ * what the framework `BridgeDeliveryHandler` (BRIDGE-5) eventually
18
+ * claims via the worker that polls the corresponding reserved pool.
19
+ *
20
+ * Null `event.metadata.direction` is tolerated: the hook logs a one-line
21
+ * warning per event and returns zeros without writing rows. The drain's
22
+ * `processed_at` stamp + subscriber dispatch still fire normally.
23
+ * Direction is null only for events published via the legacy
24
+ * `IEventBus.publish(...)` path (`TypedEventBus.publish` always sets it);
25
+ * such events are out of scope for bridge fanout.
26
+ *
27
+ * The wrapper insert generates its own `id` via Drizzle's `defaultRandom`
28
+ * — we don't `RETURNING id` because nobody needs it at drain time;
29
+ * `BridgeDeliveryHandler` later looks up the wrapper via the
30
+ * `bridge_delivery.wrapper_run_id` link if needed. This keeps the drain
31
+ * one-round-trip-per-trigger.
32
+ */
33
+ import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
34
+ import { randomUUID } from 'node:crypto';
35
+
36
+ import type { DomainEvent, DrizzleTransaction } from '../events/event-bus.protocol';
37
+ import { bridgeDelivery } from './bridge-delivery.schema';
38
+ import { jobRuns } from '../jobs/job-orchestration.schema';
39
+
40
+ import { BRIDGE_REGISTRY } from './bridge.tokens';
41
+ import type {
42
+ BridgeOutboxDrainResult,
43
+ BridgeRegistry,
44
+ BridgeTriggerEntry,
45
+ IBridgeOutboxDrainHook,
46
+ } from './bridge.protocol';
47
+ import { BRIDGE_DELIVERY_JOB_TYPE } from './bridge-delivery-handler';
48
+ import type { EventTypeName } from '../events/generated/types';
49
+
50
+ /** Reserved pools the wrapper rows route into; ADR-022 / ADR-024. */
51
+ const POOL_BY_DIRECTION: Record<string, string> = {
52
+ inbound: 'events_inbound',
53
+ change: 'events_change',
54
+ outbound: 'events_outbound',
55
+ };
56
+
57
+ @Injectable()
58
+ export class BridgeOutboxDrainHook implements IBridgeOutboxDrainHook {
59
+ private readonly logger = new Logger(BridgeOutboxDrainHook.name);
60
+ private warnedNullDirection = false;
61
+
62
+ constructor(
63
+ @Optional()
64
+ @Inject(BRIDGE_REGISTRY)
65
+ private readonly registry: BridgeRegistry = {},
66
+ ) {}
67
+
68
+ async processEvent(
69
+ event: DomainEvent,
70
+ tx: DrizzleTransaction,
71
+ ): Promise<BridgeOutboxDrainResult> {
72
+ const triggers = this.lookupTriggers(event.type);
73
+ if (triggers.length === 0) {
74
+ return { delivered: 0, dedupSkips: 0, triggerCount: 0 };
75
+ }
76
+
77
+ const direction =
78
+ (event.metadata?.['direction'] as string | undefined) ?? null;
79
+ const tenantId =
80
+ (event.metadata?.['tenantId'] as string | null | undefined) ?? null;
81
+ const wrapperPool = direction ? POOL_BY_DIRECTION[direction] : undefined;
82
+
83
+ if (!wrapperPool) {
84
+ // Null direction (or an unrecognised one — defensive). Bridge
85
+ // fanout requires a routed wrapper pool; without one we can't
86
+ // spawn. Log once per process so misconfiguration surfaces.
87
+ if (!this.warnedNullDirection) {
88
+ this.warnedNullDirection = true;
89
+ this.logger.warn(
90
+ `Skipping bridge fanout for events with null/unknown direction. ` +
91
+ `event.id=${event.id} event.type=${event.type} ` +
92
+ `direction=${String(direction)}. The wrapper pool is derived ` +
93
+ `from direction (events_<direction>); publishers must use ` +
94
+ `TypedEventBus.publish() so direction is stamped on the ` +
95
+ `outbox row.`,
96
+ );
97
+ }
98
+ return { delivered: 0, dedupSkips: 0, triggerCount: triggers.length };
99
+ }
100
+
101
+ let delivered = 0;
102
+ let dedupSkips = 0;
103
+ const client = tx as unknown as {
104
+ insert: (table: unknown) => {
105
+ values: (v: unknown) => {
106
+ onConflictDoNothing: (opts: unknown) => {
107
+ returning: (cols: unknown) => Promise<{ id: string }[]>;
108
+ };
109
+ } & {
110
+ // wrapper insert path — no ON CONFLICT
111
+ // (typed loosely via the same helper return shape)
112
+ };
113
+ };
114
+ };
115
+
116
+ for (const trigger of triggers) {
117
+ const deliveryId = randomUUID();
118
+ const wrapperRunId = randomUUID();
119
+
120
+ // 1. bridge_delivery insert with ON CONFLICT DO NOTHING + RETURNING.
121
+ const inserted = await (tx as unknown as {
122
+ insert: typeof client.insert;
123
+ })
124
+ .insert(bridgeDelivery)
125
+ .values({
126
+ id: deliveryId,
127
+ eventId: event.id,
128
+ triggerId: trigger.triggerId,
129
+ wrapperRunId,
130
+ status: 'pending',
131
+ tenantId,
132
+ })
133
+ .onConflictDoNothing({
134
+ target: [bridgeDelivery.eventId, bridgeDelivery.triggerId],
135
+ })
136
+ .returning({ id: bridgeDelivery.id });
137
+
138
+ if (inserted.length === 0) {
139
+ // Case B (facade pre-wrote `delivered`) or drain replay — skip
140
+ // wrapper insert for this trigger. Sibling triggers still fire.
141
+ dedupSkips++;
142
+ continue;
143
+ }
144
+
145
+ // 2. Wrapper job_run insert. We carry the deliveryId into the
146
+ // wrapper input so BridgeDeliveryHandler.run(ctx) can locate
147
+ // the row via repo.findDeliveryById(ctx.input.deliveryId).
148
+ await (tx as unknown as { insert: typeof client.insert })
149
+ .insert(jobRuns)
150
+ .values({
151
+ id: wrapperRunId,
152
+ jobType: BRIDGE_DELIVERY_JOB_TYPE,
153
+ jobVersion: 1,
154
+ rootRunId: wrapperRunId,
155
+ pool: wrapperPool,
156
+ status: 'pending',
157
+ input: { deliveryId },
158
+ triggerSource: 'event',
159
+ triggerRef: event.id,
160
+ tenantId,
161
+ });
162
+
163
+ delivered++;
164
+ }
165
+
166
+ return { delivered, dedupSkips, triggerCount: triggers.length };
167
+ }
168
+
169
+ private lookupTriggers(
170
+ eventType: string,
171
+ ): BridgeTriggerEntry[] {
172
+ const candidates = this.registry[eventType as EventTypeName];
173
+ return (candidates ?? []) as BridgeTriggerEntry[];
174
+ }
175
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * BridgeModule — `DynamicModule.forRoot({ backend, multiTenant })`
3
+ * factory that wires the entire bridge subsystem (BRIDGE-8, ADR-023
4
+ * Phase 2).
5
+ *
6
+ * The bridge is the formalized seam between events (ADR-024) and jobs
7
+ * (ADR-022). It is owned by neither subsystem and consumes their tokens
8
+ * via DI. `BridgeModule` is the *combiner* — neither `EventsModule` nor
9
+ * `JobsDomainModule` know about it.
10
+ *
11
+ * Consumer wiring (must be imported AFTER `EventsModule`,
12
+ * `JobsDomainModule`, and `JobWorkerModule`):
13
+ * ```ts
14
+ * @Module({
15
+ * imports: [
16
+ * EventsModule.forRoot({ backend: 'drizzle' }),
17
+ * JobWorkerModule.forRoot({
18
+ * mode: 'embedded',
19
+ * backend: 'drizzle',
20
+ * pools: ['interactive', 'batch', ...BRIDGE_RESERVED_POOLS],
21
+ * }),
22
+ * BridgeModule.forRoot({ backend: 'drizzle', multiTenant: false }),
23
+ * ],
24
+ * })
25
+ * class AppModule {}
26
+ * ```
27
+ *
28
+ * Boot-time check: `onModuleInit` inspects `JobWorkerModule`'s active
29
+ * pools and throws `BridgeReservedPoolsNotPolledError` when any of the
30
+ * three reserved bridge pools isn't being polled. Converts the
31
+ * "wrappers sit pending forever" footgun into a fail-fast.
32
+ *
33
+ * Handler registration: ONE `@JobHandler('@framework/bridge_delivery',
34
+ * ...)` decorator on `BridgeDeliveryHandler` auto-registers it in
35
+ * `JOB_HANDLER_REGISTRY` at module-load time. We declare the class as a
36
+ * Nest provider here so DI resolves its constructor deps; per-direction
37
+ * routing happens via `job_run.pool='events_<direction>'` set by
38
+ * `BridgeOutboxDrainHook` (BRIDGE-4) — workers polling each reserved
39
+ * pool independently claim wrappers from their own pool and dispatch to
40
+ * the same handler class. The reserved-pool validator exemption
41
+ * (BRIDGE-5) lets the framework handler legitimately target a reserved
42
+ * pool.
43
+ */
44
+ import {
45
+ Inject,
46
+ Module,
47
+ Optional,
48
+ type DynamicModule,
49
+ type OnModuleInit,
50
+ type Provider,
51
+ } from '@nestjs/common';
52
+
53
+ import {
54
+ JOB_WORKER_MODULE_OPTIONS,
55
+ type JobWorkerModuleOptions,
56
+ } from '../jobs/job-worker.module';
57
+
58
+ import {
59
+ BRIDGE_DELIVERY_REPO,
60
+ BRIDGE_MODULE_OPTIONS,
61
+ BRIDGE_MULTI_TENANT,
62
+ BRIDGE_OUTBOX_DRAIN_HOOK,
63
+ BRIDGE_REGISTRY,
64
+ EVENT_FLOW,
65
+ } from './bridge.tokens';
66
+ import { BridgeReservedPoolsNotPolledError } from './bridge-errors';
67
+ import { MemoryBridgeDeliveryRepo } from './bridge-delivery.memory-backend';
68
+ import { DrizzleBridgeDeliveryRepo } from './bridge-delivery.drizzle-backend';
69
+ import { BridgeOutboxDrainHook } from './bridge-outbox-drain-hook';
70
+ import { EventFlowService } from './event-flow.service';
71
+ import { BridgeDeliveryHandler } from './bridge-delivery-handler';
72
+ import { bridgeRegistry } from './generated/registry';
73
+ import { BRIDGE_RESERVED_POOLS } from './reserved-pools';
74
+
75
+ export interface BridgeModuleOptions {
76
+ /**
77
+ * `'memory'` for unit tests (no Postgres), `'drizzle'` for production.
78
+ * Switches `BRIDGE_DELIVERY_REPO` between
79
+ * `MemoryBridgeDeliveryRepo` and `DrizzleBridgeDeliveryRepo`.
80
+ */
81
+ backend: 'memory' | 'drizzle';
82
+ /**
83
+ * Multi-tenancy opt-in. When `true`, the three enforcement sites
84
+ * (`EventFlowService.publishAndStart`, `BridgeDeliveryHandler.run`,
85
+ * `DrizzleBridgeDeliveryRepo.insertDelivery`) throw
86
+ * `MissingTenantIdError` when `tenantId === undefined`. Explicit
87
+ * `null` always passes (cross-tenant work). Defaults to `false`.
88
+ */
89
+ multiTenant?: boolean;
90
+ }
91
+
92
+ @Module({})
93
+ export class BridgeModule implements OnModuleInit {
94
+ static forRoot(opts: BridgeModuleOptions): DynamicModule {
95
+ const repoProvider: Provider =
96
+ opts.backend === 'memory'
97
+ ? { provide: BRIDGE_DELIVERY_REPO, useClass: MemoryBridgeDeliveryRepo }
98
+ : { provide: BRIDGE_DELIVERY_REPO, useClass: DrizzleBridgeDeliveryRepo };
99
+
100
+ return {
101
+ module: BridgeModule,
102
+ global: true,
103
+ // BridgeModule consumes EVENT_BUS / JOB_ORCHESTRATOR / DRIZZLE
104
+ // from sibling subsystems via DI; no `imports` needed here. The
105
+ // consumer is responsible for wiring EventsModule + JobsDomainModule
106
+ // (or JobWorkerModule, which transitively imports the latter)
107
+ // BEFORE BridgeModule.
108
+ providers: [
109
+ { provide: BRIDGE_MODULE_OPTIONS, useValue: opts },
110
+ { provide: BRIDGE_MULTI_TENANT, useValue: opts.multiTenant ?? false },
111
+ { provide: BRIDGE_REGISTRY, useValue: bridgeRegistry },
112
+ repoProvider,
113
+ // Drain hook — always wired; `DrizzleEventBus` consumes it via
114
+ // `@Optional()`, so non-bridge mounts simply see `undefined`.
115
+ { provide: BRIDGE_OUTBOX_DRAIN_HOOK, useClass: BridgeOutboxDrainHook },
116
+ // Facade — class provider + token alias.
117
+ EventFlowService,
118
+ { provide: EVENT_FLOW, useExisting: EventFlowService },
119
+ // Framework handler — provider so DI can construct it. The
120
+ // `@JobHandler` decorator already auto-registers it in
121
+ // `JOB_HANDLER_REGISTRY` at module-load time, and its `jobs`
122
+ // row is upserted at `JobWorkerModule.onModuleInit`. We just
123
+ // need the class instantiated as a Nest provider so its DI
124
+ // deps (BRIDGE_DELIVERY_REPO, JOB_ORCHESTRATOR, EVENT_BUS,
125
+ // BRIDGE_REGISTRY, BRIDGE_MULTI_TENANT) resolve.
126
+ BridgeDeliveryHandler,
127
+ ],
128
+ exports: [
129
+ EVENT_FLOW,
130
+ BRIDGE_DELIVERY_REPO,
131
+ BRIDGE_REGISTRY,
132
+ BRIDGE_MULTI_TENANT,
133
+ BRIDGE_MODULE_OPTIONS,
134
+ BRIDGE_OUTBOX_DRAIN_HOOK,
135
+ ],
136
+ };
137
+ }
138
+
139
+ /**
140
+ * `JOB_WORKER_MODULE_OPTIONS` is declared `@Optional()` so unit tests
141
+ * that mount `BridgeModule` alone (no `JobWorkerModule`) boot
142
+ * cleanly — the boot-time check skips when the token is undefined.
143
+ */
144
+ constructor(
145
+ @Optional()
146
+ @Inject(JOB_WORKER_MODULE_OPTIONS)
147
+ private readonly workerOpts?: JobWorkerModuleOptions,
148
+ ) {}
149
+
150
+ async onModuleInit(): Promise<void> {
151
+ if (!this.workerOpts) return;
152
+ const activePools = this.workerOpts.pools ?? [];
153
+ const missing = BRIDGE_RESERVED_POOLS.filter(
154
+ (p) => !activePools.includes(p),
155
+ );
156
+ if (missing.length > 0) {
157
+ throw new BridgeReservedPoolsNotPolledError(missing);
158
+ }
159
+ }
160
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Bridge subsystem — protocols (ports) — ADR-023 Phase 2, BRIDGE-2.
3
+ *
4
+ * Two interfaces:
5
+ *
6
+ * - `IJobBridge` — repo-shaped contract over the `bridge_delivery`
7
+ * ledger. Backends: memory (BRIDGE-3), Drizzle
8
+ * (BRIDGE-4). Consumed by the framework
9
+ * `BridgeDeliveryHandler` (BRIDGE-5), the modified
10
+ * outbox drain (BRIDGE-4), and the `EventFlowService`
11
+ * facade for Case B pre-writes (BRIDGE-7).
12
+ *
13
+ * - `IEventFlow` — the developer-facing facade from ADR-023 §Decision 7.
14
+ * Two verbs: `publish` and `publishAndStart`. All
15
+ * request-path and fanout publishing should go through
16
+ * this token rather than `IEventBus` / `TYPED_EVENT_BUS`
17
+ * directly so reviewers can grep call sites and the
18
+ * `codegen events consumers <type>` CLI (BRIDGE-9) can
19
+ * index Tier 2 alongside Tier 3 triggers and Tier 1
20
+ * subscribers.
21
+ *
22
+ * Both interfaces accept an optional last-arg `DrizzleTransaction` so
23
+ * callers operating inside an existing transaction can thread it through
24
+ * (the outbox drain's per-event tx, the facade's Case B pre-write).
25
+ */
26
+ import type { InferInsertModel } from 'drizzle-orm';
27
+
28
+ import type { DrizzleTransaction, DomainEvent } from '../events/event-bus.protocol';
29
+ import type {
30
+ EventOfType,
31
+ EventTypeName,
32
+ } from '../events/generated/types';
33
+
34
+ import type {
35
+ BridgeDeliveryRecord,
36
+ bridgeDelivery,
37
+ } from './bridge-delivery.schema';
38
+
39
+ // ============================================================================
40
+ // IJobBridge — bridge_delivery ledger repo
41
+ // ============================================================================
42
+
43
+ /**
44
+ * Insert payload for `IJobBridge.insertDelivery`. Derived from the
45
+ * Drizzle schema so the contract stays in sync with the table — adding a
46
+ * column to `bridge_delivery` propagates to every backend without manual
47
+ * sync. `id`, `attemptedAt` carry DB defaults; `wrapperRunId`, `userRunId`,
48
+ * `skipReason`, `error`, `tenantId`, `deliveredAt` are nullable per BRIDGE-1.
49
+ */
50
+ export type BridgeDeliveryInsert = InferInsertModel<typeof bridgeDelivery>;
51
+
52
+ export interface IJobBridge {
53
+ /**
54
+ * Insert a `bridge_delivery` row.
55
+ *
56
+ * **Throws on `UNIQUE (event_id, trigger_id)` conflict.** Callers that
57
+ * expect collisions (the outbox drain hitting a facade-eager pre-write,
58
+ * the drain re-claiming after a crash) should catch the conflict and
59
+ * skip — see ADR-023 §`publishAndStart` + existing `triggers:` collision
60
+ * and the BRIDGE-4 spec for the recommended `INSERT … ON CONFLICT … DO
61
+ * NOTHING RETURNING id` shape that turns the throw into an empty result.
62
+ */
63
+ insertDelivery(
64
+ row: BridgeDeliveryInsert,
65
+ tx?: DrizzleTransaction,
66
+ ): Promise<void>;
67
+
68
+ /**
69
+ * Lookup a delivery by its idempotency key. Returns `null` when no row
70
+ * matches. Used in tests / dashboards for the canonical (event, trigger)
71
+ * lookup — distinct from `findDeliveryById`, which is what the
72
+ * `BridgeDeliveryHandler` (BRIDGE-5) uses given that the wrapper input
73
+ * only carries the delivery id.
74
+ */
75
+ findDelivery(
76
+ eventId: string,
77
+ triggerId: string,
78
+ ): Promise<BridgeDeliveryRecord | null>;
79
+
80
+ /**
81
+ * Lookup a delivery by primary key. Drizzle backend (BRIDGE-4):
82
+ * `SELECT … WHERE id = ? LIMIT 1`. Memory backend (BRIDGE-3): linear
83
+ * scan (small N). Returns `null` when no row matches — handler treats
84
+ * that as `delivery_row_missing` and the wrapper completes cleanly.
85
+ */
86
+ findDeliveryById(id: string): Promise<BridgeDeliveryRecord | null>;
87
+
88
+ /**
89
+ * Transition `pending` → `delivered`, populating `user_run_id` and
90
+ * `delivered_at`. Called by `BridgeDeliveryHandler` after
91
+ * `orchestrator.start(userJob)` returns.
92
+ */
93
+ markDelivered(
94
+ id: string,
95
+ userRunId: string,
96
+ tx?: DrizzleTransaction,
97
+ ): Promise<void>;
98
+
99
+ /**
100
+ * Transition `pending` → `skipped`, populating `skip_reason`. Called by
101
+ * `BridgeDeliveryHandler` when the trigger's `when:` predicate returns
102
+ * `false` or when the trigger no longer exists in the registry (rename
103
+ * scenario from ADR-023 §Consequences).
104
+ */
105
+ markSkipped(
106
+ id: string,
107
+ reason: string,
108
+ tx?: DrizzleTransaction,
109
+ ): Promise<void>;
110
+
111
+ /**
112
+ * Transition `pending` → `failed`, populating `error`. Called by the
113
+ * wrapper after its retry policy is exhausted; surfaces in ops
114
+ * dashboards via the `idx_bridge_delivery_status` partial index.
115
+ */
116
+ markFailed(
117
+ id: string,
118
+ error: Record<string, unknown>,
119
+ tx?: DrizzleTransaction,
120
+ ): Promise<void>;
121
+ }
122
+
123
+ // ============================================================================
124
+ // IEventFlow — developer-facing facade (ADR-023 §Decision 7)
125
+ // ============================================================================
126
+
127
+ /**
128
+ * Caller-supplied options for `IEventFlow.publishAndStart`.
129
+ *
130
+ * `tenantId` semantics match `IJobOrchestrator.StartOptions.tenantId`
131
+ * (JOB-8): explicit `null` opts into cross-tenant work; `undefined` throws
132
+ * `MissingTenantIdError` when `BridgeModule` is configured with
133
+ * `multiTenant: true`.
134
+ *
135
+ * `parentRunId` lets request-path callers attach the eagerly-started run
136
+ * to an existing run hierarchy (e.g. a higher-level orchestration that
137
+ * publishes events as side effects of its own steps).
138
+ */
139
+ export interface PublishAndStartOptions {
140
+ parentRunId?: string;
141
+ tenantId?: string | null;
142
+ }
143
+
144
+ /**
145
+ * Result of `IEventFlow.publishAndStart`. The facade returns the
146
+ * eagerly-started user run's id so the caller can subscribe to its
147
+ * completion or correlate with its own request id.
148
+ */
149
+ export interface PublishAndStartResult {
150
+ runId: string;
151
+ }
152
+
153
+ export interface IEventFlow {
154
+ /**
155
+ * Tier 1 + Tier 3 — plain publish.
156
+ *
157
+ * Writes the event to the outbox (or in-memory bus, depending on the
158
+ * backend). Bridge triggers fire asynchronously (via
159
+ * `@JobHandler.triggers`); in-process `IEventBus.subscribe` handlers
160
+ * fire in-call. Returns when the outbox row is committed.
161
+ *
162
+ * Delegates to `IEventBus.publish` under the hood.
163
+ *
164
+ * **Note on signature:** ADR-023 §Decision 7 sketches the verb as
165
+ * `publish<T extends EventType>(event: TypedEvent<T>)`. The actual
166
+ * generated types are `EventTypeName` and `EventOfType<T>` (see
167
+ * `runtime/subsystems/events/generated/types.ts`); we use those here so
168
+ * the contract typechecks against the real codegen output. The verb
169
+ * shape and behaviour are unchanged.
170
+ */
171
+ publish<T extends EventTypeName>(
172
+ event: EventOfType<T>,
173
+ tx?: DrizzleTransaction,
174
+ ): Promise<void>;
175
+
176
+ /**
177
+ * Tier 2 + Tier 3 + Tier 1 — publish + eagerly start a specific named
178
+ * job.
179
+ *
180
+ * Behaviour:
181
+ * 1. Writes the event to the outbox (so bridge triggers and Tier 1
182
+ * subscribers fire as normal).
183
+ * 2. Synchronously calls `IJobOrchestrator.start(jobType, input,
184
+ * opts)` to enqueue the user job before returning to the caller.
185
+ * 3. **Case B dedup** — if the (event, jobType) pair has a declared
186
+ * `@JobHandler.triggers` entry, the facade pre-writes a
187
+ * `bridge_delivery(status='delivered', user_run_id=<eagerRunId>)`
188
+ * row in the same transaction as `orchestrator.start(...)`. The
189
+ * drain's later `INSERT … ON CONFLICT (event_id, trigger_id) DO
190
+ * NOTHING` then sees the existing row and skips that trigger while
191
+ * still spawning any other triggers for the same event.
192
+ *
193
+ * Use when the caller needs the job enqueued before returning to the
194
+ * user (request-path, within-transaction durability). For pure async
195
+ * fanout where Tier 3 alone suffices, use `publish` instead.
196
+ *
197
+ * **Same-tx invariant** (BRIDGE-7): the outbox insert, the
198
+ * `orchestrator.start` insert, and the Case B `bridge_delivery`
199
+ * pre-write must all share a transaction. A crash between any two
200
+ * leaves the system inconsistent (e.g. event published but no eager
201
+ * run, or eager run with no Case B dedup row → drain double-spawns).
202
+ *
203
+ * **Note on signature:** ADR-023 §Decision 7 sketches `JobType` /
204
+ * `JobInputOf<J>` parameters; the jobs subsystem currently models the
205
+ * orchestrator's `start` as `start(type: string, input: unknown, …)`
206
+ * with no generated `JobType` union analogous to events' `EventTypeName`.
207
+ * The facade matches that shape today; tightening the surface is a
208
+ * post-Phase-2 follow-up that requires generated job typing first.
209
+ */
210
+ publishAndStart<T extends EventTypeName>(
211
+ event: EventOfType<T>,
212
+ jobType: string,
213
+ input: unknown,
214
+ opts?: PublishAndStartOptions,
215
+ ): Promise<PublishAndStartResult>;
216
+ }
217
+
218
+ // ============================================================================
219
+ // bridgeRegistry — emitted by codegen (BRIDGE-6), consumed by drain (BRIDGE-4),
220
+ // the framework handler (BRIDGE-5), and the EventFlow facade (BRIDGE-7).
221
+ // ============================================================================
222
+
223
+ /**
224
+ * One entry in the `bridgeRegistry`. Generated from a user job's
225
+ * `@JobHandler({ triggers: [...] })` decorator metadata in BRIDGE-6.
226
+ *
227
+ * The `T extends EventTypeName` parameter is what gives `map`/`when`
228
+ * callbacks compile-time access to the typed payload via `EventOfType<T>`.
229
+ * Codegen emits one entry per (job, trigger-index) pair, so `triggerId` is
230
+ * stable across re-runs.
231
+ *
232
+ * `triggerId` shape: `<jobType>#<triggerIndex>`. Forms the second half of
233
+ * the `bridge_delivery (event_id, trigger_id)` UNIQUE idempotency key.
234
+ *
235
+ * `map` is required and returns the input payload to pass to
236
+ * `IJobOrchestrator.start(jobType, input, ...)` — typed as `unknown` here
237
+ * because the registry is event-keyed, not job-keyed (one event can fan
238
+ * out to N jobs with N input shapes).
239
+ *
240
+ * `when` is optional. When provided and the predicate returns `false` at
241
+ * handler time, `BridgeDeliveryHandler` records the delivery as
242
+ * `skipped` with `skip_reason='predicate_false'` rather than spawning the
243
+ * user job (ADR-023 §Decision 6).
244
+ */
245
+ export interface BridgeTriggerEntry<
246
+ T extends EventTypeName = EventTypeName,
247
+ > {
248
+ triggerId: string;
249
+ jobType: string;
250
+ map: (event: EventOfType<T>) => unknown;
251
+ when?: (event: EventOfType<T>) => boolean;
252
+ }
253
+
254
+ /**
255
+ * Codegen-emitted registry — `Record<EventTypeName, BridgeTriggerEntry[]>`.
256
+ * Per-event-type ordered array of triggers (declaration order across
257
+ * handler files, deterministic across codegens so `triggerId` indices stay
258
+ * stable).
259
+ *
260
+ * The mapped-type form (`{ [T in EventTypeName]?: BridgeTriggerEntry<T>[] }`)
261
+ * gives each entry's `map`/`when` callbacks the right `EventOfType<T>`
262
+ * narrowing under indexed access.
263
+ */
264
+ export type BridgeRegistry = {
265
+ [T in EventTypeName]?: BridgeTriggerEntry<T>[];
266
+ };
267
+
268
+
269
+ // ============================================================================
270
+ // IBridgeOutboxDrainHook — port the events outbox drain calls per event
271
+ // ============================================================================
272
+
273
+ /**
274
+ * Result of one drain-hook invocation, returned for observability + tests.
275
+ *
276
+ * `delivered`: number of `bridge_delivery + wrapper job_run` row pairs the
277
+ * hook actually inserted for this event (post-`ON CONFLICT DO NOTHING`).
278
+ *
279
+ * `dedupSkips`: number of triggers whose `bridge_delivery` insert tripped
280
+ * `UNIQUE (event_id, trigger_id)` and was skipped (Case B from ADR-023's
281
+ * facade-eager pre-write, or replay of a previous drain attempt). These
282
+ * are not failures — they're the dedup mechanism doing its job.
283
+ *
284
+ * `triggerCount`: total triggers matched in the registry for this event;
285
+ * `triggerCount === delivered + dedupSkips`.
286
+ */
287
+ export interface BridgeOutboxDrainResult {
288
+ delivered: number;
289
+ dedupSkips: number;
290
+ triggerCount: number;
291
+ }
292
+
293
+ /**
294
+ * Port the events outbox drain (EVT-4 / `DrizzleEventBus.processBatch`)
295
+ * calls once per drained event, INSIDE the per-event transaction
296
+ * (BRIDGE-4). Implemented by `BridgeOutboxDrainHook` in the bridge
297
+ * subsystem; injected as `@Optional()` into `DrizzleEventBus` so projects
298
+ * that haven't installed the bridge subsystem keep the EVT-4 baseline.
299
+ *
300
+ * Why a port and not direct schema imports inside the events subsystem:
301
+ * - Keeps the events subsystem free of any knowledge of bridge_delivery
302
+ * and wrapper job_run shape; the layering inversion that ADR-023
303
+ * names ("the drain must know about bridge") is captured in this one
304
+ * port, not strewn across every bridge column the drain touches.
305
+ * - Tests can mock the port and assert call-shape without spinning up
306
+ * the full bridge module.
307
+ * - `BridgeModule.forRoot()` (BRIDGE-8) wires the implementation; in
308
+ * non-bridge consumers the token is undefined and the drain skips
309
+ * the bridge block entirely. ADR-023 §Outbox drain atomicity is
310
+ * preserved either way (the per-event tx still wraps `processed_at`).
311
+ */
312
+ export interface IBridgeOutboxDrainHook {
313
+ /**
314
+ * Process one drained event's bridge fanout. Called inside the drain's
315
+ * per-event transaction; the hook writes `bridge_delivery + wrapper
316
+ * job_run` row pairs for every matched trigger via the supplied `tx`.
317
+ *
318
+ * Behaviour:
319
+ * 1. Looks up `bridgeRegistry[event.type]`. No matches → returns
320
+ * `{ delivered: 0, dedupSkips: 0, triggerCount: 0 }`; the drain
321
+ * proceeds to dispatch user subscribers + stamp `processed_at`.
322
+ * 2. For each matched trigger:
323
+ * - `INSERT INTO bridge_delivery (event_id, trigger_id, status,
324
+ * wrapper_run_id, tenant_id, ...) VALUES (...) ON CONFLICT
325
+ * (event_id, trigger_id) DO NOTHING RETURNING id`. Empty
326
+ * result ⇒ Case B / replay collision; skip wrapper insert for
327
+ * this trigger; sibling triggers still fire normally.
328
+ * - On insert success: `INSERT INTO job_run (type=
329
+ * '@framework/bridge_delivery', pool='events_<direction>',
330
+ * input={ deliveryId }, trigger_source='event', trigger_ref=
331
+ * event.id, tenant_id)`. The wrapper row is what the framework
332
+ * `BridgeDeliveryHandler` (BRIDGE-5) will eventually claim.
333
+ * 3. Returns the counts for observability.
334
+ *
335
+ * Throwing aborts the per-event tx — bridge inserts roll back, the
336
+ * `processed_at` stamp is not made, and the event re-claims on the next
337
+ * drain cycle. Callers should let infra exceptions propagate; recoverable
338
+ * conditions (null direction, missing registry entry) are handled
339
+ * inline and do not throw.
340
+ *
341
+ * Null `event.metadata.direction` MUST be tolerated: the wrapper pool
342
+ * is derived from direction; absent direction means the publisher
343
+ * predates ADR-024 (manual `eventBus.publish(...)` rather than
344
+ * `TypedEventBus.publish(...)`). Hook should log + return zeros so the
345
+ * drain still stamps `processed_at` and dispatches subscribers.
346
+ */
347
+ processEvent(
348
+ event: DomainEvent,
349
+ tx: DrizzleTransaction,
350
+ ): Promise<BridgeOutboxDrainResult>;
351
+ }