@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +5 -0
  3. package/consumer-skills/bridge/SKILL.md +265 -0
  4. package/consumer-skills/codegen/SKILL.md +115 -0
  5. package/consumer-skills/entities/SKILL.md +111 -0
  6. package/consumer-skills/entities/families-and-queries.md +82 -0
  7. package/consumer-skills/entities/yaml-reference.md +118 -0
  8. package/consumer-skills/events/SKILL.md +71 -0
  9. package/consumer-skills/events/authoring-events.md +164 -0
  10. package/consumer-skills/events/typed-bus-and-outbox.md +163 -0
  11. package/consumer-skills/jobs/SKILL.md +66 -0
  12. package/consumer-skills/jobs/handler-authoring.md +236 -0
  13. package/consumer-skills/jobs/pools-and-ordering.md +161 -0
  14. package/consumer-skills/subsystems/SKILL.md +161 -0
  15. package/consumer-skills/subsystems/wiring-and-order.md +120 -0
  16. package/consumer-skills/sync/SKILL.md +134 -0
  17. package/consumer-skills/sync/audit-and-detection.md +302 -0
  18. package/consumer-skills/sync/change-sources-and-sinks.md +442 -0
  19. package/dist/runtime/subsystems/bridge/bridge.module.d.ts +0 -1
  20. package/dist/runtime/subsystems/bridge/bridge.module.js +294 -710
  21. package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
  22. package/dist/runtime/subsystems/bridge/index.d.ts +0 -1
  23. package/dist/runtime/subsystems/bridge/index.js +248 -664
  24. package/dist/runtime/subsystems/bridge/index.js.map +1 -1
  25. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +18 -10
  26. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js.map +1 -1
  27. package/dist/runtime/subsystems/events/events.module.js +43 -244
  28. package/dist/runtime/subsystems/events/events.module.js.map +1 -1
  29. package/dist/runtime/subsystems/events/index.d.ts +0 -1
  30. package/dist/runtime/subsystems/events/index.js +39 -241
  31. package/dist/runtime/subsystems/events/index.js.map +1 -1
  32. package/dist/runtime/subsystems/index.js +174 -791
  33. package/dist/runtime/subsystems/index.js.map +1 -1
  34. package/dist/runtime/subsystems/jobs/bullmq.config.d.ts +22 -3
  35. package/dist/runtime/subsystems/jobs/bullmq.config.js.map +1 -1
  36. package/dist/runtime/subsystems/jobs/index.d.ts +1 -4
  37. package/dist/runtime/subsystems/jobs/index.js +87 -506
  38. package/dist/runtime/subsystems/jobs/index.js.map +1 -1
  39. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -1
  40. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +3 -0
  41. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js.map +1 -1
  42. package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +11 -4
  43. package/dist/runtime/subsystems/jobs/job-worker.module.js +248 -664
  44. package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
  45. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +0 -1
  46. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +89 -391
  47. package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
  48. package/dist/src/cli/index.js +1065 -440
  49. package/dist/src/cli/index.js.map +1 -1
  50. package/dist/src/index.js +26 -4
  51. package/dist/src/index.js.map +1 -1
  52. package/package.json +2 -1
  53. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +32 -10
  54. package/runtime/subsystems/events/events.module.ts +38 -6
  55. package/runtime/subsystems/events/index.ts +7 -1
  56. package/runtime/subsystems/jobs/bullmq.config.ts +23 -3
  57. package/runtime/subsystems/jobs/index.ts +13 -8
  58. package/runtime/subsystems/jobs/job-worker.bullmq-backend.ts +5 -2
  59. package/runtime/subsystems/jobs/job-worker.module.ts +27 -7
  60. package/runtime/subsystems/jobs/jobs-domain.module.ts +27 -2
  61. 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.9.2",
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
- return {
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
- tenantId: r.tenantId,
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
- await client.insert(domainEvents).values(toInsertValues(event));
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
- await client.insert(domainEvents).values(events.map(toInsertValues));
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
- if (query.tenantId !== undefined) {
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`${domainEvents.tenantId} is null` as SQL<unknown>)
248
- : eq(domainEvents.tenantId, query.tenantId),
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
- import { RedisEventBus } from './event-bus.redis-backend';
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
- { provide: EVENT_BUS, useClass: RedisEventBus },
218
- // Register concrete class so NestJS can resolve lifecycle hooks
219
- RedisEventBus,
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
- export { RedisEventBus } from './event-bus.redis-backend';
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: ConnectionOptions;
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 } as ConnectionOptions,
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
- export {
85
- BullMQJobOrchestrator,
86
- sha1JobId,
87
- } from './job-orchestrator.bullmq-backend';
88
- export {
89
- BullMQJobWorker,
90
- type BullMQJobWorkerOptions,
91
- } from './job-worker.bullmq-backend';
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
- (job) => this.process(job as Job<BullJobPayload>),
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
- import { BullMQJobWorker } from './job-worker.bullmq-backend';
56
- import type { ConnectionOptions } from 'bullmq';
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: ConnectionOptions | null = null,
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
- private spawnBullMQWorker(
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
- ): BullMQJobWorker {
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
- return new BullMQJobWorker(
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
- import { BullMQJobOrchestrator } from './job-orchestrator.bullmq-backend';
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({ provide: JOB_ORCHESTRATOR, useClass: BullMQJobOrchestrator });
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