@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.
- package/CHANGELOG.md +29 -0
- package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js.map +1 -1
- package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js.map +1 -1
- package/dist/runtime/subsystems/bridge/bridge.module.d.ts +3 -0
- package/dist/runtime/subsystems/bridge/bridge.module.js +930 -275
- package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
- package/dist/runtime/subsystems/bridge/event-flow.service.js.map +1 -1
- package/dist/runtime/subsystems/bridge/index.d.ts +3 -0
- package/dist/runtime/subsystems/bridge/index.js +837 -182
- package/dist/runtime/subsystems/bridge/index.js.map +1 -1
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +3 -1
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +92 -1
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js.map +1 -1
- package/dist/runtime/subsystems/events/event-bus.memory-backend.d.ts +3 -1
- package/dist/runtime/subsystems/events/event-bus.memory-backend.js +99 -0
- package/dist/runtime/subsystems/events/event-bus.memory-backend.js.map +1 -1
- package/dist/runtime/subsystems/events/event-bus.redis-backend.js.map +1 -1
- package/dist/runtime/subsystems/events/event-keyset-cursor.d.ts +32 -0
- package/dist/runtime/subsystems/events/event-keyset-cursor.js +38 -0
- package/dist/runtime/subsystems/events/event-keyset-cursor.js.map +1 -0
- package/dist/runtime/subsystems/events/event-read.protocol.d.ts +94 -0
- package/dist/runtime/subsystems/events/event-read.protocol.js +9 -0
- package/dist/runtime/subsystems/events/event-read.protocol.js.map +1 -0
- package/dist/runtime/subsystems/events/events.module.js +177 -3
- package/dist/runtime/subsystems/events/events.module.js.map +1 -1
- package/dist/runtime/subsystems/events/events.tokens.d.ts +16 -1
- package/dist/runtime/subsystems/events/events.tokens.js +2 -0
- package/dist/runtime/subsystems/events/events.tokens.js.map +1 -1
- package/dist/runtime/subsystems/events/generated/bus.js.map +1 -1
- package/dist/runtime/subsystems/events/generated/index.js.map +1 -1
- package/dist/runtime/subsystems/events/index.d.ts +2 -1
- package/dist/runtime/subsystems/events/index.js +178 -3
- package/dist/runtime/subsystems/events/index.js.map +1 -1
- package/dist/runtime/subsystems/index.d.ts +1 -0
- package/dist/runtime/subsystems/index.js +1194 -264
- package/dist/runtime/subsystems/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/bullmq.config.d.ts +98 -0
- package/dist/runtime/subsystems/jobs/bullmq.config.js +143 -0
- package/dist/runtime/subsystems/jobs/bullmq.config.js.map +1 -0
- package/dist/runtime/subsystems/jobs/index.d.ts +6 -2
- package/dist/runtime/subsystems/jobs/index.js +861 -201
- package/dist/runtime/subsystems/jobs/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.d.ts +107 -0
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +922 -0
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -0
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.d.ts +52 -0
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js +57 -0
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js.map +1 -0
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.d.ts +2 -1
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +81 -1
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.d.ts +2 -1
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +81 -0
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +74 -1
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.d.ts +48 -0
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +374 -0
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js.map +1 -0
- package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +42 -4
- package/dist/runtime/subsystems/jobs/job-worker.module.js +832 -178
- package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
- package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +10 -1
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +519 -20
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
- package/dist/runtime/subsystems/jobs/pool-config.loader.d.ts +9 -1
- package/dist/runtime/subsystems/jobs/pool-config.loader.js +4 -0
- package/dist/runtime/subsystems/jobs/pool-config.loader.js.map +1 -1
- package/dist/runtime/subsystems/observability/index.d.ts +4 -3
- package/dist/runtime/subsystems/observability/index.js +109 -2
- package/dist/runtime/subsystems/observability/index.js.map +1 -1
- package/dist/runtime/subsystems/observability/observability.module.js +109 -2
- package/dist/runtime/subsystems/observability/observability.module.js.map +1 -1
- package/dist/runtime/subsystems/observability/observability.protocol.d.ts +63 -2
- package/dist/runtime/subsystems/observability/observability.service.d.ts +21 -3
- package/dist/runtime/subsystems/observability/observability.service.js +109 -2
- package/dist/runtime/subsystems/observability/observability.service.js.map +1 -1
- package/dist/runtime/subsystems/observability/reporters/bridge-metrics.reporter.d.ts +1 -0
- package/dist/runtime/subsystems/observability/reporters/index.d.ts +1 -0
- package/dist/src/cli/index.js +30 -6
- package/dist/src/cli/index.js.map +1 -1
- package/package.json +1 -1
- package/runtime/subsystems/bridge/bridge.module.ts +5 -0
- package/runtime/subsystems/events/event-bus.drizzle-backend.ts +109 -3
- package/runtime/subsystems/events/event-bus.memory-backend.ts +103 -1
- package/runtime/subsystems/events/event-keyset-cursor.ts +59 -0
- package/runtime/subsystems/events/event-read.protocol.ts +97 -0
- package/runtime/subsystems/events/events.module.ts +18 -2
- package/runtime/subsystems/events/events.tokens.ts +16 -0
- package/runtime/subsystems/events/index.ts +7 -0
- package/runtime/subsystems/jobs/bullmq.config.ts +125 -0
- package/runtime/subsystems/jobs/index.ts +22 -0
- package/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.ts +381 -0
- package/runtime/subsystems/jobs/job-run-keyset-cursor.ts +88 -0
- package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +59 -1
- package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +53 -0
- package/runtime/subsystems/jobs/job-run-service.protocol.ts +77 -0
- package/runtime/subsystems/jobs/job-worker.bullmq-backend.ts +311 -0
- package/runtime/subsystems/jobs/job-worker.module.ts +124 -10
- package/runtime/subsystems/jobs/jobs-domain.module.ts +40 -21
- package/runtime/subsystems/jobs/pool-config.loader.ts +11 -0
- package/runtime/subsystems/observability/index.ts +8 -0
- package/runtime/subsystems/observability/observability.protocol.ts +76 -0
- package/runtime/subsystems/observability/observability.service.ts +148 -1
- package/templates/entity/new/clean-lite-ps/prompt-extension.js +14 -12
- package/templates/relationship/new/prompt.js +8 -5
- package/templates/subsystem/jobs/worker.ejs.t +30 -7
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
500
|
-
if (
|
|
501
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
if (
|
|
209
|
-
if (
|
|
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)
|
|
219
|
-
if (!required)
|
|
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
|
|
9
|
-
*
|
|
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
|
-
|
|
35
|
-
|
|
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 —
|
|
22
|
+
* ## `tenant_id` columns — always emitted
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|