@pattern-stack/codegen 0.18.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.
- package/CHANGELOG.md +88 -0
- package/README.md +51 -0
- package/consumer-skills/codegen/SKILL.md +32 -0
- package/consumer-skills/entities/SKILL.md +2 -0
- package/consumer-skills/events/authoring-events.md +31 -0
- package/dist/{chunk-COGHTKXY.js → chunk-27ETSJ2X.js} +2 -2
- package/dist/{chunk-X6BP6LI5.js → chunk-3YCUIGPG.js} +10 -10
- package/dist/{chunk-BK5ICA2F.js → chunk-4MVGAMUA.js} +4 -4
- package/dist/{chunk-LQ6PYFU6.js → chunk-4OC5MSHO.js} +47 -1
- package/dist/chunk-4OC5MSHO.js.map +1 -0
- package/dist/{chunk-3A34R6CI.js → chunk-5LXOJGO2.js} +4 -4
- package/dist/{chunk-T6C4LFLC.js → chunk-7YGORYZD.js} +4 -4
- package/dist/{chunk-PKDS6QIJ.js → chunk-ATVGYF3D.js} +7 -7
- package/dist/{chunk-2TVVBC53.js → chunk-BORNCTH3.js} +2 -2
- package/dist/{chunk-7MMS36AN.js → chunk-CUSMC2KK.js} +10 -10
- package/dist/{chunk-V4AF6DI4.js → chunk-DUBZOXJC.js} +9 -2
- package/dist/{chunk-V4AF6DI4.js.map → chunk-DUBZOXJC.js.map} +1 -1
- package/dist/chunk-DUUCU77W.js +211 -0
- package/dist/chunk-DUUCU77W.js.map +1 -0
- package/dist/{chunk-OFRRBC7M.js → chunk-E2BRT5IB.js} +15 -1
- package/dist/chunk-E2BRT5IB.js.map +1 -0
- package/dist/{chunk-VQOAATIG.js → chunk-FLYF76CU.js} +4 -4
- package/dist/{chunk-43SBT72G.js → chunk-I6UXRJ3Q.js} +4 -4
- package/dist/{chunk-VHAR2BGH.js → chunk-INO47JXD.js} +5 -5
- package/dist/{chunk-BGULBWKJ.js → chunk-JOBQ6RUU.js} +1 -1
- package/dist/chunk-JOBQ6RUU.js.map +1 -0
- package/dist/{chunk-C5E7H553.js → chunk-KBO5OOON.js} +4 -4
- package/dist/{chunk-R4BPUUB5.js → chunk-KHQ72A5F.js} +54 -6
- package/dist/chunk-KHQ72A5F.js.map +1 -0
- package/dist/{chunk-CFFTPWHM.js → chunk-KK5A7B2T.js} +70 -5
- package/dist/chunk-KK5A7B2T.js.map +1 -0
- package/dist/{chunk-RKNW56RU.js → chunk-LARB26EI.js} +73 -6
- package/dist/chunk-LARB26EI.js.map +1 -0
- package/dist/{chunk-2VGVSL2D.js → chunk-LQXBQO72.js} +6 -6
- package/dist/{chunk-K2I6XIK5.js → chunk-MVKW2BCR.js} +2 -2
- package/dist/{chunk-EWYI5GGJ.js → chunk-NYJYK6J4.js} +15 -15
- package/dist/{chunk-IN3EWFB4.js → chunk-QSJ3J4HE.js} +6 -6
- package/dist/{chunk-TBGTMALE.js → chunk-SGSWVNNB.js} +4 -4
- package/dist/chunk-SYVZ4MD2.js +1 -0
- package/dist/{chunk-NXHL5YII.js → chunk-T6SCOJF4.js} +4 -4
- package/dist/{chunk-YZLBU6O2.js → chunk-WKNOEVWQ.js} +5 -5
- package/dist/runtime/base-classes/index.js +17 -17
- package/dist/runtime/subsystems/auth/auth.module.js +3 -3
- package/dist/runtime/subsystems/auth/index.js +10 -10
- package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js +2 -2
- package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +3 -3
- package/dist/runtime/subsystems/bridge/bridge-delivery.schema.js +2 -2
- package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +6 -6
- package/dist/runtime/subsystems/bridge/bridge.module.js +18 -18
- package/dist/runtime/subsystems/bridge/index.js +21 -21
- package/dist/runtime/subsystems/cache/cache.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/cache/cache.module.js +3 -3
- package/dist/runtime/subsystems/cache/index.js +5 -5
- package/dist/runtime/subsystems/events/domain-events.schema.js +1 -1
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +19 -32
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +3 -3
- package/dist/runtime/subsystems/events/event-bus.memory-backend.d.ts +18 -1
- package/dist/runtime/subsystems/events/event-bus.memory-backend.js +1 -1
- package/dist/runtime/subsystems/events/event-bus.protocol.d.ts +45 -1
- package/dist/runtime/subsystems/events/event-scheduler.d.ts +96 -0
- package/dist/runtime/subsystems/events/event-scheduler.js +25 -0
- package/dist/runtime/subsystems/events/event-scheduler.js.map +1 -0
- package/dist/runtime/subsystems/events/events-errors.d.ts +12 -1
- package/dist/runtime/subsystems/events/events-errors.js +5 -3
- package/dist/runtime/subsystems/events/events.module.d.ts +41 -2
- package/dist/runtime/subsystems/events/events.module.js +11 -8
- package/dist/runtime/subsystems/events/generated/bus.js +3 -3
- package/dist/runtime/subsystems/events/generated/index.js +3 -3
- package/dist/runtime/subsystems/events/generated/registry.d.ts +6 -0
- package/dist/runtime/subsystems/events/generated/registry.js +1 -1
- package/dist/runtime/subsystems/events/index.d.ts +4 -3
- package/dist/runtime/subsystems/events/index.js +38 -14
- package/dist/runtime/subsystems/index.d.ts +1 -0
- package/dist/runtime/subsystems/index.js +117 -116
- package/dist/runtime/subsystems/integration/build-change-source.js +2 -2
- package/dist/runtime/subsystems/integration/index.js +36 -36
- package/dist/runtime/subsystems/integration/integration.module.js +4 -4
- package/dist/runtime/subsystems/jobs/index.js +33 -33
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +5 -5
- package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.js +3 -3
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +2 -2
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +3 -3
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +3 -3
- package/dist/runtime/subsystems/jobs/job-worker.js +2 -2
- package/dist/runtime/subsystems/jobs/job-worker.module.js +11 -11
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +9 -9
- package/dist/runtime/subsystems/storage/index.js +4 -4
- package/dist/runtime/subsystems/storage/storage.module.js +2 -2
- package/dist/src/cli/index.js +168 -22
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.js +12 -12
- package/package.json +1 -1
- package/runtime/subsystems/events/domain-events.schema.ts +16 -0
- package/runtime/subsystems/events/event-bus.drizzle-backend.ts +103 -1
- package/runtime/subsystems/events/event-bus.memory-backend.ts +57 -1
- package/runtime/subsystems/events/event-bus.protocol.ts +47 -0
- package/runtime/subsystems/events/event-scheduler.ts +351 -0
- package/runtime/subsystems/events/events-errors.ts +14 -0
- package/runtime/subsystems/events/events.module.ts +78 -1
- package/runtime/subsystems/events/generated/registry.ts +1 -0
- package/runtime/subsystems/events/index.ts +25 -3
- package/dist/chunk-BGULBWKJ.js.map +0 -1
- package/dist/chunk-CFFTPWHM.js.map +0 -1
- package/dist/chunk-FN2PYDPP.js +0 -1
- package/dist/chunk-LQ6PYFU6.js.map +0 -1
- package/dist/chunk-OFRRBC7M.js.map +0 -1
- package/dist/chunk-R4BPUUB5.js.map +0 -1
- package/dist/chunk-RKNW56RU.js.map +0 -1
- /package/dist/{chunk-COGHTKXY.js.map → chunk-27ETSJ2X.js.map} +0 -0
- /package/dist/{chunk-X6BP6LI5.js.map → chunk-3YCUIGPG.js.map} +0 -0
- /package/dist/{chunk-BK5ICA2F.js.map → chunk-4MVGAMUA.js.map} +0 -0
- /package/dist/{chunk-3A34R6CI.js.map → chunk-5LXOJGO2.js.map} +0 -0
- /package/dist/{chunk-T6C4LFLC.js.map → chunk-7YGORYZD.js.map} +0 -0
- /package/dist/{chunk-PKDS6QIJ.js.map → chunk-ATVGYF3D.js.map} +0 -0
- /package/dist/{chunk-2TVVBC53.js.map → chunk-BORNCTH3.js.map} +0 -0
- /package/dist/{chunk-7MMS36AN.js.map → chunk-CUSMC2KK.js.map} +0 -0
- /package/dist/{chunk-VQOAATIG.js.map → chunk-FLYF76CU.js.map} +0 -0
- /package/dist/{chunk-43SBT72G.js.map → chunk-I6UXRJ3Q.js.map} +0 -0
- /package/dist/{chunk-VHAR2BGH.js.map → chunk-INO47JXD.js.map} +0 -0
- /package/dist/{chunk-C5E7H553.js.map → chunk-KBO5OOON.js.map} +0 -0
- /package/dist/{chunk-2VGVSL2D.js.map → chunk-LQXBQO72.js.map} +0 -0
- /package/dist/{chunk-K2I6XIK5.js.map → chunk-MVKW2BCR.js.map} +0 -0
- /package/dist/{chunk-EWYI5GGJ.js.map → chunk-NYJYK6J4.js.map} +0 -0
- /package/dist/{chunk-IN3EWFB4.js.map → chunk-QSJ3J4HE.js.map} +0 -0
- /package/dist/{chunk-TBGTMALE.js.map → chunk-SGSWVNNB.js.map} +0 -0
- /package/dist/{chunk-FN2PYDPP.js.map → chunk-SYVZ4MD2.js.map} +0 -0
- /package/dist/{chunk-NXHL5YII.js.map → chunk-T6SCOJF4.js.map} +0 -0
- /package/dist/{chunk-YZLBU6O2.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-
|
|
47
|
+
} from "../chunk-KK5A7B2T.js";
|
|
48
48
|
import "../chunk-KVOWSC5S.js";
|
|
49
|
-
import "../chunk-
|
|
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-
|
|
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-
|
|
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
|
@@ -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 {
|
|
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 {
|
|
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
|
+
}
|