@pattern-stack/codegen 0.8.1 → 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 (107) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js.map +1 -1
  3. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js.map +1 -1
  4. package/dist/runtime/subsystems/bridge/bridge.module.d.ts +3 -0
  5. package/dist/runtime/subsystems/bridge/bridge.module.js +930 -275
  6. package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
  7. package/dist/runtime/subsystems/bridge/event-flow.service.js.map +1 -1
  8. package/dist/runtime/subsystems/bridge/index.d.ts +3 -0
  9. package/dist/runtime/subsystems/bridge/index.js +837 -182
  10. package/dist/runtime/subsystems/bridge/index.js.map +1 -1
  11. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +3 -1
  12. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +92 -1
  13. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js.map +1 -1
  14. package/dist/runtime/subsystems/events/event-bus.memory-backend.d.ts +3 -1
  15. package/dist/runtime/subsystems/events/event-bus.memory-backend.js +99 -0
  16. package/dist/runtime/subsystems/events/event-bus.memory-backend.js.map +1 -1
  17. package/dist/runtime/subsystems/events/event-bus.redis-backend.js.map +1 -1
  18. package/dist/runtime/subsystems/events/event-keyset-cursor.d.ts +32 -0
  19. package/dist/runtime/subsystems/events/event-keyset-cursor.js +38 -0
  20. package/dist/runtime/subsystems/events/event-keyset-cursor.js.map +1 -0
  21. package/dist/runtime/subsystems/events/event-read.protocol.d.ts +94 -0
  22. package/dist/runtime/subsystems/events/event-read.protocol.js +9 -0
  23. package/dist/runtime/subsystems/events/event-read.protocol.js.map +1 -0
  24. package/dist/runtime/subsystems/events/events.module.js +177 -3
  25. package/dist/runtime/subsystems/events/events.module.js.map +1 -1
  26. package/dist/runtime/subsystems/events/events.tokens.d.ts +16 -1
  27. package/dist/runtime/subsystems/events/events.tokens.js +2 -0
  28. package/dist/runtime/subsystems/events/events.tokens.js.map +1 -1
  29. package/dist/runtime/subsystems/events/generated/bus.js.map +1 -1
  30. package/dist/runtime/subsystems/events/generated/index.js.map +1 -1
  31. package/dist/runtime/subsystems/events/index.d.ts +2 -1
  32. package/dist/runtime/subsystems/events/index.js +178 -3
  33. package/dist/runtime/subsystems/events/index.js.map +1 -1
  34. package/dist/runtime/subsystems/index.d.ts +1 -0
  35. package/dist/runtime/subsystems/index.js +1194 -264
  36. package/dist/runtime/subsystems/index.js.map +1 -1
  37. package/dist/runtime/subsystems/jobs/bullmq.config.d.ts +98 -0
  38. package/dist/runtime/subsystems/jobs/bullmq.config.js +143 -0
  39. package/dist/runtime/subsystems/jobs/bullmq.config.js.map +1 -0
  40. package/dist/runtime/subsystems/jobs/index.d.ts +6 -2
  41. package/dist/runtime/subsystems/jobs/index.js +861 -201
  42. package/dist/runtime/subsystems/jobs/index.js.map +1 -1
  43. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.d.ts +107 -0
  44. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +922 -0
  45. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -0
  46. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.d.ts +52 -0
  47. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js +57 -0
  48. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js.map +1 -0
  49. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.d.ts +2 -1
  50. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +81 -1
  51. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js.map +1 -1
  52. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.d.ts +2 -1
  53. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +81 -0
  54. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js.map +1 -1
  55. package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +74 -1
  56. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.d.ts +48 -0
  57. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +374 -0
  58. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js.map +1 -0
  59. package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +42 -4
  60. package/dist/runtime/subsystems/jobs/job-worker.module.js +832 -178
  61. package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
  62. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +10 -1
  63. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +519 -20
  64. package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
  65. package/dist/runtime/subsystems/jobs/pool-config.loader.d.ts +9 -1
  66. package/dist/runtime/subsystems/jobs/pool-config.loader.js +4 -0
  67. package/dist/runtime/subsystems/jobs/pool-config.loader.js.map +1 -1
  68. package/dist/runtime/subsystems/observability/index.d.ts +4 -3
  69. package/dist/runtime/subsystems/observability/index.js +109 -2
  70. package/dist/runtime/subsystems/observability/index.js.map +1 -1
  71. package/dist/runtime/subsystems/observability/observability.module.js +109 -2
  72. package/dist/runtime/subsystems/observability/observability.module.js.map +1 -1
  73. package/dist/runtime/subsystems/observability/observability.protocol.d.ts +63 -2
  74. package/dist/runtime/subsystems/observability/observability.service.d.ts +21 -3
  75. package/dist/runtime/subsystems/observability/observability.service.js +109 -2
  76. package/dist/runtime/subsystems/observability/observability.service.js.map +1 -1
  77. package/dist/runtime/subsystems/observability/reporters/bridge-metrics.reporter.d.ts +1 -0
  78. package/dist/runtime/subsystems/observability/reporters/index.d.ts +1 -0
  79. package/dist/src/cli/index.js +30 -6
  80. package/dist/src/cli/index.js.map +1 -1
  81. package/package.json +1 -1
  82. package/runtime/subsystems/bridge/bridge.module.ts +5 -0
  83. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +109 -3
  84. package/runtime/subsystems/events/event-bus.memory-backend.ts +103 -1
  85. package/runtime/subsystems/events/event-keyset-cursor.ts +59 -0
  86. package/runtime/subsystems/events/event-read.protocol.ts +97 -0
  87. package/runtime/subsystems/events/events.module.ts +18 -2
  88. package/runtime/subsystems/events/events.tokens.ts +16 -0
  89. package/runtime/subsystems/events/index.ts +7 -0
  90. package/runtime/subsystems/jobs/bullmq.config.ts +125 -0
  91. package/runtime/subsystems/jobs/index.ts +22 -0
  92. package/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.ts +381 -0
  93. package/runtime/subsystems/jobs/job-run-keyset-cursor.ts +88 -0
  94. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +59 -1
  95. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +53 -0
  96. package/runtime/subsystems/jobs/job-run-service.protocol.ts +77 -0
  97. package/runtime/subsystems/jobs/job-worker.bullmq-backend.ts +311 -0
  98. package/runtime/subsystems/jobs/job-worker.module.ts +124 -10
  99. package/runtime/subsystems/jobs/jobs-domain.module.ts +40 -21
  100. package/runtime/subsystems/jobs/pool-config.loader.ts +11 -0
  101. package/runtime/subsystems/observability/index.ts +8 -0
  102. package/runtime/subsystems/observability/observability.protocol.ts +76 -0
  103. package/runtime/subsystems/observability/observability.service.ts +148 -1
  104. package/templates/entity/new/clean-lite-ps/prompt-extension.js +14 -12
  105. package/templates/relationship/new/prompt.js +8 -5
  106. package/templates/subsystem/jobs/worker.ejs.t +30 -7
  107. package/templates/subsystem/sync/sync-audit.schema.ejs.t +12 -16
@@ -24,10 +24,17 @@ import {
24
24
  import { DrizzleJobOrchestrator } from './job-orchestrator.drizzle-backend';
25
25
  import { DrizzleJobRunService } from './job-run-service.drizzle-backend';
26
26
  import { DrizzleJobStepService } from './job-step-service.drizzle-backend';
27
+ import { BullMQJobOrchestrator } from './job-orchestrator.bullmq-backend';
27
28
  import { MemoryJobOrchestrator } from './job-orchestrator.memory-backend';
28
29
  import { MemoryJobRunService } from './job-run-service.memory-backend';
29
30
  import { MemoryJobStepService } from './job-step-service.memory-backend';
30
31
  import { MemoryJobStore } from './memory-job-store';
32
+ import {
33
+ BULLMQ_CONNECTION,
34
+ BULLMQ_RESOLVED_CONFIG,
35
+ resolveBullMqConfig,
36
+ type BullMqExtensionsConfig,
37
+ } from './bullmq.config';
31
38
 
32
39
  /**
33
40
  * Drizzle backend extensions surface. None are wired into the Drizzle
@@ -44,19 +51,8 @@ export interface DrizzleBackendExtensions {
44
51
  pollIntervalMs?: number;
45
52
  }
46
53
 
47
- // Phase 6+ — typed-but-unimplemented BullMQ extension slot. Kept as a
48
- // commented-out interface to make the future shape discoverable without
49
- // shipping dead runtime code. Per CLAUDE.md "no feature-flag-guarded dead
50
- // code" we don't ship the option in `JobsDomainModuleOptions.extensions`
51
- // either; flip it on when JOB-Phase-6 lands the BullMQ orchestrator.
52
- //
53
- // export interface BullMqBackendExtensions {
54
- // bullBoard?: { enabled: boolean; mountPath?: string };
55
- // redisUrl?: string;
56
- // }
57
-
58
54
  export interface JobsDomainModuleOptions {
59
- backend: 'drizzle' | 'memory';
55
+ backend: 'drizzle' | 'memory' | 'bullmq';
60
56
  /**
61
57
  * Backend-specific extensions. Only the matching backend's extensions
62
58
  * are read at boot; non-matching keys are ignored. This is the
@@ -64,7 +60,12 @@ export interface JobsDomainModuleOptions {
64
60
  */
65
61
  extensions?: {
66
62
  drizzle?: DrizzleBackendExtensions;
67
- // bullmq?: BullMqBackendExtensions; // Phase 6+
63
+ /**
64
+ * BullMQ backend extensions (BULLMQ-1). Snake_case mirrors the YAML
65
+ * under `jobs.extensions.bullmq`. `redis_url` falls back to
66
+ * `process.env.REDIS_URL` then `redis://localhost:6379`.
67
+ */
68
+ bullmq?: BullMqExtensionsConfig;
68
69
  };
69
70
  /** Multi-tenancy opt-in. Wired by JOB-8; module signature stays stable. */
70
71
  multiTenant?: boolean;
@@ -73,8 +74,6 @@ export interface JobsDomainModuleOptions {
73
74
  @Module({})
74
75
  export class JobsDomainModule {
75
76
  static forRoot(opts: JobsDomainModuleOptions): DynamicModule {
76
- void opts.extensions; // typed reservation; consumed by Phase 6+ wiring
77
-
78
77
  const multiTenant = opts.multiTenant ?? false;
79
78
 
80
79
  const providers: Provider[] = [
@@ -98,22 +97,42 @@ export class JobsDomainModule {
98
97
  providers.push({ provide: JOB_ORCHESTRATOR, useExisting: MemoryJobOrchestrator });
99
98
  providers.push(MemoryJobRunService);
100
99
  providers.push({ provide: JOB_RUN_SERVICE, useExisting: MemoryJobRunService });
100
+ } else if (opts.backend === 'bullmq') {
101
+ // BULLMQ-1 — BullMQ orchestrator over a Postgres source of truth. The
102
+ // run/step services stay Drizzle (domain reads + `listForScope` are
103
+ // Postgres queries, unchanged per spec). Only the orchestrator's
104
+ // claim/dispatch half swaps to BullMQ.
105
+ const resolved = resolveBullMqConfig(opts.extensions?.bullmq);
106
+ providers.push({ provide: BULLMQ_CONNECTION, useValue: resolved.connection });
107
+ providers.push({ provide: BULLMQ_RESOLVED_CONFIG, useValue: resolved });
108
+ providers.push({ provide: JOB_ORCHESTRATOR, useClass: BullMQJobOrchestrator });
109
+ providers.push({ provide: JOB_RUN_SERVICE, useClass: DrizzleJobRunService });
110
+ providers.push({ provide: JOB_STEP_SERVICE, useClass: DrizzleJobStepService });
101
111
  } else {
102
112
  providers.push({ provide: JOB_ORCHESTRATOR, useClass: DrizzleJobOrchestrator });
103
113
  providers.push({ provide: JOB_RUN_SERVICE, useClass: DrizzleJobRunService });
104
114
  providers.push({ provide: JOB_STEP_SERVICE, useClass: DrizzleJobStepService });
105
115
  }
106
116
 
117
+ const exports = [
118
+ JOB_ORCHESTRATOR,
119
+ JOB_RUN_SERVICE,
120
+ JOB_STEP_SERVICE,
121
+ JOBS_MULTI_TENANT,
122
+ ];
123
+ // BULLMQ-1 — only export the BullMQ tokens when they were actually
124
+ // provided. Nest throws "exported but not provided" otherwise. Exported so
125
+ // JobWorkerModule (which imports this module) can read the resolved
126
+ // connection/config to spawn BullMQ workers.
127
+ if (opts.backend === 'bullmq') {
128
+ exports.push(BULLMQ_CONNECTION, BULLMQ_RESOLVED_CONFIG);
129
+ }
130
+
107
131
  return {
108
132
  module: JobsDomainModule,
109
133
  global: true,
110
134
  providers,
111
- exports: [
112
- JOB_ORCHESTRATOR,
113
- JOB_RUN_SERVICE,
114
- JOB_STEP_SERVICE,
115
- JOBS_MULTI_TENANT,
116
- ],
135
+ exports,
117
136
  };
118
137
  }
119
138
  }
@@ -194,6 +194,17 @@ export function allNonReservedPoolNames(config: PoolConfig): string[] {
194
194
  return out;
195
195
  }
196
196
 
197
+ /**
198
+ * Names of **every** pool in the resolved config, reserved `events_*` lanes
199
+ * included. The activation set for a standalone worker booted with
200
+ * `JobWorkerModule.forRoot({ allPools: true })` (BULLMQ-1 Phase 1) — the
201
+ * single worker process drains both user pools and the bridge's reserved
202
+ * pools so wrapper `job_run` rows are never stranded.
203
+ */
204
+ export function allPoolNames(config: PoolConfig): string[] {
205
+ return [...config.keys()];
206
+ }
207
+
197
208
  // ─── internals ──────────────────────────────────────────────────────────────
198
209
 
199
210
  interface UserPoolShape {
@@ -30,6 +30,14 @@ export type {
30
30
  IObservability,
31
31
  PoolStatusCount,
32
32
  JobRunFailure,
33
+ JobRunSummary,
34
+ JobRunPage,
35
+ ListJobRunsQuery,
36
+ EventSummary,
37
+ EventPage,
38
+ ListEventsQuery,
39
+ CorrelationTimeline,
40
+ CorrelationTimelineEntry,
33
41
  StatusHistogram,
34
42
  SyncRunSummary,
35
43
  CursorSnapshot,
@@ -21,12 +21,49 @@
21
21
 
22
22
  import type {
23
23
  JobRunFailure,
24
+ JobRunPage,
25
+ JobRunSummary,
26
+ ListJobRunsQuery,
24
27
  PoolStatusCount,
25
28
  } from '../jobs/job-run-service.protocol';
29
+ import type {
30
+ EventPage,
31
+ EventSummary,
32
+ ListEventsQuery,
33
+ } from '../events/event-read.protocol';
26
34
  import type { StatusHistogram } from '../bridge/bridge.protocol';
27
35
  import type { SyncRunSummary } from '../sync/sync-run-recorder.protocol';
28
36
  import type { CursorSnapshot } from '../sync/sync-cursor-store.protocol';
29
37
 
38
+ /**
39
+ * One chronological entry in a correlation timeline (OBS-LIST-1). Either a
40
+ * `job_run` or a `domain_event` sharing the same `rootRunId`, tagged with a
41
+ * `kind` discriminator and a single `at` timestamp used for ordering.
42
+ */
43
+ export type CorrelationTimelineEntry =
44
+ | { kind: 'job_run'; at: Date; run: JobRunSummary }
45
+ | { kind: 'event'; at: Date; event: EventSummary };
46
+
47
+ /**
48
+ * Stitched view of everything correlated to a single `rootRunId`
49
+ * (OBS-LIST-1): the job runs sharing that root plus the domain events whose
50
+ * `metadata.rootRunId` matches, merged into one ascending timeline with a
51
+ * small roll-up summary.
52
+ */
53
+ export interface CorrelationTimeline {
54
+ rootRunId: string;
55
+ /** Ascending by `at`. Job runs ordered by `createdAt`; events by `occurredAt`. */
56
+ entries: CorrelationTimelineEntry[];
57
+ summary: {
58
+ runCount: number;
59
+ eventCount: number;
60
+ /** Earliest `at` across all entries, or `null` when empty. */
61
+ startedAt: Date | null;
62
+ /** Latest `at` across all entries, or `null` when empty. */
63
+ lastActivityAt: Date | null;
64
+ };
65
+ }
66
+
30
67
  export interface IObservability {
31
68
  /**
32
69
  * Live `(pool, status)` counts across `job_run`. Delegates to
@@ -79,6 +116,39 @@ export interface IObservability {
79
116
  * Empty array when the sync subsystem is not installed.
80
117
  */
81
118
  getCursors(tenantId?: string | null): Promise<CursorSnapshot[]>;
119
+
120
+ /**
121
+ * Paginated, filterable `job_run` list (OBS-LIST-1). Delegates to
122
+ * `IJobRunService.listJobRuns`. Keyset pagination on `created_at`.
123
+ *
124
+ * Returns an empty page (`{ items: [], nextCursor: null }`) when the jobs
125
+ * subsystem is not installed.
126
+ */
127
+ listJobRuns(query?: ListJobRunsQuery): Promise<JobRunPage>;
128
+
129
+ /**
130
+ * Paginated, filterable `domain_events` list (OBS-LIST-1). Delegates to
131
+ * `IEventReadPort.listEvents`. Keyset pagination on `occurred_at`.
132
+ *
133
+ * Returns an empty page when the events read port is not installed (e.g.
134
+ * the events subsystem is absent, or its backend is `redis` which retains
135
+ * no history).
136
+ */
137
+ listEvents(query?: ListEventsQuery): Promise<EventPage>;
138
+
139
+ /**
140
+ * Stitch the job runs and domain events sharing a `rootRunId` into a
141
+ * single ascending timeline + summary (OBS-LIST-1). Composes
142
+ * `IJobRunService.listJobRuns` (filtered by the run tree) and
143
+ * `IEventReadPort.listEvents({ rootRunId })`.
144
+ *
145
+ * Returns an empty timeline (zero counts, null bounds) when neither the
146
+ * jobs subsystem nor the events read port is installed.
147
+ */
148
+ getCorrelationTimeline(
149
+ rootRunId: string,
150
+ tenantId?: string | null,
151
+ ): Promise<CorrelationTimeline>;
82
152
  }
83
153
 
84
154
  // Re-export composed return types so consumers of IObservability can import
@@ -86,6 +156,12 @@ export interface IObservability {
86
156
  export type {
87
157
  PoolStatusCount,
88
158
  JobRunFailure,
159
+ JobRunSummary,
160
+ JobRunPage,
161
+ ListJobRunsQuery,
162
+ EventSummary,
163
+ EventPage,
164
+ ListEventsQuery,
89
165
  StatusHistogram,
90
166
  SyncRunSummary,
91
167
  CursorSnapshot,
@@ -33,9 +33,20 @@ import { JOB_RUN_SERVICE } from '../jobs/jobs-domain.tokens';
33
33
  import type {
34
34
  IJobRunService,
35
35
  JobRunFailure,
36
+ JobRunPage,
37
+ JobRunSummary,
38
+ ListJobRunsQuery,
36
39
  PoolStatusCount,
37
40
  } from '../jobs/job-run-service.protocol';
38
41
 
42
+ import { EVENT_READ_PORT } from '../events/events.tokens';
43
+ import type {
44
+ EventPage,
45
+ EventSummary,
46
+ IEventReadPort,
47
+ ListEventsQuery,
48
+ } from '../events/event-read.protocol';
49
+
39
50
  import { BRIDGE_DELIVERY_REPO } from '../bridge/bridge.tokens';
40
51
  import type { IJobBridge, StatusHistogram } from '../bridge/bridge.protocol';
41
52
 
@@ -49,7 +60,19 @@ import type {
49
60
  ICursorStore,
50
61
  } from '../sync/sync-cursor-store.protocol';
51
62
 
52
- import type { IObservability } from './observability.protocol';
63
+ import type {
64
+ CorrelationTimeline,
65
+ CorrelationTimelineEntry,
66
+ IObservability,
67
+ } from './observability.protocol';
68
+
69
+ /**
70
+ * Safety bound on how many pages the correlation timeline will walk when
71
+ * draining a sibling port. A single run tree producing more than
72
+ * 50 pages × default page size of correlated rows is pathological; cap to
73
+ * keep the stitch bounded rather than unbounded-loop on bad data.
74
+ */
75
+ const MAX_TIMELINE_PAGES = 50;
53
76
 
54
77
  @Injectable()
55
78
  export class ObservabilityService implements IObservability {
@@ -65,6 +88,16 @@ export class ObservabilityService implements IObservability {
65
88
  failed: 0,
66
89
  };
67
90
 
91
+ /** Empty page used when a sibling read port is absent. */
92
+ private static readonly EMPTY_JOB_RUN_PAGE: JobRunPage = {
93
+ items: [],
94
+ nextCursor: null,
95
+ };
96
+ private static readonly EMPTY_EVENT_PAGE: EventPage = {
97
+ items: [],
98
+ nextCursor: null,
99
+ };
100
+
68
101
  constructor(
69
102
  @Optional()
70
103
  @Inject(JOB_RUN_SERVICE)
@@ -78,6 +111,9 @@ export class ObservabilityService implements IObservability {
78
111
  @Optional()
79
112
  @Inject(SYNC_CURSOR_STORE)
80
113
  private readonly cursors?: ICursorStore,
114
+ @Optional()
115
+ @Inject(EVENT_READ_PORT)
116
+ private readonly events?: IEventReadPort | null,
81
117
  ) {}
82
118
 
83
119
  async getPoolDepths(tenantId?: string | null): Promise<PoolStatusCount[]> {
@@ -114,4 +150,115 @@ export class ObservabilityService implements IObservability {
114
150
  if (!this.cursors) return [];
115
151
  return this.cursors.listAll(tenantId);
116
152
  }
153
+
154
+ async listJobRuns(query?: ListJobRunsQuery): Promise<JobRunPage> {
155
+ if (!this.jobRuns) {
156
+ return { ...ObservabilityService.EMPTY_JOB_RUN_PAGE };
157
+ }
158
+ return this.jobRuns.listJobRuns(query);
159
+ }
160
+
161
+ async listEvents(query?: ListEventsQuery): Promise<EventPage> {
162
+ if (!this.events) {
163
+ return { ...ObservabilityService.EMPTY_EVENT_PAGE };
164
+ }
165
+ return this.events.listEvents(query);
166
+ }
167
+
168
+ async getCorrelationTimeline(
169
+ rootRunId: string,
170
+ tenantId?: string | null,
171
+ ): Promise<CorrelationTimeline> {
172
+ const runs = await this.collectRuns(rootRunId, tenantId);
173
+ const events = await this.collectEvents(rootRunId, tenantId);
174
+
175
+ const entries: CorrelationTimelineEntry[] = [
176
+ ...runs.map(
177
+ (run): CorrelationTimelineEntry => ({
178
+ kind: 'job_run',
179
+ at: run.createdAt,
180
+ run,
181
+ }),
182
+ ),
183
+ ...events.map(
184
+ (event): CorrelationTimelineEntry => ({
185
+ kind: 'event',
186
+ at: event.occurredAt,
187
+ event,
188
+ }),
189
+ ),
190
+ ];
191
+
192
+ // Ascending chronological order. Stable tie-break: job runs before
193
+ // events at the same instant (the run that emits an event precedes it).
194
+ entries.sort((a, b) => {
195
+ const dt = a.at.getTime() - b.at.getTime();
196
+ if (dt !== 0) return dt;
197
+ if (a.kind === b.kind) return 0;
198
+ return a.kind === 'job_run' ? -1 : 1;
199
+ });
200
+
201
+ const startedAt = entries.length > 0 ? entries[0]!.at : null;
202
+ const lastActivityAt =
203
+ entries.length > 0 ? entries[entries.length - 1]!.at : null;
204
+
205
+ return {
206
+ rootRunId,
207
+ entries,
208
+ summary: {
209
+ runCount: runs.length,
210
+ eventCount: events.length,
211
+ startedAt,
212
+ lastActivityAt,
213
+ },
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Drain every `job_run` sharing `rootRunId` by walking the keyset cursor.
219
+ * Empty when the jobs subsystem is absent.
220
+ */
221
+ private async collectRuns(
222
+ rootRunId: string,
223
+ tenantId?: string | null,
224
+ ): Promise<JobRunSummary[]> {
225
+ if (!this.jobRuns) return [];
226
+ const out: JobRunSummary[] = [];
227
+ let cursor: string | undefined;
228
+ for (let page = 0; page < MAX_TIMELINE_PAGES; page += 1) {
229
+ const result = await this.jobRuns.listJobRuns({
230
+ rootRunId,
231
+ tenantId,
232
+ cursor,
233
+ });
234
+ out.push(...result.items);
235
+ if (!result.nextCursor) break;
236
+ cursor = result.nextCursor;
237
+ }
238
+ return out;
239
+ }
240
+
241
+ /**
242
+ * Drain every `domain_event` whose `metadata.rootRunId` matches by walking
243
+ * the keyset cursor. Empty when the events read port is absent.
244
+ */
245
+ private async collectEvents(
246
+ rootRunId: string,
247
+ tenantId?: string | null,
248
+ ): Promise<EventSummary[]> {
249
+ if (!this.events) return [];
250
+ const out: EventSummary[] = [];
251
+ let cursor: string | undefined;
252
+ for (let page = 0; page < MAX_TIMELINE_PAGES; page += 1) {
253
+ const result = await this.events.listEvents({
254
+ rootRunId,
255
+ tenantId,
256
+ cursor,
257
+ });
258
+ out.push(...result.items);
259
+ if (!result.nextCursor) break;
260
+ cursor = result.nextCursor;
261
+ }
262
+ return out;
263
+ }
117
264
  }
@@ -495,28 +495,30 @@ function collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSof
495
495
  function zodChainForCreate(field) {
496
496
  const { type, nullable, required, hasDefault, hasChoices, choices } = field;
497
497
 
498
+ // Apply nullability and optionality INDEPENDENTLY. A nullable column accepts
499
+ // null (`.nullable()`); a field without `required: true` may be omitted from
500
+ // the create payload (`.optional()`). A field that is both gets
501
+ // `.nullable().optional()`. Previously the `nullable` branch returned early,
502
+ // so a nullable-and-optional field never got `.optional()` — forcing callers
503
+ // to send an explicit `null` for every optional column (e.g. POST /accounts
504
+ // rejecting a body that omits `domain`/`industry`).
498
505
  if (hasChoices) {
499
- const base = `z.enum([${choices.map((c) => `'${c}'`).join(', ')}])`;
500
- if (!required && !nullable) return base + '.optional()';
501
- if (nullable) return base + '.nullable()';
506
+ let base = `z.enum([${choices.map((c) => `'${c}'`).join(', ')}])`;
507
+ if (nullable) base += '.nullable()';
508
+ if (!required) base += '.optional()';
502
509
  return base;
503
510
  }
504
511
 
505
512
  let base = ZOD_TYPE_MAP[type] || 'z.unknown()';
506
513
 
507
514
  if (type === 'boolean' && hasDefault) {
515
+ // `.default()` already makes the input optional in Zod.
508
516
  base += `.default(${field.default ?? false})`;
509
517
  return base;
510
518
  }
511
519
 
512
- if (nullable) {
513
- return base + '.nullable()';
514
- }
515
-
516
- if (!required) {
517
- return base + '.optional()';
518
- }
519
-
520
+ if (nullable) base += '.nullable()';
521
+ if (!required) base += '.optional()';
520
522
  return base;
521
523
  }
522
524
 
@@ -1132,7 +1134,7 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
1132
1134
  // FK fields from belongs_to for create/output DTOs
1133
1135
  const belongsToFkFields = belongsTo.map((rel) => ({
1134
1136
  camelName: rel.camelField,
1135
- zodChainCreate: rel.nullable ? 'z.string().uuid().nullable()' : 'z.string().uuid()',
1137
+ zodChainCreate: rel.nullable ? 'z.string().uuid().nullable().optional()' : 'z.string().uuid()',
1136
1138
  zodChainOutput: rel.nullable ? 'z.string().uuid().nullable()' : 'z.string().uuid()',
1137
1139
  nullable: rel.nullable,
1138
1140
  }));
@@ -203,10 +203,13 @@ function processFields(fields) {
203
203
  function zodChainForCreate(field) {
204
204
  const { type, nullable, required, hasDefault, hasChoices, choices } = field;
205
205
 
206
+ // Nullability and optionality are independent — see the clean-lite-ps copy of
207
+ // this function for the rationale (nullable-and-optional fields must get both
208
+ // `.nullable()` and `.optional()`, not just `.nullable()`).
206
209
  if (hasChoices) {
207
- const base = `z.enum([${choices.map((c) => `'${c}'`).join(", ")}])`;
208
- if (!required && !nullable) return base + ".optional()";
209
- if (nullable) return base + ".nullable()";
210
+ let base = `z.enum([${choices.map((c) => `'${c}'`).join(", ")}])`;
211
+ if (nullable) base += ".nullable()";
212
+ if (!required) base += ".optional()";
210
213
  return base;
211
214
  }
212
215
 
@@ -215,8 +218,8 @@ function zodChainForCreate(field) {
215
218
  base += `.default(${field.default ?? false})`;
216
219
  return base;
217
220
  }
218
- if (nullable) return base + ".nullable()";
219
- if (!required) return base + ".optional()";
221
+ if (nullable) base += ".nullable()";
222
+ if (!required) base += ".optional()";
220
223
  return base;
221
224
  }
222
225
 
@@ -5,13 +5,30 @@ unless_exists: true
5
5
  /**
6
6
  * Standalone job worker entrypoint — emitted by `codegen subsystem install jobs`.
7
7
  *
8
- * Boots a Nest application context (NO HTTP listener) wiring the jobs domain
9
- * module plus JobWorkerModule in `standalone` mode. Run with:
8
+ * Boots a Nest application context (NO HTTP listener) wiring the full
9
+ * subsystem barrel (`SUBSYSTEM_MODULES` events + jobs + bridge + sync, in
10
+ * dependency order) plus `JobWorkerModule.forRoot({ mode: 'standalone',
11
+ * allPools: true })`. Run with:
10
12
  *
11
13
  * bun worker.ts
12
14
  *
15
+ * Why the barrel + `allPools`:
16
+ * - The events subsystem's outbox drain and the bridge's fanout wrappers
17
+ * run as `job_run` rows in the RESERVED `events_*` pools. A worker that
18
+ * only polls the non-reserved pools (`interactive`, `batch`, …) leaves
19
+ * those lanes stranded — `BridgeDeliveryHandler` never fires and durable
20
+ * event→job fanout silently stops.
21
+ * - `allPools: true` activates every pool in the resolved config, reserved
22
+ * lanes included, so this single standalone process drains both user work
23
+ * and the framework's reserved lanes.
24
+ * - Importing `SUBSYSTEM_MODULES` (rather than `JobsDomainModule` alone)
25
+ * registers `EVENT_BUS` / `JOB_ORCHESTRATOR` / `BRIDGE_*` so the
26
+ * framework `@framework/bridge_delivery` handler resolves its DI deps.
27
+ * `BridgeModule`'s reserved-pool guard short-circuits to pass because
28
+ * `allPools` is set.
29
+ *
13
30
  * Embedded mode (single-process) is configured by importing
14
- * JobWorkerModule.forRoot({ mode: 'embedded' }) inside AppModule instead —
31
+ * `JobWorkerModule.forRoot({ mode: 'embedded' })` inside AppModule instead —
15
32
  * see the commented guidance injected into `src/main.ts`.
16
33
  *
17
34
  * SIGTERM triggers graceful shutdown bounded by SHUTDOWN_TIMEOUT_MS; after the
@@ -23,16 +40,22 @@ import { Logger, Module } from '@nestjs/common';
23
40
  import { NestFactory } from '@nestjs/core';
24
41
 
25
42
  import { DatabaseModule } from '@shared/database/database.module';
26
- import { JobsDomainModule } from '@shared/subsystems/jobs/jobs-domain.module';
27
43
  import { JobWorkerModule } from '@shared/subsystems/jobs/job-worker.module';
44
+ import { SUBSYSTEM_MODULES } from '@generated/subsystems';
28
45
 
29
46
  const SHUTDOWN_TIMEOUT_MS = 30_000;
30
47
 
31
48
  @Module({
32
49
  imports: [
33
50
  DatabaseModule,
34
- JobsDomainModule.forRoot({ backend: 'drizzle' }),
35
- JobWorkerModule.forRoot({ mode: 'standalone' }),
51
+ // Events + Jobs + Bridge + Sync (dependency-ordered) from the generated
52
+ // barrel. This is the same composition AppModule imports — keeping the
53
+ // worker's DI graph identical to the HTTP app's so handlers resolve the
54
+ // same way in both processes.
55
+ ...SUBSYSTEM_MODULES,
56
+ // `allPools: true` drains the reserved `events_*` lanes (events outbox +
57
+ // bridge wrappers) alongside the user pools.
58
+ JobWorkerModule.forRoot({ mode: 'standalone', allPools: true }),
36
59
  ],
37
60
  })
38
61
  class WorkerAppModule {}
@@ -72,7 +95,7 @@ async function bootstrap(): Promise<void> {
72
95
  void shutdown('SIGINT');
73
96
  });
74
97
 
75
- logger.log('job worker started (standalone mode)');
98
+ logger.log('job worker started (standalone mode, all pools)');
76
99
  }
77
100
 
78
101
  bootstrap().catch((err) => {
@@ -19,14 +19,16 @@ force: true
19
19
  * is owned by the sync subsystem's runtime protocol
20
20
  * (`sync-field-diff.protocol.ts` from SYNC-2).
21
21
  *
22
- * ## `tenant_id` columns — scaffold-time conditional
22
+ * ## `tenant_id` columns — always emitted
23
23
  *
24
- * When `sync.multi_tenant: true` in `codegen.config.yaml`, this schema
25
- * emits `tenant_id` as a nullable text column on all three tables. The
26
- * `SYNC_MULTI_TENANT` DI flag (SYNC-6) enforces non-null at runtime
27
- * across the orchestrator + Drizzle backends. Enabling post-install
28
- * requires reinstalling this subsystem (`subsystem install sync --force
29
- * --force-config`) plus an Atlas migration.
24
+ * `tenant_id` is emitted as a nullable text column on all three tables
25
+ * REGARDLESS of `sync.multi_tenant` the runtime sync code (cursor store +
26
+ * run recorder) references `tenant_id` unconditionally, so a `multi_tenant:
27
+ * false` consumer that omitted the column failed to type-check (the column
28
+ * was referenced but absent). The `SYNC_MULTI_TENANT` DI flag (SYNC-6) gates
29
+ * non-null *enforcement* at runtime; it does not gate the column's existence
30
+ * (mirrors the jobs subsystem). Under `multi_tenant: false` the column simply
31
+ * stays null.
30
32
  *
31
33
  * See SYNC-1 / SYNC-6 in epic #60 for the decision rationale.
32
34
  */
@@ -98,9 +100,7 @@ export const syncSubscriptions = pgTable(
98
100
  config: jsonb('config').notNull().default({}).$type<Record<string, unknown>>(),
99
101
  cursor: jsonb('cursor').$type<unknown>(),
100
102
  lastSyncAt: timestamp('last_sync_at', { withTimezone: true }),
101
- <% if (multiTenant) { -%>
102
- tenantId: text('tenant_id'), // scaffold-time conditional — see sync.multi_tenant
103
- <% } -%>
103
+ tenantId: text('tenant_id'), // always emitted — the runtime sync code (cursor store + run recorder) references tenant_id unconditionally; SYNC_MULTI_TENANT gates enforcement, not the column's existence (mirrors jobs)
104
104
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
105
105
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
106
106
  },
@@ -141,9 +141,7 @@ export const syncRuns = pgTable(
141
141
  .notNull()
142
142
  .defaultNow(),
143
143
  completedAt: timestamp('completed_at', { withTimezone: true }),
144
- <% if (multiTenant) { -%>
145
- tenantId: text('tenant_id'), // scaffold-time conditional — see sync.multi_tenant
146
- <% } -%>
144
+ tenantId: text('tenant_id'), // always emitted — the runtime sync code (cursor store + run recorder) references tenant_id unconditionally; SYNC_MULTI_TENANT gates enforcement, not the column's existence (mirrors jobs)
147
145
  },
148
146
  (t) => ({
149
147
  idxSyncRunsSubscriptionStartedAt: index(
@@ -178,9 +176,7 @@ export const syncRunItems = pgTable(
178
176
  createdAt: timestamp('created_at', { withTimezone: true })
179
177
  .notNull()
180
178
  .defaultNow(),
181
- <% if (multiTenant) { -%>
182
- tenantId: text('tenant_id'), // scaffold-time conditional — see sync.multi_tenant
183
- <% } -%>
179
+ tenantId: text('tenant_id'), // always emitted — the runtime sync code (cursor store + run recorder) references tenant_id unconditionally; SYNC_MULTI_TENANT gates enforcement, not the column's existence (mirrors jobs)
184
180
  },
185
181
  (t) => ({
186
182
  idxSyncRunItemsRunCreatedAt: index('idx_sync_run_items_run_created_at').on(