@pattern-stack/codegen 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/consumer-skills/events/authoring-events.md +31 -0
  3. package/dist/{chunk-COGHTKXY.js → chunk-27ETSJ2X.js} +2 -2
  4. package/dist/{chunk-235ZMMJR.js → chunk-3YCUIGPG.js} +10 -10
  5. package/dist/{chunk-Z7PQCAVK.js → chunk-4OC5MSHO.js} +50 -4
  6. package/dist/chunk-4OC5MSHO.js.map +1 -0
  7. package/dist/{chunk-VNBC3VXM.js → chunk-5LXOJGO2.js} +6 -6
  8. package/dist/{chunk-7OVCARTQ.js → chunk-5RT7JGKT.js} +4 -4
  9. package/dist/{chunk-T6C4LFLC.js → chunk-7YGORYZD.js} +4 -4
  10. package/dist/{chunk-PKDS6QIJ.js → chunk-ATVGYF3D.js} +7 -7
  11. package/dist/{chunk-2TVVBC53.js → chunk-BORNCTH3.js} +2 -2
  12. package/dist/{chunk-E6PLM6QG.js → chunk-CUSMC2KK.js} +13 -13
  13. package/dist/{chunk-V4AF6DI4.js → chunk-DUBZOXJC.js} +9 -2
  14. package/dist/{chunk-V4AF6DI4.js.map → chunk-DUBZOXJC.js.map} +1 -1
  15. package/dist/chunk-DUUCU77W.js +211 -0
  16. package/dist/chunk-DUUCU77W.js.map +1 -0
  17. package/dist/{chunk-OFRRBC7M.js → chunk-E2BRT5IB.js} +15 -1
  18. package/dist/chunk-E2BRT5IB.js.map +1 -0
  19. package/dist/{chunk-XKWOJZZ4.js → chunk-E45CSC33.js} +2 -2
  20. package/dist/{chunk-VQOAATIG.js → chunk-FLYF76CU.js} +4 -4
  21. package/dist/{chunk-43SBT72G.js → chunk-I6UXRJ3Q.js} +4 -4
  22. package/dist/{chunk-GM3RMJIJ.js → chunk-INO47JXD.js} +3 -3
  23. package/dist/{chunk-BGULBWKJ.js → chunk-JOBQ6RUU.js} +1 -1
  24. package/dist/chunk-JOBQ6RUU.js.map +1 -0
  25. package/dist/{chunk-VDL5CJ5C.js → chunk-KBO5OOON.js} +9 -9
  26. package/dist/{chunk-OZEPJGMA.js → chunk-KHQ72A5F.js} +54 -6
  27. package/dist/chunk-KHQ72A5F.js.map +1 -0
  28. package/dist/{chunk-F7KN3U6U.js → chunk-KK5A7B2T.js} +27 -1
  29. package/dist/chunk-KK5A7B2T.js.map +1 -0
  30. package/dist/{chunk-B34G6PHD.js → chunk-LARB26EI.js} +77 -10
  31. package/dist/chunk-LARB26EI.js.map +1 -0
  32. package/dist/{chunk-65MO75WM.js → chunk-LQXBQO72.js} +8 -8
  33. package/dist/{chunk-K2I6XIK5.js → chunk-MVKW2BCR.js} +2 -2
  34. package/dist/{chunk-AZLUWG5S.js → chunk-NYJYK6J4.js} +10 -10
  35. package/dist/{chunk-BHZP6LOV.js → chunk-QSJ3J4HE.js} +7 -7
  36. package/dist/{chunk-R6F6KFIL.js → chunk-SGSWVNNB.js} +7 -7
  37. package/dist/chunk-SYVZ4MD2.js +1 -0
  38. package/dist/{chunk-7LKAMLV4.js → chunk-T6SCOJF4.js} +4 -4
  39. package/dist/{chunk-CLWBNXKF.js → chunk-W2UIDI3R.js} +4 -4
  40. package/dist/{chunk-SNH35CNA.js → chunk-WKNOEVWQ.js} +6 -6
  41. package/dist/runtime/base-classes/index.js +17 -17
  42. package/dist/runtime/subsystems/auth/auth.module.js +2 -2
  43. package/dist/runtime/subsystems/auth/index.js +7 -7
  44. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js +3 -3
  45. package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +4 -4
  46. package/dist/runtime/subsystems/bridge/bridge-delivery.schema.js +2 -2
  47. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +8 -8
  48. package/dist/runtime/subsystems/bridge/bridge.module.js +21 -21
  49. package/dist/runtime/subsystems/bridge/event-flow.service.js +2 -2
  50. package/dist/runtime/subsystems/bridge/index.js +21 -21
  51. package/dist/runtime/subsystems/cache/cache.drizzle-backend.js +2 -2
  52. package/dist/runtime/subsystems/cache/cache.module.js +3 -3
  53. package/dist/runtime/subsystems/cache/index.js +5 -5
  54. package/dist/runtime/subsystems/events/domain-events.schema.js +1 -1
  55. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +19 -32
  56. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +4 -4
  57. package/dist/runtime/subsystems/events/event-bus.memory-backend.d.ts +18 -1
  58. package/dist/runtime/subsystems/events/event-bus.memory-backend.js +2 -2
  59. package/dist/runtime/subsystems/events/event-bus.protocol.d.ts +45 -1
  60. package/dist/runtime/subsystems/events/event-scheduler.d.ts +96 -0
  61. package/dist/runtime/subsystems/events/event-scheduler.js +25 -0
  62. package/dist/runtime/subsystems/events/event-scheduler.js.map +1 -0
  63. package/dist/runtime/subsystems/events/events-errors.d.ts +12 -1
  64. package/dist/runtime/subsystems/events/events-errors.js +5 -3
  65. package/dist/runtime/subsystems/events/events.module.d.ts +41 -2
  66. package/dist/runtime/subsystems/events/events.module.js +12 -9
  67. package/dist/runtime/subsystems/events/generated/bus.js +3 -3
  68. package/dist/runtime/subsystems/events/generated/index.js +3 -3
  69. package/dist/runtime/subsystems/events/generated/registry.d.ts +6 -0
  70. package/dist/runtime/subsystems/events/generated/registry.js +1 -1
  71. package/dist/runtime/subsystems/events/index.d.ts +4 -3
  72. package/dist/runtime/subsystems/events/index.js +39 -15
  73. package/dist/runtime/subsystems/index.d.ts +1 -0
  74. package/dist/runtime/subsystems/index.js +93 -92
  75. package/dist/runtime/subsystems/integration/build-change-source.js +2 -2
  76. package/dist/runtime/subsystems/integration/index.js +36 -36
  77. package/dist/runtime/subsystems/integration/integration.module.js +4 -4
  78. package/dist/runtime/subsystems/jobs/index.js +46 -46
  79. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +8 -8
  80. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.js +4 -4
  81. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +2 -2
  82. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +3 -3
  83. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +3 -3
  84. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +3 -3
  85. package/dist/runtime/subsystems/jobs/job-worker.js +4 -4
  86. package/dist/runtime/subsystems/jobs/job-worker.module.js +13 -13
  87. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +11 -11
  88. package/dist/runtime/subsystems/observability/index.js +3 -3
  89. package/dist/runtime/subsystems/observability/observability.module.js +3 -3
  90. package/dist/runtime/subsystems/observability/observability.service.js +2 -2
  91. package/dist/src/cli/index.js +23 -12
  92. package/dist/src/cli/index.js.map +1 -1
  93. package/dist/src/index.js +12 -12
  94. package/package.json +1 -1
  95. package/runtime/subsystems/events/domain-events.schema.ts +16 -0
  96. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +103 -1
  97. package/runtime/subsystems/events/event-bus.memory-backend.ts +57 -1
  98. package/runtime/subsystems/events/event-bus.protocol.ts +47 -0
  99. package/runtime/subsystems/events/event-scheduler.ts +351 -0
  100. package/runtime/subsystems/events/events-errors.ts +14 -0
  101. package/runtime/subsystems/events/events.module.ts +78 -1
  102. package/runtime/subsystems/events/generated/registry.ts +1 -0
  103. package/runtime/subsystems/events/index.ts +25 -3
  104. package/dist/chunk-B34G6PHD.js.map +0 -1
  105. package/dist/chunk-BGULBWKJ.js.map +0 -1
  106. package/dist/chunk-F7KN3U6U.js.map +0 -1
  107. package/dist/chunk-FN2PYDPP.js +0 -1
  108. package/dist/chunk-OFRRBC7M.js.map +0 -1
  109. package/dist/chunk-OZEPJGMA.js.map +0 -1
  110. package/dist/chunk-Z7PQCAVK.js.map +0 -1
  111. /package/dist/{chunk-COGHTKXY.js.map → chunk-27ETSJ2X.js.map} +0 -0
  112. /package/dist/{chunk-235ZMMJR.js.map → chunk-3YCUIGPG.js.map} +0 -0
  113. /package/dist/{chunk-VNBC3VXM.js.map → chunk-5LXOJGO2.js.map} +0 -0
  114. /package/dist/{chunk-7OVCARTQ.js.map → chunk-5RT7JGKT.js.map} +0 -0
  115. /package/dist/{chunk-T6C4LFLC.js.map → chunk-7YGORYZD.js.map} +0 -0
  116. /package/dist/{chunk-PKDS6QIJ.js.map → chunk-ATVGYF3D.js.map} +0 -0
  117. /package/dist/{chunk-2TVVBC53.js.map → chunk-BORNCTH3.js.map} +0 -0
  118. /package/dist/{chunk-E6PLM6QG.js.map → chunk-CUSMC2KK.js.map} +0 -0
  119. /package/dist/{chunk-XKWOJZZ4.js.map → chunk-E45CSC33.js.map} +0 -0
  120. /package/dist/{chunk-VQOAATIG.js.map → chunk-FLYF76CU.js.map} +0 -0
  121. /package/dist/{chunk-43SBT72G.js.map → chunk-I6UXRJ3Q.js.map} +0 -0
  122. /package/dist/{chunk-GM3RMJIJ.js.map → chunk-INO47JXD.js.map} +0 -0
  123. /package/dist/{chunk-VDL5CJ5C.js.map → chunk-KBO5OOON.js.map} +0 -0
  124. /package/dist/{chunk-65MO75WM.js.map → chunk-LQXBQO72.js.map} +0 -0
  125. /package/dist/{chunk-K2I6XIK5.js.map → chunk-MVKW2BCR.js.map} +0 -0
  126. /package/dist/{chunk-AZLUWG5S.js.map → chunk-NYJYK6J4.js.map} +0 -0
  127. /package/dist/{chunk-BHZP6LOV.js.map → chunk-QSJ3J4HE.js.map} +0 -0
  128. /package/dist/{chunk-R6F6KFIL.js.map → chunk-SGSWVNNB.js.map} +0 -0
  129. /package/dist/{chunk-FN2PYDPP.js.map → chunk-SYVZ4MD2.js.map} +0 -0
  130. /package/dist/{chunk-7LKAMLV4.js.map → chunk-T6SCOJF4.js.map} +0 -0
  131. /package/dist/{chunk-CLWBNXKF.js.map → chunk-W2UIDI3R.js.map} +0 -0
  132. /package/dist/{chunk-SNH35CNA.js.map → chunk-WKNOEVWQ.js.map} +0 -0
package/dist/src/index.js CHANGED
@@ -44,27 +44,27 @@ import {
44
44
  validateOrchestrationProject,
45
45
  validatePatternComposition,
46
46
  validatePatternProject
47
- } from "../chunk-F7KN3U6U.js";
47
+ } from "../chunk-KK5A7B2T.js";
48
48
  import "../chunk-KVOWSC5S.js";
49
- import "../chunk-PKDS6QIJ.js";
50
- import "../chunk-PRWIX6UW.js";
49
+ import "../chunk-ATVGYF3D.js";
51
50
  import "../chunk-YK5JEVLX.js";
52
51
  import "../chunk-EO2QPOKH.js";
53
- import "../chunk-SQDOBLBP.js";
54
- import "../chunk-TDEHU73T.js";
55
- import "../chunk-LG57S2SC.js";
52
+ import "../chunk-PRWIX6UW.js";
56
53
  import "../chunk-XWBK3XJK.js";
57
- import "../chunk-S7C6TIIF.js";
58
- import "../chunk-MZ6GV4YF.js";
59
- import "../chunk-HNWZFNKP.js";
60
54
  import "../chunk-AHV4GDYM.js";
61
- import "../chunk-43SBT72G.js";
62
- import "../chunk-4MF3HKJA.js";
63
- import "../chunk-TIZXQU26.js";
55
+ import "../chunk-SQDOBLBP.js";
64
56
  import "../chunk-JEINYUJH.js";
65
57
  import "../chunk-5TK7MEN4.js";
66
58
  import "../chunk-4KNXX6TI.js";
67
59
  import "../chunk-3CJFPU6Q.js";
60
+ import "../chunk-TDEHU73T.js";
61
+ import "../chunk-S7C6TIIF.js";
62
+ import "../chunk-MZ6GV4YF.js";
63
+ import "../chunk-LG57S2SC.js";
64
+ import "../chunk-HNWZFNKP.js";
65
+ import "../chunk-I6UXRJ3Q.js";
66
+ import "../chunk-TIZXQU26.js";
67
+ import "../chunk-4MF3HKJA.js";
68
68
  import "../chunk-U64T4YZE.js";
69
69
  import "../chunk-2E224ZSN.js";
70
70
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pattern-stack/codegen",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Entity-driven code generation for full-stack TypeScript applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,6 +43,7 @@ import {
43
43
  pgTable,
44
44
  text,
45
45
  timestamp,
46
+ uniqueIndex,
46
47
  uuid,
47
48
  } from 'drizzle-orm/pg-core';
48
49
  import { sql } from 'drizzle-orm';
@@ -96,6 +97,21 @@ export const domainEvents = pgTable(
96
97
  idxDomainEventsTierStatusOccurredAt: index(
97
98
  'idx_domain_events_tier_status_occurred_at',
98
99
  ).on(t.tier, t.status, t.occurredAt),
100
+ /**
101
+ * Scheduling idempotency — partial UNIQUE expression index (ADR-039). The
102
+ * `EventScheduler` materialises one tick per (event type, slot) by inserting
103
+ * with `metadata.scheduleSlot = @schedule/<type>/<slotStartMs>` and
104
+ * `ON CONFLICT DO NOTHING`; this constraint is what makes
105
+ * "exactly one event per slot" true across multi-instance deploys and
106
+ * boot/tick races — no advisory lock, no leader election. Partial on the
107
+ * extracted slot key so it only covers scheduler-materialised rows; ordinary
108
+ * (use-case / webhook) events carry no `scheduleSlot` and are untouched.
109
+ */
110
+ idxDomainEventsScheduleSlot: uniqueIndex(
111
+ 'idx_domain_events_schedule_slot',
112
+ )
113
+ .on(t.type, sql`(${t.metadata} ->> 'scheduleSlot')`)
114
+ .where(sql`${t.metadata} ->> 'scheduleSlot' IS NOT NULL`),
99
115
  /**
100
116
  * Tier ↔ routing-fields invariant (AUDIT-1):
101
117
  * - `tier` is one of `'domain' | 'audit'`.
@@ -28,9 +28,15 @@
28
28
  * throughput. At that point, swap the backend for Redis Streams or similar
29
29
  * via EventsModule.forRoot({ backend: '...' }) without touching use cases.
30
30
  */
31
+ import { randomUUID } from 'node:crypto';
31
32
  import { Injectable, OnModuleDestroy, OnModuleInit, Inject, Logger, Optional } from '@nestjs/common';
32
33
  import { eq, and, inArray, asc, desc, gte, lt, or, sql, type SQL } from 'drizzle-orm';
33
- import type { DomainEvent, DrizzleTransaction, IEventBus } from './event-bus.protocol';
34
+ import type {
35
+ DomainEvent,
36
+ DrizzleTransaction,
37
+ IEventBus,
38
+ ScheduledEventSpec,
39
+ } from './event-bus.protocol';
34
40
  import type {
35
41
  EventPage,
36
42
  IEventReadPort,
@@ -135,6 +141,17 @@ function toEventSummary(r: DomainEventRecord) {
135
141
  };
136
142
  }
137
143
 
144
+ /**
145
+ * Postgres unique-violation (SQLSTATE 23505) test. Used by the scheduled-event
146
+ * materialiser (ADR-039) to treat a slot-key collision as the
147
+ * already-materialised no-op. Reads `.code` defensively across driver shapes
148
+ * (node-postgres surfaces it on the error, some wrappers nest it on `.cause`).
149
+ */
150
+ function isUniqueViolation(err: unknown): boolean {
151
+ const code = (err as { code?: unknown; cause?: { code?: unknown } } | undefined);
152
+ return code?.code === '23505' || code?.cause?.code === '23505';
153
+ }
154
+
138
155
  @Injectable()
139
156
  export class DrizzleEventBus implements IEventBus, IEventReadPort, OnModuleInit, OnModuleDestroy {
140
157
  private readonly logger = new Logger(DrizzleEventBus.name);
@@ -340,6 +357,91 @@ export class DrizzleEventBus implements IEventBus, IEventReadPort, OnModuleInit,
340
357
  };
341
358
  }
342
359
 
360
+ // ============================================================================
361
+ // ADR-039 — scheduled-event materialisation (time as an event source)
362
+ // ============================================================================
363
+
364
+ /**
365
+ * Insert one scheduled tick event idempotently. The slot key is stamped onto
366
+ * `metadata.scheduleSlot`; `ON CONFLICT DO NOTHING` against the partial UNIQUE
367
+ * expression index `idx_domain_events_schedule_slot` makes a duplicate insert
368
+ * a no-op — the DB constraint is the exactly-one-event-per-slot invariant.
369
+ *
370
+ * Reuses the standard outbox row shape (pool/direction/metadata) so the
371
+ * existing drain carries the tick like any other event. A LISTEN/NOTIFY wake
372
+ * fires for an immediately-due tick (boot/catch-up rows whose slot is already
373
+ * in the past); a future slot is claimed by polling once `occurred_at` passes.
374
+ */
375
+ async materializeScheduledEvent(
376
+ spec: ScheduledEventSpec,
377
+ ): Promise<{ created: boolean }> {
378
+ const multiTenant = this.opts.multiTenant ?? false;
379
+ const metadata: Record<string, unknown> = {
380
+ pool: spec.pool,
381
+ direction: spec.direction,
382
+ scheduleSlot: spec.slotKey,
383
+ triggerSource: 'schedule',
384
+ };
385
+ const base = {
386
+ id: randomUUID(),
387
+ type: spec.type,
388
+ // Payload-free scheduled fact (the dealbrain strict-producer pattern).
389
+ aggregateId: spec.type,
390
+ aggregateType: spec.type,
391
+ payload: {} as Record<string, unknown>,
392
+ occurredAt: spec.slotStart,
393
+ processedAt: null,
394
+ status: 'pending' as const,
395
+ metadata,
396
+ pool: spec.pool,
397
+ direction: spec.direction,
398
+ tier: 'domain' as const,
399
+ };
400
+ const values = multiTenant ? { ...base, tenantId: null } : base;
401
+
402
+ // The idempotency guard is the partial UNIQUE expression index
403
+ // `idx_domain_events_schedule_slot` on (type, metadata->>'scheduleSlot').
404
+ // Drizzle 0.45's typed `onConflictDoNothing({ target })` only accepts
405
+ // columns, so it can't name an expression index — we instead let the
406
+ // insert run and treat a unique-violation (SQLSTATE 23505) as the
407
+ // already-materialised no-op. This is the exactly-one-per-slot invariant:
408
+ // a concurrent or repeat materialise of the same slot loses the race at
409
+ // the DB and reports `created: false`.
410
+ try {
411
+ await this.db.insert(domainEvents).values(values);
412
+ } catch (err) {
413
+ if (isUniqueViolation(err)) return { created: false };
414
+ throw err;
415
+ }
416
+
417
+ // Wake the drainer for an already-due tick. A future slot waits for polling.
418
+ if (spec.slotStart.getTime() <= Date.now()) {
419
+ await this.emitWakeNotify(this.db, [spec.pool]);
420
+ }
421
+ return { created: true };
422
+ }
423
+
424
+ /** Most recent scheduled tick's `occurred_at` (epoch ms) for `type`, or null.
425
+ * Read by the scheduler's catch-up backfill. */
426
+ async lastScheduledSlotMs(type: string): Promise<number | null> {
427
+ const rows = await this.db
428
+ .select({ occurredAt: domainEvents.occurredAt })
429
+ .from(domainEvents)
430
+ .where(
431
+ and(
432
+ eq(domainEvents.type, type),
433
+ sql`${domainEvents.metadata} ->> 'triggerSource' = 'schedule'`,
434
+ ),
435
+ )
436
+ .orderBy(desc(domainEvents.occurredAt))
437
+ .limit(1);
438
+ const row = rows[0];
439
+ if (!row?.occurredAt) return null;
440
+ return row.occurredAt instanceof Date
441
+ ? row.occurredAt.getTime()
442
+ : new Date(row.occurredAt as unknown as string).getTime();
443
+ }
444
+
343
445
  // ============================================================================
344
446
  // IEventReadPort (OBS-LIST-1)
345
447
  // ============================================================================
@@ -19,8 +19,13 @@
19
19
  * than introducing a memory-only options type — the surface is the same
20
20
  * and keeping them unified avoids drift between backends.
21
21
  */
22
+ import { randomUUID } from 'node:crypto';
22
23
  import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
23
- import type { DomainEvent, IEventBus } from './event-bus.protocol';
24
+ import type {
25
+ DomainEvent,
26
+ IEventBus,
27
+ ScheduledEventSpec,
28
+ } from './event-bus.protocol';
24
29
  import type {
25
30
  EventPage,
26
31
  EventSummary,
@@ -110,6 +115,57 @@ export class MemoryEventBus implements IEventBus, IEventReadPort {
110
115
  return this.publishedEvents.find((e) => e.id === eventId) ?? null;
111
116
  }
112
117
 
118
+ // ============================================================================
119
+ // ADR-039 — scheduled-event materialisation (memory parity)
120
+ // ============================================================================
121
+
122
+ /** Slot keys already materialised — the in-memory mirror of the partial
123
+ * UNIQUE expression index `idx_domain_events_schedule_slot`. */
124
+ private readonly materialisedSlots = new Set<string>();
125
+
126
+ /**
127
+ * Mirror of the Drizzle `ON CONFLICT DO NOTHING` insert: emit one payload-free
128
+ * tick event per slot key, no-op if the slot was already materialised. The
129
+ * "constraint" is the `materialisedSlots` set. The tick is published through
130
+ * the normal `publish` path so subscribers fire synchronously (the memory bus
131
+ * has no future-slot/poll concept — a materialised slot dispatches now, which
132
+ * is the behaviour the unit suite pins).
133
+ */
134
+ async materializeScheduledEvent(
135
+ spec: ScheduledEventSpec,
136
+ ): Promise<{ created: boolean }> {
137
+ if (this.materialisedSlots.has(spec.slotKey)) return { created: false };
138
+ this.materialisedSlots.add(spec.slotKey);
139
+ const event: DomainEvent = {
140
+ id: randomUUID(),
141
+ type: spec.type,
142
+ aggregateId: spec.type,
143
+ aggregateType: spec.type,
144
+ payload: {},
145
+ occurredAt: spec.slotStart,
146
+ metadata: {
147
+ pool: spec.pool,
148
+ direction: spec.direction,
149
+ scheduleSlot: spec.slotKey,
150
+ triggerSource: 'schedule',
151
+ },
152
+ };
153
+ await this.publish(event);
154
+ return { created: true };
155
+ }
156
+
157
+ /** Most recent scheduled tick's `occurred_at` (epoch ms) for `type`, or null. */
158
+ async lastScheduledSlotMs(type: string): Promise<number | null> {
159
+ let best: number | null = null;
160
+ for (const e of this.publishedEvents) {
161
+ if (e.type !== type) continue;
162
+ if (e.metadata?.['triggerSource'] !== 'schedule') continue;
163
+ const ms = e.occurredAt.getTime();
164
+ if (best === null || ms > best) best = ms;
165
+ }
166
+ return best;
167
+ }
168
+
113
169
  subscribe<T extends DomainEvent = DomainEvent>(
114
170
  eventType: string,
115
171
  handler: (event: T) => Promise<void>,
@@ -83,4 +83,51 @@ export interface IEventBus {
83
83
  * of Redis backend is unsupported.
84
84
  */
85
85
  findById(eventId: string): Promise<DomainEvent | null>;
86
+
87
+ /**
88
+ * Materialise exactly one scheduled tick event (ADR-039 — time as an event
89
+ * source). Called by the framework `EventScheduler` on its boot + tick passes.
90
+ *
91
+ * The `slotKey` (`@schedule/<type>/<slotStartMs>`) is a pure function of
92
+ * (type, slot) — every instance computes the same key — and is stamped onto
93
+ * `metadata.scheduleSlot` (+ `metadata.triggerSource='schedule'`). The insert
94
+ * is deterministic and idempotent:
95
+ * - Drizzle: `INSERT … ON CONFLICT DO NOTHING` against the partial UNIQUE
96
+ * expression index on `(type, metadata->>'scheduleSlot')`. The DB
97
+ * constraint — not a read, not a lock — is the exactly-one-event-per-slot
98
+ * invariant across multi-instance deploys and boot/tick races.
99
+ * - Memory: a slot-key marker set mirrors the constraint.
100
+ *
101
+ * Returns `created: false` when the slot event already existed (the no-op
102
+ * case). Optional on the protocol — only the scheduler calls it, and the
103
+ * Redis backend (no outbox history) does not implement it (the scheduler is
104
+ * drizzle/memory only).
105
+ */
106
+ materializeScheduledEvent?(
107
+ spec: ScheduledEventSpec,
108
+ ): Promise<{ created: boolean }>;
109
+
110
+ /**
111
+ * Optional (ADR-039) — `occurred_at` (epoch ms) of the most recent scheduled
112
+ * tick for `type`, or `null`. Read by the scheduler's catch-up backfill as
113
+ * `MAX(occurred_at) WHERE type=? AND metadata->>'triggerSource'='schedule'`.
114
+ */
115
+ lastScheduledSlotMs?(type: string): Promise<number | null>;
116
+ }
117
+
118
+ /**
119
+ * The fully-resolved shape the scheduler hands a backend to materialise one
120
+ * tick. Carries the routing fields the outbox row needs (direction/pool from
121
+ * the event's registry metadata) plus the slot key + boundary.
122
+ */
123
+ export interface ScheduledEventSpec {
124
+ type: string;
125
+ /** `@schedule/<type>/<slotStartMs>` — the idempotency key. */
126
+ slotKey: string;
127
+ /** Slot boundary → the event's `occurred_at`. */
128
+ slotStart: Date;
129
+ /** `inbound | change | outbound` from the event's registry metadata. */
130
+ direction: string;
131
+ /** `events_inbound | events_change | events_outbound` from the registry. */
132
+ pool: string;
86
133
  }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * EventScheduler — declarative time-based emission (ADR-039: time as an event
3
+ * source). Materialises exactly one `domain_events` row per (scheduled event
4
+ * type, slot) on a cadence; ADR-023's three activation tiers — unchanged — then
5
+ * react. The scheduler is a STRICT PRODUCER: it emits facts and does no work
6
+ * (the dealbrain `scheduler.service.ts` shape, generalised onto the outbox).
7
+ *
8
+ * Two entry points, both driven by `EventsModule`'s lifecycle:
9
+ *
10
+ * - **reconcile-on-boot** (`materializeBoot`, at `onModuleInit`) — for every
11
+ * scheduled event type, materialise the CURRENT slot (catch-up off → run
12
+ * once on recovery) or bounded backfill (catch-up on). Boot is when a
13
+ * downtime-healing tick matters most. In the outbox model a removed
14
+ * `schedule:` simply stops being materialised — there's no broker scheduler
15
+ * entry to leave dangling, so the dealbrain ENG-605 "zombie scheduler" class
16
+ * of bug is structurally absent; the reconcile half is what we keep.
17
+ * - **tick pass** (`materializeTick` on an interval) — materialise each
18
+ * scheduled event's NEXT (and current) slot so ticks self-perpetuate.
19
+ *
20
+ * Exactly-one-per-slot lives in the DB (the partial UNIQUE expression index on
21
+ * `(type, metadata->>'scheduleSlot')`), reached via
22
+ * `IEventBus.materializeScheduledEvent` → `INSERT … ON CONFLICT DO NOTHING`.
23
+ * The scheduler never READS for an existing slot event (that read is the
24
+ * swe-brain dedupe trap — it matches the still-running incumbent). The slot key
25
+ * is a pure function of (type, slot), so every instance computes the same key
26
+ * and the constraint collapses the race.
27
+ *
28
+ * Drizzle + memory only. The Redis bus retains no outbox history, so slot-key
29
+ * idempotency can't be enforced there (mirrors bridge-on-Redis being
30
+ * unsupported); the scheduler is not wired under `backend: 'redis'`.
31
+ */
32
+ import { Logger } from '@nestjs/common';
33
+ import type { IEventBus } from './event-bus.protocol';
34
+ import { ScheduleConfigError } from './events-errors';
35
+
36
+ // ─── Duration grammar ────────────────────────────────────────────────────────
37
+
38
+ const UNIT_MS: Readonly<Record<string, number>> = Object.freeze({
39
+ ms: 1,
40
+ s: 1_000,
41
+ m: 60_000,
42
+ h: 3_600_000,
43
+ d: 86_400_000,
44
+ });
45
+
46
+ /**
47
+ * Parse a `schedule.every` into milliseconds. Accepts a positive number (ms) or
48
+ * a duration string `<number><unit>` (unit ∈ ms|s|m|h|d; decimals allowed).
49
+ * Throws `ScheduleConfigError` synchronously on anything unparseable, ≤0, or
50
+ * non-finite — so a bad schedule surfaces at boot before the tick loop starts.
51
+ */
52
+ export function parseEvery(every: string | number, eventType?: string): number {
53
+ const where = eventType ? ` (event '${eventType}')` : '';
54
+ let ms: number;
55
+ if (typeof every === 'number') {
56
+ ms = every;
57
+ } else if (typeof every === 'string') {
58
+ const match = /^\s*([0-9]*\.?[0-9]+)\s*(ms|s|m|h|d)\s*$/.exec(every);
59
+ // Destructure the capture groups; under the consumer's stricter tsc
60
+ // (`noUncheckedIndexedAccess`) regex groups are `string | undefined` and
61
+ // the UNIT_MS lookup is `number | undefined`, so guard both explicitly
62
+ // rather than asserting. A truthy `match` always has both groups for this
63
+ // pattern, but the guard makes that provable (no non-null assertion).
64
+ const value = match?.[1];
65
+ const unit = match?.[2];
66
+ const unitMs = unit ? UNIT_MS[unit] : undefined;
67
+ if (value === undefined || unitMs === undefined) {
68
+ throw new ScheduleConfigError(
69
+ `schedule.every '${every}'${where} is not a valid duration. Use a ` +
70
+ `number of ms or '<n><unit>' with unit ms|s|m|h|d (e.g. '1h', '30m').`,
71
+ );
72
+ }
73
+ ms = Number(value) * unitMs;
74
+ } else {
75
+ throw new ScheduleConfigError(
76
+ `schedule.every${where} must be a duration string or a number of ms; ` +
77
+ `got ${typeof every}.`,
78
+ );
79
+ }
80
+ if (!Number.isFinite(ms) || ms <= 0) {
81
+ throw new ScheduleConfigError(
82
+ `schedule.every${where} resolved to ${ms}ms — must be a finite, positive ` +
83
+ `duration.`,
84
+ );
85
+ }
86
+ return ms;
87
+ }
88
+
89
+ // ─── Slot math ───────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * The start of the slot containing `atMs`, for a schedule of `everyMs`.
93
+ * - `align: true` (default) — epoch-anchored: `floor(at / every) * every`.
94
+ * - `align: false` — anchored to `anchorMs` (the scheduler's first-run time).
95
+ */
96
+ export function slotStartFor(
97
+ atMs: number,
98
+ everyMs: number,
99
+ align: boolean,
100
+ anchorMs: number,
101
+ ): number {
102
+ if (align) return Math.floor(atMs / everyMs) * everyMs;
103
+ if (atMs < anchorMs) return anchorMs;
104
+ return anchorMs + Math.floor((atMs - anchorMs) / everyMs) * everyMs;
105
+ }
106
+
107
+ /** The start of the slot AFTER the one containing `atMs`. */
108
+ export function nextSlotStart(
109
+ atMs: number,
110
+ everyMs: number,
111
+ align: boolean,
112
+ anchorMs: number,
113
+ ): number {
114
+ return slotStartFor(atMs, everyMs, align, anchorMs) + everyMs;
115
+ }
116
+
117
+ /** Prefix every scheduler-materialised `metadata.scheduleSlot` carries — the
118
+ * partial UNIQUE index is scoped to non-null slot keys; this prefix keeps the
119
+ * key namespace unambiguous and greppable. */
120
+ export const SCHEDULE_KEY_PREFIX = '@schedule/';
121
+
122
+ /** Deterministic slot key. Pure function of (type, slotStart) — every instance
123
+ * computes the same value, which is what makes the idempotent insert
124
+ * exactly-once. */
125
+ export function slotKeyFor(type: string, slotStartMs: number): string {
126
+ return `${SCHEDULE_KEY_PREFIX}${type}/${slotStartMs}`;
127
+ }
128
+
129
+ // ─── Resolved schedule ───────────────────────────────────────────────────────
130
+
131
+ const DEFAULT_MAX_CATCH_UP_SLOTS = 1000;
132
+
133
+ /** Below this floor (== the default outbox poll interval) materialise/drain
134
+ * latency dominates the cadence; allowed but warned once at boot. */
135
+ export const SCHEDULE_FLOOR_MS = 1_000;
136
+
137
+ /** One scheduled event the scheduler will materialise. Built from the generated
138
+ * event registry (`schedule` block + direction/pool routing metadata). */
139
+ export interface ScheduledEvent {
140
+ type: string;
141
+ everyMs: number;
142
+ align: boolean;
143
+ catchUp: boolean;
144
+ maxCatchUpSlots: number;
145
+ /** Routing — from the event's registry metadata (a scheduled event is
146
+ * domain-tier, so both are always present). */
147
+ direction: string;
148
+ pool: string;
149
+ }
150
+
151
+ /** The raw `schedule` block as it appears in the generated registry entry. */
152
+ export interface RegistrySchedule {
153
+ every: string | number;
154
+ align?: boolean;
155
+ catchUp?: boolean;
156
+ maxCatchUpSlots?: number;
157
+ }
158
+
159
+ /** Validate + normalise one registry entry's `schedule` into a `ScheduledEvent`.
160
+ * Throws `ScheduleConfigError` on a malformed `every` (boot backstop — codegen
161
+ * already validated, this catches hand-edits / version skew). */
162
+ export function resolveScheduledEvent(
163
+ type: string,
164
+ schedule: RegistrySchedule,
165
+ direction: string | null,
166
+ pool: string | null,
167
+ ): ScheduledEvent {
168
+ if (!direction || !pool) {
169
+ throw new ScheduleConfigError(
170
+ `event '${type}' declares a schedule but has no direction/pool — a ` +
171
+ `scheduled event must be domain-tier so it can route to the bridge.`,
172
+ );
173
+ }
174
+ return {
175
+ type,
176
+ everyMs: parseEvery(schedule.every, type),
177
+ align: schedule.align ?? true,
178
+ catchUp: schedule.catchUp ?? false,
179
+ maxCatchUpSlots: schedule.maxCatchUpSlots ?? DEFAULT_MAX_CATCH_UP_SLOTS,
180
+ direction,
181
+ pool,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Read the scheduled-event set from a generated `eventRegistry`. The registry
187
+ * value shape is structural (`{ schedule?, direction, pool }`) so this stays
188
+ * decoupled from the generated `EventMetadata` type. Returns `[]` when nothing
189
+ * declared `schedule:`.
190
+ */
191
+ export function scheduledEventsFromRegistry(
192
+ registry: Record<
193
+ string,
194
+ { schedule?: RegistrySchedule; direction: string | null; pool: string | null }
195
+ >,
196
+ ): ScheduledEvent[] {
197
+ const out: ScheduledEvent[] = [];
198
+ for (const [type, meta] of Object.entries(registry)) {
199
+ if (!meta?.schedule) continue;
200
+ out.push(resolveScheduledEvent(type, meta.schedule, meta.direction, meta.pool));
201
+ }
202
+ return out;
203
+ }
204
+
205
+ // ─── EventScheduler ──────────────────────────────────────────────────────────
206
+
207
+ export interface EventSchedulerOptions {
208
+ /** Tick cadence (ms). Default = smallest scheduled `every`, floored. Test override. */
209
+ tickIntervalMs?: number;
210
+ /** Injectable clock for deterministic tests. Default `Date.now`. */
211
+ now?: () => number;
212
+ }
213
+
214
+ export class EventScheduler {
215
+ private readonly logger = new Logger(EventScheduler.name);
216
+ private readonly now: () => number;
217
+ private timer: ReturnType<typeof setInterval> | null = null;
218
+ private readonly anchorMs: number;
219
+ private readonly tickIntervalMs: number;
220
+
221
+ constructor(
222
+ private readonly bus: IEventBus,
223
+ private readonly schedules: ReadonlyArray<ScheduledEvent>,
224
+ opts: EventSchedulerOptions = {},
225
+ ) {
226
+ this.now = opts.now ?? Date.now;
227
+ this.anchorMs = this.now();
228
+ const smallest = schedules.length
229
+ ? Math.min(...schedules.map((s) => s.everyMs))
230
+ : SCHEDULE_FLOOR_MS;
231
+ this.tickIntervalMs = opts.tickIntervalMs ?? Math.max(smallest, SCHEDULE_FLOOR_MS);
232
+ for (const s of schedules) {
233
+ if (s.everyMs < SCHEDULE_FLOOR_MS) {
234
+ this.logger.warn(
235
+ `schedule for '${s.type}' is every ${s.everyMs}ms — below the ` +
236
+ `${SCHEDULE_FLOOR_MS}ms floor; materialise/drain latency dominates, ` +
237
+ `so the cadence is not honoured to that precision.`,
238
+ );
239
+ }
240
+ }
241
+ if (typeof bus.materializeScheduledEvent !== 'function') {
242
+ // The backend (e.g. Redis) cannot enforce slot idempotency. Caller
243
+ // should not construct the scheduler for such backends; guard anyway.
244
+ this.logger.warn(
245
+ `the configured event bus does not support scheduled-event ` +
246
+ `materialisation; ${schedules.length} schedule(s) will not fire.`,
247
+ );
248
+ }
249
+ }
250
+
251
+ /** Reconcile-on-boot, then start the tick interval. Idempotent. */
252
+ async start(): Promise<void> {
253
+ if (this.schedules.length === 0) return;
254
+ if (typeof this.bus.materializeScheduledEvent !== 'function') return;
255
+ await this.materializeBoot();
256
+ if (this.timer) return;
257
+ this.timer = setInterval(() => {
258
+ void this.materializeTick();
259
+ }, this.tickIntervalMs);
260
+ (this.timer as { unref?: () => void }).unref?.();
261
+ this.logger.log(
262
+ `EventScheduler started: ${this.schedules.length} scheduled event(s), ` +
263
+ `tick=${this.tickIntervalMs}ms.`,
264
+ );
265
+ }
266
+
267
+ /** Stop the tick interval. Idempotent. */
268
+ stop(): void {
269
+ if (this.timer) {
270
+ clearInterval(this.timer);
271
+ this.timer = null;
272
+ }
273
+ }
274
+
275
+ /** Boot pass — materialise the current slot (or bounded backfill) per event. */
276
+ async materializeBoot(): Promise<void> {
277
+ const nowMs = this.now();
278
+ for (const s of this.schedules) {
279
+ try {
280
+ if (s.catchUp) {
281
+ await this.backfill(s, nowMs);
282
+ } else {
283
+ await this.materializeOne(s, slotStartFor(nowMs, s.everyMs, s.align, this.anchorMs));
284
+ }
285
+ } catch (err) {
286
+ this.logger.error(
287
+ `boot materialise for '${s.type}' failed: ${(err as Error).message}`,
288
+ );
289
+ }
290
+ }
291
+ }
292
+
293
+ /** Tick pass — materialise the current + next slot per event (current covers a
294
+ * tick landing in a fresh slot the boot pass missed). */
295
+ async materializeTick(): Promise<void> {
296
+ const nowMs = this.now();
297
+ for (const s of this.schedules) {
298
+ try {
299
+ const current = slotStartFor(nowMs, s.everyMs, s.align, this.anchorMs);
300
+ await this.materializeOne(s, current);
301
+ await this.materializeOne(s, current + s.everyMs);
302
+ } catch (err) {
303
+ this.logger.error(
304
+ `tick materialise for '${s.type}' failed: ${(err as Error).message}`,
305
+ );
306
+ }
307
+ }
308
+ }
309
+
310
+ private async materializeOne(s: ScheduledEvent, slotStartMs: number): Promise<void> {
311
+ // `materialize*` only runs after `start()`/the boot path confirmed the bus
312
+ // supports materialisation; guard here too (no non-null assertion) so the
313
+ // optional-method call is provably defined.
314
+ const materialize = this.bus.materializeScheduledEvent;
315
+ if (!materialize) return;
316
+ const slotKey = slotKeyFor(s.type, slotStartMs);
317
+ const { created } = await materialize.call(this.bus, {
318
+ type: s.type,
319
+ slotKey,
320
+ slotStart: new Date(slotStartMs),
321
+ direction: s.direction,
322
+ pool: s.pool,
323
+ });
324
+ if (created) {
325
+ this.logger.debug?.(
326
+ `materialised '${s.type}' slot ${new Date(slotStartMs).toISOString()}`,
327
+ );
328
+ }
329
+ }
330
+
331
+ /** Backfill missed slots from the last emitted slot to the current one,
332
+ * bounded by `maxCatchUpSlots`. */
333
+ private async backfill(s: ScheduledEvent, nowMs: number): Promise<void> {
334
+ const current = slotStartFor(nowMs, s.everyMs, s.align, this.anchorMs);
335
+ const lastMs = (await this.bus.lastScheduledSlotMs?.(s.type)) ?? null;
336
+ let from = lastMs !== null ? lastMs + s.everyMs : current;
337
+ if (from > current) from = current; // last >= current → just (re)try current
338
+ const total = Math.floor((current - from) / s.everyMs) + 1;
339
+ if (total > s.maxCatchUpSlots) {
340
+ const dropped = total - s.maxCatchUpSlots;
341
+ from = current - (s.maxCatchUpSlots - 1) * s.everyMs;
342
+ this.logger.warn(
343
+ `catchUp for '${s.type}' would backfill ${total} slots; capping at ` +
344
+ `${s.maxCatchUpSlots} (dropping ${dropped} oldest).`,
345
+ );
346
+ }
347
+ for (let slot = from; slot <= current; slot += s.everyMs) {
348
+ await this.materializeOne(s, slot);
349
+ }
350
+ }
351
+ }