@pattern-stack/codegen 0.9.2 → 0.10.1
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 +73 -0
- package/README.md +5 -0
- package/consumer-skills/bridge/SKILL.md +265 -0
- package/consumer-skills/codegen/SKILL.md +115 -0
- package/consumer-skills/entities/SKILL.md +111 -0
- package/consumer-skills/entities/families-and-queries.md +82 -0
- package/consumer-skills/entities/yaml-reference.md +118 -0
- package/consumer-skills/events/SKILL.md +71 -0
- package/consumer-skills/events/authoring-events.md +164 -0
- package/consumer-skills/events/typed-bus-and-outbox.md +163 -0
- package/consumer-skills/jobs/SKILL.md +66 -0
- package/consumer-skills/jobs/handler-authoring.md +236 -0
- package/consumer-skills/jobs/pools-and-ordering.md +161 -0
- package/consumer-skills/subsystems/SKILL.md +161 -0
- package/consumer-skills/subsystems/wiring-and-order.md +120 -0
- package/consumer-skills/sync/SKILL.md +134 -0
- package/consumer-skills/sync/audit-and-detection.md +302 -0
- package/consumer-skills/sync/change-sources-and-sinks.md +442 -0
- package/dist/runtime/subsystems/bridge/bridge.module.d.ts +0 -1
- package/dist/runtime/subsystems/bridge/bridge.module.js +294 -710
- package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
- package/dist/runtime/subsystems/bridge/index.d.ts +0 -1
- package/dist/runtime/subsystems/bridge/index.js +248 -664
- package/dist/runtime/subsystems/bridge/index.js.map +1 -1
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +18 -10
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js.map +1 -1
- package/dist/runtime/subsystems/events/events.module.js +43 -244
- package/dist/runtime/subsystems/events/events.module.js.map +1 -1
- package/dist/runtime/subsystems/events/index.d.ts +0 -1
- package/dist/runtime/subsystems/events/index.js +39 -241
- package/dist/runtime/subsystems/events/index.js.map +1 -1
- package/dist/runtime/subsystems/index.js +174 -791
- package/dist/runtime/subsystems/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/bullmq.config.d.ts +22 -3
- package/dist/runtime/subsystems/jobs/bullmq.config.js.map +1 -1
- package/dist/runtime/subsystems/jobs/index.d.ts +1 -4
- package/dist/runtime/subsystems/jobs/index.js +87 -506
- package/dist/runtime/subsystems/jobs/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +3 -0
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +11 -4
- package/dist/runtime/subsystems/jobs/job-worker.module.js +248 -664
- package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
- package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +0 -1
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +89 -391
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
- package/dist/src/cli/index.js +1065 -440
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.js +26 -4
- package/dist/src/index.js.map +1 -1
- package/package.json +2 -1
- package/runtime/subsystems/events/event-bus.drizzle-backend.ts +32 -10
- package/runtime/subsystems/events/events.module.ts +38 -6
- package/runtime/subsystems/events/index.ts +7 -1
- package/runtime/subsystems/jobs/bullmq.config.ts +23 -3
- package/runtime/subsystems/jobs/index.ts +13 -8
- package/runtime/subsystems/jobs/job-worker.bullmq-backend.ts +5 -2
- package/runtime/subsystems/jobs/job-worker.module.ts +27 -7
- package/runtime/subsystems/jobs/jobs-domain.module.ts +27 -2
- package/templates/subsystem/events/domain-events.schema.ejs.t +43 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pattern-stack/codegen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Entity-driven code generation for full-stack TypeScript applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"dist",
|
|
41
41
|
"runtime",
|
|
42
42
|
"templates",
|
|
43
|
+
"consumer-skills",
|
|
43
44
|
"examples/auth-integrations/**",
|
|
44
45
|
"src/config/*.mjs",
|
|
45
46
|
"src/schema/naming-config.schema.mjs",
|
|
@@ -59,17 +59,16 @@ const POLL_BATCH_SIZE = 50;
|
|
|
59
59
|
* the per-event extraction logic in one place so publish/publishMany stay
|
|
60
60
|
* in sync.
|
|
61
61
|
*/
|
|
62
|
-
function toInsertValues(event: DomainEvent) {
|
|
62
|
+
function toInsertValues(event: DomainEvent, multiTenant: boolean) {
|
|
63
63
|
const metadata = event.metadata ?? undefined;
|
|
64
64
|
const pool = (metadata?.['pool'] as string | undefined) ?? null;
|
|
65
65
|
const direction = (metadata?.['direction'] as string | undefined) ?? null;
|
|
66
|
-
const tenantId = (metadata?.['tenantId'] as string | undefined) ?? null;
|
|
67
66
|
// AUDIT-1: tier defaults to 'domain' when absent. The DB CHECK
|
|
68
67
|
// constraint (`domain_events_tier_routing_check`) enforces the
|
|
69
68
|
// tier ⇔ routing-fields invariant at the storage boundary; no
|
|
70
69
|
// JS-side assertion is needed here.
|
|
71
70
|
const tier = (metadata?.['tier'] as string | undefined) ?? 'domain';
|
|
72
|
-
|
|
71
|
+
const base = {
|
|
73
72
|
id: event.id,
|
|
74
73
|
type: event.type,
|
|
75
74
|
aggregateId: event.aggregateId,
|
|
@@ -82,8 +81,14 @@ function toInsertValues(event: DomainEvent) {
|
|
|
82
81
|
pool,
|
|
83
82
|
direction,
|
|
84
83
|
tier,
|
|
85
|
-
tenantId,
|
|
86
84
|
};
|
|
85
|
+
// EVT-8: `tenant_id` is a scaffold-time conditional column, emitted only
|
|
86
|
+
// when `events.multi_tenant: true`. Only write it when multi-tenancy is
|
|
87
|
+
// on — under single-tenant scaffolds the column does not exist, so the
|
|
88
|
+
// key must be omitted from the insert.
|
|
89
|
+
if (!multiTenant) return base;
|
|
90
|
+
const tenantId = (metadata?.['tenantId'] as string | undefined) ?? null;
|
|
91
|
+
return { ...base, tenantId };
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
/**
|
|
@@ -107,7 +112,11 @@ function toEventSummary(r: DomainEventRecord) {
|
|
|
107
112
|
direction: r.direction,
|
|
108
113
|
tier: r.tier,
|
|
109
114
|
rootRunId: typeof rootRunId === 'string' ? rootRunId : null,
|
|
110
|
-
|
|
115
|
+
// EVT-8: `tenant_id` is a scaffold-time conditional column. Read it
|
|
116
|
+
// structurally so this projection typechecks against both the
|
|
117
|
+
// multi-tenant schema (column present) and the single-tenant schema
|
|
118
|
+
// (column absent → undefined → null).
|
|
119
|
+
tenantId: (r as { tenantId?: string | null }).tenantId ?? null,
|
|
111
120
|
occurredAt:
|
|
112
121
|
r.occurredAt instanceof Date
|
|
113
122
|
? r.occurredAt
|
|
@@ -175,13 +184,17 @@ export class DrizzleEventBus implements IEventBus, IEventReadPort, OnModuleInit,
|
|
|
175
184
|
|
|
176
185
|
async publish(event: DomainEvent, tx?: DrizzleTransaction): Promise<void> {
|
|
177
186
|
const client = (tx ?? this.db) as DrizzleClient;
|
|
178
|
-
|
|
187
|
+
const multiTenant = this.opts.multiTenant ?? false;
|
|
188
|
+
await client.insert(domainEvents).values(toInsertValues(event, multiTenant));
|
|
179
189
|
}
|
|
180
190
|
|
|
181
191
|
async publishMany(events: DomainEvent[], tx?: DrizzleTransaction): Promise<void> {
|
|
182
192
|
if (events.length === 0) return;
|
|
183
193
|
const client = (tx ?? this.db) as DrizzleClient;
|
|
184
|
-
|
|
194
|
+
const multiTenant = this.opts.multiTenant ?? false;
|
|
195
|
+
await client
|
|
196
|
+
.insert(domainEvents)
|
|
197
|
+
.values(events.map((e) => toInsertValues(e, multiTenant)));
|
|
185
198
|
}
|
|
186
199
|
|
|
187
200
|
async findById(eventId: string): Promise<DomainEvent | null> {
|
|
@@ -241,11 +254,20 @@ export class DrizzleEventBus implements IEventBus, IEventReadPort, OnModuleInit,
|
|
|
241
254
|
sql`${domainEvents.metadata}->>'rootRunId' = ${query.rootRunId}`,
|
|
242
255
|
);
|
|
243
256
|
}
|
|
244
|
-
|
|
257
|
+
// EVT-8: `tenant_id` is a scaffold-time conditional column (emitted only
|
|
258
|
+
// under `events.multi_tenant: true`). Guard the filter behind the same
|
|
259
|
+
// `multiTenant` flag, and read the column structurally so this backend
|
|
260
|
+
// typechecks against both the multi-tenant schema (column present) and
|
|
261
|
+
// the single-tenant schema (column absent). When multi-tenancy is off
|
|
262
|
+
// there is no `tenant_id` column to filter on.
|
|
263
|
+
if (this.opts.multiTenant && query.tenantId !== undefined) {
|
|
264
|
+
const tenantIdColumn = (
|
|
265
|
+
domainEvents as unknown as { tenantId: typeof domainEvents.pool }
|
|
266
|
+
).tenantId;
|
|
245
267
|
conditions.push(
|
|
246
268
|
query.tenantId === null
|
|
247
|
-
? (sql`${
|
|
248
|
-
: eq(
|
|
269
|
+
? (sql`${tenantIdColumn} is null` as SQL<unknown>)
|
|
270
|
+
: eq(tenantIdColumn, query.tenantId),
|
|
249
271
|
);
|
|
250
272
|
}
|
|
251
273
|
|
|
@@ -57,9 +57,27 @@ import { DRIZZLE } from '../../constants/tokens';
|
|
|
57
57
|
import type { DrizzleClient } from '../../types/drizzle';
|
|
58
58
|
import { DrizzleEventBus } from './event-bus.drizzle-backend';
|
|
59
59
|
import { MemoryEventBus } from './event-bus.memory-backend';
|
|
60
|
-
|
|
60
|
+
// #6 — `RedisEventBus` is lazy-loaded only when `backend: 'redis'` is selected.
|
|
61
|
+
// The file is filtered out of the vendor set for non-redis installs (see
|
|
62
|
+
// `backendFileFilter` in src/cli/commands/subsystem.ts); the dynamic-string
|
|
63
|
+
// import below makes TS treat the specifier as `any` so the consumer's tsc
|
|
64
|
+
// never tries to resolve the absent file.
|
|
61
65
|
import { TypedEventBus } from './generated/bus';
|
|
62
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Lazy-load the Redis backend. Routed through a non-literal specifier so
|
|
69
|
+
* the consumer's `tsc` doesn't resolve `./event-bus.redis-backend` at type
|
|
70
|
+
* check time — important because that file is filtered out of drizzle/
|
|
71
|
+
* memory installs (#6).
|
|
72
|
+
*/
|
|
73
|
+
async function loadRedisEventBus(): Promise<new (url: string) => object> {
|
|
74
|
+
// Non-literal specifier — TS gives this an `any` module type, sidestepping
|
|
75
|
+
// resolution of a file that may not be vendored.
|
|
76
|
+
const specifier = './event-bus.redis-backend';
|
|
77
|
+
const mod = (await import(specifier)) as { RedisEventBus: new (url: string) => object };
|
|
78
|
+
return mod.RedisEventBus;
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
export interface EventsModuleOptions {
|
|
64
82
|
backend: 'drizzle' | 'memory' | 'redis';
|
|
65
83
|
/**
|
|
@@ -124,11 +142,11 @@ function buildTypedBusProviders(multiTenant: boolean): Provider[] {
|
|
|
124
142
|
* drizzle backend is selected but no DRIZZLE provider is registered, we
|
|
125
143
|
* throw a clear error instead of silently constructing a broken bus.
|
|
126
144
|
*/
|
|
127
|
-
function buildEventBusAsync(
|
|
145
|
+
async function buildEventBusAsync(
|
|
128
146
|
options: EventsModuleOptions,
|
|
129
147
|
db: DrizzleClient | null,
|
|
130
148
|
redisUrl: string,
|
|
131
|
-
): unknown {
|
|
149
|
+
): Promise<unknown> {
|
|
132
150
|
if (options.backend === 'drizzle') {
|
|
133
151
|
if (!db) {
|
|
134
152
|
throw new Error(
|
|
@@ -139,6 +157,9 @@ function buildEventBusAsync(
|
|
|
139
157
|
return new DrizzleEventBus(db, options);
|
|
140
158
|
}
|
|
141
159
|
if (options.backend === 'redis') {
|
|
160
|
+
// #6: lazy import — the redis backend ships only with `--backend redis`
|
|
161
|
+
// installs; drizzle/memory consumers never touch the file.
|
|
162
|
+
const RedisEventBus = await loadRedisEventBus();
|
|
142
163
|
return new RedisEventBus(redisUrl);
|
|
143
164
|
}
|
|
144
165
|
return new MemoryEventBus(options);
|
|
@@ -214,9 +235,20 @@ export class EventsModule {
|
|
|
214
235
|
providers: [
|
|
215
236
|
{ provide: EVENTS_MODULE_OPTIONS, useValue: options },
|
|
216
237
|
{ provide: REDIS_URL, useValue: resolvedUrl },
|
|
217
|
-
{
|
|
218
|
-
|
|
219
|
-
|
|
238
|
+
{
|
|
239
|
+
// #6: useFactory + dynamic import so the consumer's tsc never
|
|
240
|
+
// needs to resolve `event-bus.redis-backend.ts` for drizzle/
|
|
241
|
+
// memory installs (the file is filtered out by
|
|
242
|
+
// `backendFileFilter`). Nest awaits async factories + manages
|
|
243
|
+
// lifecycle on the returned instance, so we drop the old bare
|
|
244
|
+
// `RedisEventBus` provider entry.
|
|
245
|
+
provide: EVENT_BUS,
|
|
246
|
+
useFactory: async (url: string): Promise<object> => {
|
|
247
|
+
const RedisEventBus = await loadRedisEventBus();
|
|
248
|
+
return new RedisEventBus(url);
|
|
249
|
+
},
|
|
250
|
+
inject: [REDIS_URL],
|
|
251
|
+
},
|
|
220
252
|
...buildTypedBusProviders(multiTenant),
|
|
221
253
|
],
|
|
222
254
|
exports: [EVENT_BUS, TYPED_EVENT_BUS, EVENTS_MULTI_TENANT],
|
|
@@ -23,6 +23,12 @@ export { EventsModule } from './events.module';
|
|
|
23
23
|
export type { EventsModuleOptions } from './events.module';
|
|
24
24
|
export { MemoryEventBus } from './event-bus.memory-backend';
|
|
25
25
|
export { DrizzleEventBus } from './event-bus.drizzle-backend';
|
|
26
|
-
|
|
26
|
+
// #6 — backend-specific implementation classes are NOT re-exported here.
|
|
27
|
+
// `RedisEventBus` is only vendored when the consumer installs with
|
|
28
|
+
// `--backend redis`; surfacing it from this barrel would force the consumer's
|
|
29
|
+
// tsc to resolve `./event-bus.redis-backend` even on a drizzle/memory install
|
|
30
|
+
// (the file is filtered out → TS2307). Consumers who select redis import the
|
|
31
|
+
// class directly from `./event-bus.redis-backend` if they need it at all —
|
|
32
|
+
// `EventsModule.forRoot({ backend: 'redis' })` lazy-loads it internally.
|
|
27
33
|
export { domainEvents } from './domain-events.schema';
|
|
28
34
|
export type { DomainEventRecord } from './domain-events.schema';
|
|
@@ -6,9 +6,29 @@
|
|
|
6
6
|
* `jobs.extensions.bullmq.*` config namespace (CLAUDE.md core/extension
|
|
7
7
|
* protocol). The Drizzle backend never reads any of it.
|
|
8
8
|
*/
|
|
9
|
-
import type { ConnectionOptions } from 'bullmq';
|
|
10
9
|
import { loadPoolConfig, type PoolConfig } from './pool-config.loader';
|
|
11
10
|
|
|
11
|
+
/**
|
|
12
|
+
* #6 — Structural mirror of BullMQ's `ConnectionOptions`. Declared locally
|
|
13
|
+
* so this config file (which ships into EVERY jobs install, drizzle or
|
|
14
|
+
* bullmq) does NOT need the `bullmq` peer dep resolved by the consumer's
|
|
15
|
+
* tsc. The bullmq backend internally casts to the real `ConnectionOptions`
|
|
16
|
+
* — that file is only vendored when `--backend bullmq` is selected
|
|
17
|
+
* (see `backendFileFilter`).
|
|
18
|
+
*
|
|
19
|
+
* Accepts the `{ url }` shape this resolver emits, plus the host/port/
|
|
20
|
+
* password/db form BullMQ also accepts, with an open index for any extra
|
|
21
|
+
* ioredis options consumers may flow through.
|
|
22
|
+
*/
|
|
23
|
+
export type BullMqConnectionOptions = {
|
|
24
|
+
url?: string;
|
|
25
|
+
host?: string;
|
|
26
|
+
port?: number;
|
|
27
|
+
password?: string;
|
|
28
|
+
db?: number;
|
|
29
|
+
[key: string]: unknown;
|
|
30
|
+
};
|
|
31
|
+
|
|
12
32
|
/**
|
|
13
33
|
* Typed shape of `codegen.config.yaml: jobs.extensions.bullmq`. Snake_case
|
|
14
34
|
* because it mirrors the YAML the consumer authors.
|
|
@@ -53,7 +73,7 @@ export interface BullMqExtensionsConfig {
|
|
|
53
73
|
* orchestrator + worker actually consume.
|
|
54
74
|
*/
|
|
55
75
|
export interface BullMqResolvedConfig {
|
|
56
|
-
connection:
|
|
76
|
+
connection: BullMqConnectionOptions;
|
|
57
77
|
queuePrefix?: string;
|
|
58
78
|
bullBoard?: { enabled: boolean; mountPath: string };
|
|
59
79
|
}
|
|
@@ -85,7 +105,7 @@ export function resolveBullMqConfig(
|
|
|
85
105
|
ext?.redis_url ?? process.env.REDIS_URL ?? DEFAULT_REDIS_URL;
|
|
86
106
|
|
|
87
107
|
const resolved: BullMqResolvedConfig = {
|
|
88
|
-
connection: { url }
|
|
108
|
+
connection: { url },
|
|
89
109
|
queuePrefix: ext?.queue_prefix,
|
|
90
110
|
};
|
|
91
111
|
if (ext?.bull_board?.enabled) {
|
|
@@ -81,19 +81,24 @@ export { DrizzleJobRunService } from './job-run-service.drizzle-backend';
|
|
|
81
81
|
export { DrizzleJobStepService } from './job-step-service.drizzle-backend';
|
|
82
82
|
|
|
83
83
|
// ─── BULLMQ-1: BullMQ backend (additive; opt-in via jobs.backend: bullmq) ──
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
84
|
+
// #6 — backend-specific implementation classes are NOT re-exported from this
|
|
85
|
+
// public barrel. `BullMQJobOrchestrator` + `BullMQJobWorker` are only vendored
|
|
86
|
+
// when the consumer installs with `--backend bullmq`; surfacing them here
|
|
87
|
+
// would force every consumer's tsc to resolve those files even on a drizzle
|
|
88
|
+
// install (filtered out → TS2307). Consumers who select bullmq import them
|
|
89
|
+
// directly from their backend file; `JobsDomainModule.forRoot({ backend:
|
|
90
|
+
// 'bullmq' })` + `JobWorkerModule` lazy-load them internally.
|
|
91
|
+
//
|
|
92
|
+
// `bullmq.config.ts` (tokens + helpers) IS still re-exported — it always
|
|
93
|
+
// ships (its only type surface is a local `BullMqConnectionOptions`, no
|
|
94
|
+
// `bullmq` peer-dep resolution required). The module files static-import
|
|
95
|
+
// from it.
|
|
92
96
|
export {
|
|
93
97
|
BULLMQ_CONNECTION,
|
|
94
98
|
BULLMQ_RESOLVED_CONFIG,
|
|
95
99
|
resolveBullMqConfig,
|
|
96
100
|
resolvePoolQueueName,
|
|
101
|
+
type BullMqConnectionOptions,
|
|
97
102
|
type BullMqExtensionsConfig,
|
|
98
103
|
type BullMqResolvedConfig,
|
|
99
104
|
} from './bullmq.config';
|
|
@@ -94,13 +94,16 @@ export class BullMQJobWorker {
|
|
|
94
94
|
}
|
|
95
95
|
this.worker = new WorkerCtor(
|
|
96
96
|
this.options.queueName,
|
|
97
|
-
|
|
97
|
+
// #6 / noImplicitAny — explicit annotations so the file stays
|
|
98
|
+
// strict-clean even when consumers compile under a stricter tsconfig
|
|
99
|
+
// than the one used to type-check the runtime tree.
|
|
100
|
+
(job: Job<BullJobPayload>) => this.process(job),
|
|
98
101
|
{
|
|
99
102
|
connection: this.options.connection,
|
|
100
103
|
concurrency: this.options.concurrency,
|
|
101
104
|
},
|
|
102
105
|
);
|
|
103
|
-
this.worker.on('failed', (job, err) => {
|
|
106
|
+
this.worker.on('failed', (job: Job<BullJobPayload> | undefined, err: Error) => {
|
|
104
107
|
// BullMQ fires `failed` after EACH attempt; only mirror to job_run when
|
|
105
108
|
// attempts are exhausted (BullMQ will not retry further).
|
|
106
109
|
if (!job) return;
|
|
@@ -52,12 +52,17 @@ import {
|
|
|
52
52
|
type PoolConfig,
|
|
53
53
|
} from './pool-config.loader';
|
|
54
54
|
import { JobWorker, type JobWorkerOptions } from './job-worker';
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// #6 — `BullMQJobWorker` is lazy-loaded only when `backend: 'bullmq'` is
|
|
56
|
+
// selected (`spawnBullMQWorker` below). The file is filtered out of drizzle/
|
|
57
|
+
// memory installs (see `backendFileFilter`). The `ConnectionOptions` type
|
|
58
|
+
// previously imported from `'bullmq'` is replaced by `BullMqConnectionOptions`
|
|
59
|
+
// from `./bullmq.config` (a self-contained structural mirror that does NOT
|
|
60
|
+
// require the `bullmq` peer dep to type-check).
|
|
57
61
|
import {
|
|
58
62
|
BULLMQ_CONNECTION,
|
|
59
63
|
BULLMQ_RESOLVED_CONFIG,
|
|
60
64
|
resolvePoolQueueName,
|
|
65
|
+
type BullMqConnectionOptions,
|
|
61
66
|
type BullMqResolvedConfig,
|
|
62
67
|
} from './bullmq.config';
|
|
63
68
|
import {
|
|
@@ -157,7 +162,7 @@ export class JobWorkerOrchestrator implements OnModuleInit, OnModuleDestroy {
|
|
|
157
162
|
*/
|
|
158
163
|
@Optional()
|
|
159
164
|
@Inject(BULLMQ_CONNECTION)
|
|
160
|
-
private readonly bullConnection:
|
|
165
|
+
private readonly bullConnection: BullMqConnectionOptions | null = null,
|
|
161
166
|
@Optional()
|
|
162
167
|
@Inject(BULLMQ_RESOLVED_CONFIG)
|
|
163
168
|
private readonly bullConfig: BullMqResolvedConfig | null = null,
|
|
@@ -233,7 +238,7 @@ export class JobWorkerOrchestrator implements OnModuleInit, OnModuleDestroy {
|
|
|
233
238
|
const worker = this.options.workerFactory
|
|
234
239
|
? this.options.workerFactory(workerOptions)
|
|
235
240
|
: backend === 'bullmq'
|
|
236
|
-
? this.spawnBullMQWorker(poolName, def.queue, def.concurrency, poolConfig)
|
|
241
|
+
? await this.spawnBullMQWorker(poolName, def.queue, def.concurrency, poolConfig)
|
|
237
242
|
: this.spawnWorker(workerOptions);
|
|
238
243
|
// `JobWorker` extends Nest's lifecycle hooks but the worker isn't
|
|
239
244
|
// a Nest provider here (we manage the array ourselves). Call
|
|
@@ -364,12 +369,20 @@ export class JobWorkerOrchestrator implements OnModuleInit, OnModuleDestroy {
|
|
|
364
369
|
* orchestrator's `dispatch` via `resolvePoolQueueName(pool, …)` so producer
|
|
365
370
|
* and consumer agree.
|
|
366
371
|
*/
|
|
367
|
-
|
|
372
|
+
/**
|
|
373
|
+
* #6 — async + dynamic-import. The `job-worker.bullmq-backend.ts` file is
|
|
374
|
+
* filtered out of the vendor set for drizzle/memory installs (no `bullmq`
|
|
375
|
+
* peer dep needed). The non-literal import specifier makes TS treat the
|
|
376
|
+
* module as `any` so the consumer's tsc never tries to resolve an absent
|
|
377
|
+
* file. This method is only entered when `backend === 'bullmq'` — at which
|
|
378
|
+
* point the file IS vendored.
|
|
379
|
+
*/
|
|
380
|
+
private async spawnBullMQWorker(
|
|
368
381
|
pool: string,
|
|
369
382
|
_queueAlias: string,
|
|
370
383
|
concurrency: number,
|
|
371
384
|
poolConfig: PoolConfig,
|
|
372
|
-
):
|
|
385
|
+
): Promise<Pick<JobWorker, 'onModuleInit' | 'onModuleDestroy'>> {
|
|
373
386
|
if (!this.db) {
|
|
374
387
|
throw new Error(
|
|
375
388
|
`JobWorkerModule: BullMQ worker spawning requires the Drizzle client ` +
|
|
@@ -390,7 +403,14 @@ export class JobWorkerOrchestrator implements OnModuleInit, OnModuleDestroy {
|
|
|
390
403
|
);
|
|
391
404
|
}
|
|
392
405
|
const queueName = resolvePoolQueueName(pool, this.bullConfig, poolConfig);
|
|
393
|
-
|
|
406
|
+
const specifier = './job-worker.bullmq-backend';
|
|
407
|
+
const mod = (await import(specifier)) as {
|
|
408
|
+
BullMQJobWorker: new (...args: unknown[]) => Pick<
|
|
409
|
+
JobWorker,
|
|
410
|
+
'onModuleInit' | 'onModuleDestroy'
|
|
411
|
+
>;
|
|
412
|
+
};
|
|
413
|
+
return new mod.BullMQJobWorker(
|
|
394
414
|
this.db,
|
|
395
415
|
this.orchestrator,
|
|
396
416
|
this.stepService,
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* and the type system reserves the slot.
|
|
16
16
|
*/
|
|
17
17
|
import { Module, type DynamicModule, type Provider } from '@nestjs/common';
|
|
18
|
+
import { DRIZZLE } from '../../constants/tokens';
|
|
18
19
|
import {
|
|
19
20
|
JOB_ORCHESTRATOR,
|
|
20
21
|
JOB_RUN_SERVICE,
|
|
@@ -24,7 +25,10 @@ import {
|
|
|
24
25
|
import { DrizzleJobOrchestrator } from './job-orchestrator.drizzle-backend';
|
|
25
26
|
import { DrizzleJobRunService } from './job-run-service.drizzle-backend';
|
|
26
27
|
import { DrizzleJobStepService } from './job-step-service.drizzle-backend';
|
|
27
|
-
|
|
28
|
+
// #6 — `BullMQJobOrchestrator` is lazy-loaded only when `backend: 'bullmq'`
|
|
29
|
+
// is selected. The backend file is filtered out of drizzle/memory installs
|
|
30
|
+
// (see `backendFileFilter`); a non-literal dynamic import below sidesteps
|
|
31
|
+
// consumer-side tsc resolution of an absent file.
|
|
28
32
|
import { MemoryJobOrchestrator } from './job-orchestrator.memory-backend';
|
|
29
33
|
import { MemoryJobRunService } from './job-run-service.memory-backend';
|
|
30
34
|
import { MemoryJobStepService } from './job-step-service.memory-backend';
|
|
@@ -102,10 +106,31 @@ export class JobsDomainModule {
|
|
|
102
106
|
// run/step services stay Drizzle (domain reads + `listForScope` are
|
|
103
107
|
// Postgres queries, unchanged per spec). Only the orchestrator's
|
|
104
108
|
// claim/dispatch half swaps to BullMQ.
|
|
109
|
+
//
|
|
110
|
+
// #6 — the bullmq backend module is filtered out of drizzle/memory
|
|
111
|
+
// installs (no `bullmq` peer dep, no consumer-side tsc compile of an
|
|
112
|
+
// unused file). The factory below dynamic-imports it via a non-literal
|
|
113
|
+
// specifier so TS treats the module type as `any` and never tries to
|
|
114
|
+
// resolve the absent file on a drizzle/memory consumer.
|
|
105
115
|
const resolved = resolveBullMqConfig(opts.extensions?.bullmq);
|
|
106
116
|
providers.push({ provide: BULLMQ_CONNECTION, useValue: resolved.connection });
|
|
107
117
|
providers.push({ provide: BULLMQ_RESOLVED_CONFIG, useValue: resolved });
|
|
108
|
-
providers.push({
|
|
118
|
+
providers.push({
|
|
119
|
+
provide: JOB_ORCHESTRATOR,
|
|
120
|
+
useFactory: async (...args: unknown[]): Promise<object> => {
|
|
121
|
+
const specifier = './job-orchestrator.bullmq-backend';
|
|
122
|
+
const mod = (await import(specifier)) as {
|
|
123
|
+
BullMQJobOrchestrator: new (...args: unknown[]) => object;
|
|
124
|
+
};
|
|
125
|
+
return new mod.BullMQJobOrchestrator(...args);
|
|
126
|
+
},
|
|
127
|
+
// The bullmq orchestrator constructor mirrors DrizzleJobOrchestrator's
|
|
128
|
+
// injection list: DRIZZLE + JOBS_MULTI_TENANT + the resolved BullMQ
|
|
129
|
+
// tokens. Importing token references would force a static dep on the
|
|
130
|
+
// tokens file in this module's import graph; using the existing
|
|
131
|
+
// symbols already in scope is sufficient.
|
|
132
|
+
inject: [DRIZZLE, JOBS_MULTI_TENANT, BULLMQ_CONNECTION, BULLMQ_RESOLVED_CONFIG],
|
|
133
|
+
});
|
|
109
134
|
providers.push({ provide: JOB_RUN_SERVICE, useClass: DrizzleJobRunService });
|
|
110
135
|
providers.push({ provide: JOB_STEP_SERVICE, useClass: DrizzleJobStepService });
|
|
111
136
|
} else {
|
|
@@ -13,12 +13,26 @@ force: true
|
|
|
13
13
|
* First-class routing columns (EVT-1):
|
|
14
14
|
* - `pool` — populated by DrizzleEventBus.publish() (EVT-4); enables
|
|
15
15
|
* pool-filtered drain queries without unpacking metadata JSON.
|
|
16
|
+
* NULL when `tier='audit'` (audit events are not routed).
|
|
16
17
|
* - `direction` — `inbound` | `change` | `outbound`; mirrors the routing
|
|
17
18
|
* dimension used by jobs' reserved `events_inbound` /
|
|
18
19
|
* `events_change` / `events_outbound` pools.
|
|
20
|
+
* NULL when `tier='audit'`.
|
|
19
21
|
* - `tenant_id` — scaffold-time conditional: emitted only when
|
|
20
22
|
* `events.multi_tenant: true` in `codegen.config.yaml`.
|
|
21
23
|
* See EVT-8 and the JOB-6 precedent for the same pattern.
|
|
24
|
+
* The DrizzleEventBus reads/writes this column behind the
|
|
25
|
+
* same `multiTenant` flag, so the backend typechecks
|
|
26
|
+
* against both the multi-tenant and single-tenant schema.
|
|
27
|
+
*
|
|
28
|
+
* Audit-tier column (AUDIT-1):
|
|
29
|
+
* - `tier` — `'domain'` | `'audit'`. Defaults to `'domain'`. Always
|
|
30
|
+
* emitted (the DrizzleEventBus always writes/reads it).
|
|
31
|
+
* Audit-tier rows are observability-only (subscribers may
|
|
32
|
+
* observe but the bridge MUST NOT spawn jobs from them);
|
|
33
|
+
* they have null `pool` and `direction` by construction.
|
|
34
|
+
* The CHECK constraint `domain_events_tier_routing_check`
|
|
35
|
+
* enforces `tier='audit' ⇔ (pool IS NULL AND direction IS NULL)`.
|
|
22
36
|
*
|
|
23
37
|
* The `metadata` JSON column continues to carry these values for protocol
|
|
24
38
|
* stability; the first-class columns are an optimization for drain filtering.
|
|
@@ -27,8 +41,11 @@ force: true
|
|
|
27
41
|
* - (status, occurred_at) — polling drain filter
|
|
28
42
|
* - (aggregate_id, aggregate_type) — event replay per aggregate
|
|
29
43
|
* - (pool, status, occurred_at) — per-pool drain filter (EVT-1)
|
|
44
|
+
* - (tier, status, occurred_at) — per-tier filter for the observability
|
|
45
|
+
* viewer's tier toggle (AUDIT-1).
|
|
30
46
|
*/
|
|
31
47
|
import {
|
|
48
|
+
check,
|
|
32
49
|
index,
|
|
33
50
|
jsonb,
|
|
34
51
|
pgTable,
|
|
@@ -36,6 +53,7 @@ import {
|
|
|
36
53
|
timestamp,
|
|
37
54
|
uuid,
|
|
38
55
|
} from 'drizzle-orm/pg-core';
|
|
56
|
+
import { sql } from 'drizzle-orm';
|
|
39
57
|
import type { InferSelectModel } from 'drizzle-orm';
|
|
40
58
|
|
|
41
59
|
export const domainEvents = pgTable(
|
|
@@ -53,10 +71,17 @@ export const domainEvents = pgTable(
|
|
|
53
71
|
/** Error message from the last failed dispatch attempt. */
|
|
54
72
|
error: text('error'),
|
|
55
73
|
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
|
|
56
|
-
/** Routing pool (e.g. `events_inbound`, `events_change`, `events_outbound`). Populated by DrizzleEventBus.publish() in EVT-4. */
|
|
74
|
+
/** Routing pool (e.g. `events_inbound`, `events_change`, `events_outbound`). Populated by DrizzleEventBus.publish() in EVT-4. NULL when `tier='audit'`. */
|
|
57
75
|
pool: text('pool'),
|
|
58
|
-
/** Routing direction: `inbound` | `change` | `outbound`. Populated by DrizzleEventBus.publish() in EVT-4. */
|
|
76
|
+
/** Routing direction: `inbound` | `change` | `outbound`. Populated by DrizzleEventBus.publish() in EVT-4. NULL when `tier='audit'`. */
|
|
59
77
|
direction: text('direction'),
|
|
78
|
+
/**
|
|
79
|
+
* Event tier: `'domain'` (default) or `'audit'`. Audit-tier rows are
|
|
80
|
+
* observability-only and have null `pool`/`direction` by construction —
|
|
81
|
+
* enforced by the `domain_events_tier_routing_check` CHECK constraint
|
|
82
|
+
* declared below. (AUDIT-1)
|
|
83
|
+
*/
|
|
84
|
+
tier: text('tier').notNull().default('domain'),
|
|
60
85
|
<% if (multiTenant) { -%>
|
|
61
86
|
tenantId: text('tenant_id'), // scaffold-time conditional — see EVT-8
|
|
62
87
|
<% } -%>
|
|
@@ -76,6 +101,22 @@ export const domainEvents = pgTable(
|
|
|
76
101
|
idxDomainEventsPoolStatusOccurredAt: index(
|
|
77
102
|
'idx_domain_events_pool_status_occurred_at',
|
|
78
103
|
).on(t.pool, t.status, t.occurredAt),
|
|
104
|
+
/** Per-tier filter (AUDIT-1). Backs the observability viewer's tier toggle. */
|
|
105
|
+
idxDomainEventsTierStatusOccurredAt: index(
|
|
106
|
+
'idx_domain_events_tier_status_occurred_at',
|
|
107
|
+
).on(t.tier, t.status, t.occurredAt),
|
|
108
|
+
/**
|
|
109
|
+
* Tier ↔ routing-fields invariant (AUDIT-1):
|
|
110
|
+
* - `tier` is one of `'domain' | 'audit'`.
|
|
111
|
+
* - `tier='audit'` ⇔ `pool IS NULL AND direction IS NULL`.
|
|
112
|
+
* - `tier='domain'` ⇒ `pool` and `direction` are populated (the
|
|
113
|
+
* DrizzleEventBus inserts always supply them; the bus stamps them
|
|
114
|
+
* in AUDIT-3).
|
|
115
|
+
*/
|
|
116
|
+
tierRoutingCheck: check(
|
|
117
|
+
'domain_events_tier_routing_check',
|
|
118
|
+
sql`${t.tier} in ('domain','audit') AND ((${t.tier} = 'audit') = (${t.pool} is null and ${t.direction} is null))`,
|
|
119
|
+
),
|
|
79
120
|
}),
|
|
80
121
|
);
|
|
81
122
|
|