@nwire/forge 0.12.1 → 0.13.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/README.md +100 -83
- package/dist/framework-events.d.ts +8 -37
- package/dist/framework-events.js +7 -3
- package/dist/helpers/cli-runner.js +21 -10
- package/dist/index.d.ts +8 -7
- package/dist/index.js +7 -6
- package/dist/plugins/actions-chain.d.ts +39 -22
- package/dist/plugins/actions-chain.js +117 -78
- package/dist/plugins/actions-plugin.d.ts +26 -23
- package/dist/plugins/actions-plugin.js +122 -44
- package/dist/plugins/actors-chain.d.ts +9 -2
- package/dist/plugins/actors-chain.js +62 -2
- package/dist/plugins/actors-plugin.d.ts +1 -1
- package/dist/plugins/actors-plugin.js +24 -14
- package/dist/plugins/external-calls-plugin.d.ts +28 -0
- package/dist/plugins/external-calls-plugin.js +136 -0
- package/dist/plugins/idempotency-plugin.d.ts +15 -1
- package/dist/plugins/idempotency-plugin.js +56 -11
- package/dist/plugins/projections-chain.d.ts +2 -2
- package/dist/plugins/projections-chain.js +2 -2
- package/dist/plugins/projections-plugin.d.ts +1 -1
- package/dist/plugins/projections-plugin.js +4 -13
- package/dist/plugins/queries-chain.d.ts +4 -3
- package/dist/plugins/queries-chain.js +8 -5
- package/dist/plugins/queries-plugin.d.ts +15 -29
- package/dist/plugins/queries-plugin.js +36 -49
- package/dist/plugins/workflows-chain.d.ts +9 -2
- package/dist/plugins/workflows-chain.js +19 -1
- package/dist/plugins/workflows-plugin.d.ts +1 -1
- package/dist/plugins/workflows-plugin.js +12 -20
- package/dist/primitives/define-action.d.ts +80 -115
- package/dist/primitives/define-action.js +111 -56
- package/dist/primitives/define-actor.d.ts +103 -214
- package/dist/primitives/define-actor.js +157 -216
- package/dist/primitives/define-handler.d.ts +42 -112
- package/dist/primitives/define-handler.js +14 -45
- package/dist/primitives/define-projection.d.ts +23 -28
- package/dist/primitives/define-projection.js +29 -32
- package/dist/primitives/define-query.d.ts +52 -42
- package/dist/primitives/define-query.js +65 -28
- package/dist/primitives/define-workflow.d.ts +8 -11
- package/dist/primitives/define-workflow.js +14 -8
- package/dist/runtime/forge-dispatcher.d.ts +30 -12
- package/dist/runtime/forge-dispatcher.js +199 -237
- package/dist/runtime/forge-plugin.d.ts +8 -0
- package/dist/runtime/forge-plugin.js +113 -31
- package/dist/runtime/forge-plugins.d.ts +55 -0
- package/dist/runtime/forge-plugins.js +57 -0
- package/dist/runtime/forge-types.d.ts +8 -2
- package/dist/runtime/with-forge.d.ts +8 -11
- package/dist/runtime/with-forge.js +9 -11
- package/dist/stores/idempotency-store.d.ts +1 -1
- package/package.json +12 -12
|
@@ -23,7 +23,7 @@ import { type DeadLetterSink } from "@nwire/dead-letter";
|
|
|
23
23
|
import type { EventBus } from "@nwire/bus";
|
|
24
24
|
import type { z } from "zod";
|
|
25
25
|
import type { ActionDefinition, ActionInput, ActionResult } from "../primitives/define-action.js";
|
|
26
|
-
import type { HandlerDefinition } from "../primitives/define-handler.js";
|
|
26
|
+
import type { HandlerContext, HandlerDefinition } from "../primitives/define-handler.js";
|
|
27
27
|
import type { ActorDefinition } from "../primitives/define-actor.js";
|
|
28
28
|
import type { WorkflowDefinition, WorkflowInstance } from "../primitives/define-workflow.js";
|
|
29
29
|
import type { ProjectionDefinition } from "../primitives/define-projection.js";
|
|
@@ -133,9 +133,19 @@ export declare class ForgeDispatcher {
|
|
|
133
133
|
fireDueTimers(now?: number): Promise<number>;
|
|
134
134
|
fireDueWorkflowTimers(now?: Date): Promise<number>;
|
|
135
135
|
dispatch<A extends ActionDefinition>(action: A, input: ActionInput<A>, parentEnvelope?: MessageEnvelope, opts?: DispatchOptions): Promise<ActionResult<A>>;
|
|
136
|
+
/**
|
|
137
|
+
* The forge command pipeline — runs as a registered action handler's core
|
|
138
|
+
* under `runtime.execute`: `action.dispatched` telemetry, the
|
|
139
|
+
* `ActionDispatching` veto hook, the per-action `before`/`after` hooks,
|
|
140
|
+
* retry + backoff invoking the raw handler, dead-letter on exhaustion,
|
|
141
|
+
* completion/failure telemetry, and event-return publishing. `ctx` is
|
|
142
|
+
* execute's handler ctx (capCtx + verbs); `rawHandler` is the action's
|
|
143
|
+
* original handler fn.
|
|
144
|
+
*/
|
|
145
|
+
runActionPipeline(action: ActionDefinition<any>, rawHandler: (input: unknown, ctx: HandlerContext) => unknown, ctx: HandlerContext, signal: AbortSignal): Promise<unknown>;
|
|
136
146
|
/**
|
|
137
147
|
* Publish a batch of events. Each event flows through the
|
|
138
|
-
* `
|
|
148
|
+
* `LocalDelivery` hook chain — idempotency → actors → projections →
|
|
139
149
|
* workflows → bus — at structurally enforced priorities. The chain
|
|
140
150
|
* participants are attached once at plugin setup via
|
|
141
151
|
* {@link attachPublishChain}.
|
|
@@ -145,23 +155,29 @@ export declare class ForgeDispatcher {
|
|
|
145
155
|
*/
|
|
146
156
|
publish(events: readonly EventMessage[], parentEnvelope: MessageEnvelope): Promise<void>;
|
|
147
157
|
/**
|
|
148
|
-
* Attach forge's
|
|
158
|
+
* Attach forge's bundled fold steps to the core `LocalDelivery` hook.
|
|
149
159
|
* Priority slots are defined in `EVENT_PUBLISHING_PRIORITIES`:
|
|
150
160
|
*
|
|
151
|
-
* 1000 — idempotency gate. Short-circuits on duplicate.
|
|
161
|
+
* 1000 — idempotency gate. Short-circuits (vetoes) on duplicate.
|
|
152
162
|
* 800 — actor state transitions.
|
|
153
163
|
* 600 — projection folds.
|
|
154
164
|
* 400 — workflow correlation + fire.
|
|
155
|
-
* 200 — cross-process bus delivery (public events only).
|
|
156
|
-
* 100 — outbound sink drain (public events only) — feeds
|
|
157
|
-
* adopters that install via `installSinkStage` (bullmq,
|
|
158
|
-
* AMQP, telemetry-otel, …).
|
|
159
165
|
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
166
|
+
* Cross-process bus delivery and the outbound sink drain are NOT chain
|
|
167
|
+
* steps — they're the outgoing `publish` concern, applied by
|
|
168
|
+
* `dispatcher.publish` after `runtime.deliver` for public, non-deduped
|
|
169
|
+
* events. Keeping them out of the local chain means local `emit` (and
|
|
170
|
+
* `publishCapability`) never double-drain.
|
|
171
|
+
*
|
|
172
|
+
* Called once at plugin setup (bundled mode). NOTE: the hook engine does
|
|
173
|
+
* NOT dedup step names — bundled (`createForgePlugin`) and à-la-carte (the
|
|
174
|
+
* sub-plugins) are mutually exclusive; mixing them double-registers a fold
|
|
175
|
+
* step. Each sub-plugin warns at `AppReady` if it detects a duplicate
|
|
176
|
+
* (the `AppReady` hook fires `.on` listeners via `allSettled`, so a thrown
|
|
177
|
+
* guard would be swallowed — a hard-fail needs a registration-time check;
|
|
178
|
+
* tracked as a follow-up).
|
|
162
179
|
*/
|
|
163
180
|
attachPublishChain(): void;
|
|
164
|
-
applyExternalEvent(eventName: string, payload: unknown, envelope: MessageEnvelope): Promise<void>;
|
|
165
181
|
private foldProjections;
|
|
166
182
|
private applyToActors;
|
|
167
183
|
private extractKey;
|
|
@@ -169,7 +185,9 @@ export declare class ForgeDispatcher {
|
|
|
169
185
|
private applyEventToActorLocked;
|
|
170
186
|
private computeTimersForState;
|
|
171
187
|
private runWorkflows;
|
|
172
|
-
|
|
188
|
+
/** Public actor-view accessor — backs the `actor(actor, id)` ctx member. */
|
|
189
|
+
actorView<TActor extends ActorDefinition>(actor: TActor, id: string, envelope: MessageEnvelope): Promise<any>;
|
|
173
190
|
private loadActorView;
|
|
174
191
|
getDeadLetterSink(): DeadLetterSink;
|
|
175
192
|
}
|
|
193
|
+
/** Local alias for the dispatch-hook ctx shape; lifted into kernel.Runtime. */
|
|
@@ -116,9 +116,20 @@ export class ForgeDispatcher {
|
|
|
116
116
|
this.handlers.set(name, handler);
|
|
117
117
|
this.ensureActionBeforeHook(name);
|
|
118
118
|
this.ensureActionAfterHook(name);
|
|
119
|
+
// Make the handler's `run` the forge command pipeline, so dispatching it
|
|
120
|
+
// through `runtime.execute` (whether from `dispatch`, a wire, or HTTP)
|
|
121
|
+
// runs retry/DLQ/hooks/telemetry/event-publish as the core. Mutate in
|
|
122
|
+
// place so every reference (dispatcher, runtime registry, wires) shares
|
|
123
|
+
// it. The retry loop re-invokes the raw `handler.handler` directly, as
|
|
124
|
+
// before — no nested `.use` chain.
|
|
125
|
+
const rawHandler = handler.handler;
|
|
126
|
+
const action = handler.action;
|
|
127
|
+
handler.run = async (ctx, opts) => ({
|
|
128
|
+
result: await this.runActionPipeline(action, rawHandler, ctx, opts?.signal ?? new AbortController().signal),
|
|
129
|
+
});
|
|
119
130
|
// The action declares `emits: [SomeEvent, ...]`. Any event the
|
|
120
131
|
// author marked `.public()` carries `$public: true` — surface it
|
|
121
|
-
// to the dispatcher so the
|
|
132
|
+
// to the dispatcher so the LocalDelivery chain's bus + outbound
|
|
122
133
|
// steps fire when that event flows through.
|
|
123
134
|
const emits = handler.action.emits;
|
|
124
135
|
if (emits) {
|
|
@@ -434,12 +445,35 @@ export class ForgeDispatcher {
|
|
|
434
445
|
if (!handler) {
|
|
435
446
|
throw new Error(`Runtime: no handler registered for action "${action.name}".`);
|
|
436
447
|
}
|
|
448
|
+
// One dispatch path. The handler's `run` IS the forge command pipeline
|
|
449
|
+
// (installed at registerActionHandler), so retry / DLQ / ActionDispatching /
|
|
450
|
+
// before-after / telemetry / event-publish run as the handler's core under
|
|
451
|
+
// `runtime.execute` — capCtx, one per-request scope, one dispatch pin.
|
|
452
|
+
return this.runtime.execute(handler, input, {
|
|
453
|
+
parent: parentEnvelope,
|
|
454
|
+
signal: opts?.signal,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* The forge command pipeline — runs as a registered action handler's core
|
|
459
|
+
* under `runtime.execute`: `action.dispatched` telemetry, the
|
|
460
|
+
* `ActionDispatching` veto hook, the per-action `before`/`after` hooks,
|
|
461
|
+
* retry + backoff invoking the raw handler, dead-letter on exhaustion,
|
|
462
|
+
* completion/failure telemetry, and event-return publishing. `ctx` is
|
|
463
|
+
* execute's handler ctx (capCtx + verbs); `rawHandler` is the action's
|
|
464
|
+
* original handler fn.
|
|
465
|
+
*/
|
|
466
|
+
async runActionPipeline(
|
|
467
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
468
|
+
action, rawHandler, ctx, signal) {
|
|
437
469
|
const appName = this.runtime.appName;
|
|
438
|
-
const envelope =
|
|
439
|
-
const
|
|
470
|
+
const envelope = ctx.envelope;
|
|
471
|
+
const rawInput = ctx.input;
|
|
472
|
+
const validated = isValidated(rawInput)
|
|
473
|
+
? rawInput
|
|
474
|
+
: markValidated(action.schema.parse(rawInput));
|
|
440
475
|
const log = loggerForEnvelope(this.runtime.logger, envelope);
|
|
441
|
-
const
|
|
442
|
-
const ctx = this.buildHandlerContext(envelope, log, signal);
|
|
476
|
+
const startedAt = performance.now();
|
|
443
477
|
this.runtime.pushTelemetry({
|
|
444
478
|
kind: "action.dispatched",
|
|
445
479
|
action: action.name,
|
|
@@ -448,7 +482,6 @@ export class ForgeDispatcher {
|
|
|
448
482
|
appName,
|
|
449
483
|
ts: new Date().toISOString(),
|
|
450
484
|
});
|
|
451
|
-
const startedAt = performance.now();
|
|
452
485
|
const dispatchResult = await this.runtime.hooks.ActionDispatching.runDetailed({
|
|
453
486
|
action,
|
|
454
487
|
input: validated,
|
|
@@ -468,128 +501,123 @@ export class ForgeDispatcher {
|
|
|
468
501
|
return undefined;
|
|
469
502
|
}
|
|
470
503
|
}
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
504
|
+
const retry = action.retry;
|
|
505
|
+
const maxAttempts = 1 + (retry?.max ?? 0);
|
|
506
|
+
let attempt = 0;
|
|
507
|
+
let lastError;
|
|
508
|
+
while (attempt < maxAttempts) {
|
|
509
|
+
attempt++;
|
|
510
|
+
if (attempt > 1 && signal.aborted) {
|
|
511
|
+
log.warn(`abort observed between attempts; skipping retries`, {
|
|
512
|
+
action: action.name,
|
|
513
|
+
attempt,
|
|
514
|
+
maxAttempts,
|
|
515
|
+
});
|
|
516
|
+
// eslint-disable-next-line no-throw-literal
|
|
517
|
+
throw lastError;
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
if (attempt > 1) {
|
|
521
|
+
const delay = computeBackoff(retry, attempt - 1);
|
|
522
|
+
log.warn(`retrying handler`, {
|
|
480
523
|
action: action.name,
|
|
481
524
|
attempt,
|
|
482
525
|
maxAttempts,
|
|
526
|
+
delayMs: delay,
|
|
483
527
|
});
|
|
484
|
-
|
|
485
|
-
|
|
528
|
+
if (delay > 0)
|
|
529
|
+
await sleep(delay);
|
|
486
530
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
531
|
+
const rawResult = await rawHandler(validated, ctx);
|
|
532
|
+
const events = normalizeEventReturn((rawResult ?? null));
|
|
533
|
+
if (events.length > 0) {
|
|
534
|
+
await this.publish(events, envelope);
|
|
535
|
+
}
|
|
536
|
+
const durationMs = performance.now() - startedAt;
|
|
537
|
+
this.runtime.pushTelemetry({
|
|
538
|
+
kind: "action.completed",
|
|
539
|
+
action: action.name,
|
|
540
|
+
durationMs,
|
|
541
|
+
emittedEvents: events.map((e) => e.eventName),
|
|
542
|
+
envelope,
|
|
543
|
+
appName,
|
|
544
|
+
ts: new Date().toISOString(),
|
|
545
|
+
});
|
|
546
|
+
const afterHook = this.actionAfterHooks.get(action.name);
|
|
547
|
+
if (afterHook) {
|
|
548
|
+
try {
|
|
549
|
+
await afterHook.run({
|
|
550
|
+
action,
|
|
551
|
+
input: validated,
|
|
552
|
+
result: rawResult ?? undefined,
|
|
553
|
+
durationMs,
|
|
495
554
|
});
|
|
496
|
-
if (delay > 0)
|
|
497
|
-
await sleep(delay);
|
|
498
555
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const durationMs = performance.now() - startedAt;
|
|
505
|
-
this.runtime.pushTelemetry({
|
|
506
|
-
kind: "action.completed",
|
|
507
|
-
action: action.name,
|
|
508
|
-
durationMs,
|
|
509
|
-
emittedEvents: events.map((e) => e.eventName),
|
|
510
|
-
envelope,
|
|
511
|
-
appName,
|
|
512
|
-
ts: new Date().toISOString(),
|
|
513
|
-
});
|
|
514
|
-
const afterHook = this.actionAfterHooks.get(action.name);
|
|
515
|
-
if (afterHook) {
|
|
516
|
-
try {
|
|
517
|
-
await afterHook.run({
|
|
518
|
-
action,
|
|
519
|
-
input: validated,
|
|
520
|
-
result: rawResult ?? undefined,
|
|
521
|
-
durationMs,
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
catch (err) {
|
|
525
|
-
log.error(`action.after hook threw`, {
|
|
526
|
-
action: action.name,
|
|
527
|
-
error: err?.message,
|
|
528
|
-
});
|
|
529
|
-
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
log.error(`action.after hook threw`, {
|
|
558
|
+
action: action.name,
|
|
559
|
+
error: err?.message,
|
|
560
|
+
});
|
|
530
561
|
}
|
|
531
|
-
|
|
562
|
+
}
|
|
563
|
+
void this.runtime.hooks.ActionCompleted.run({
|
|
564
|
+
action,
|
|
565
|
+
input: validated,
|
|
566
|
+
result: rawResult ?? undefined,
|
|
567
|
+
durationMs,
|
|
568
|
+
});
|
|
569
|
+
return rawResult ?? undefined;
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
lastError = err;
|
|
573
|
+
log.error(`handler threw`, {
|
|
574
|
+
action: action.name,
|
|
575
|
+
attempt,
|
|
576
|
+
maxAttempts,
|
|
577
|
+
error: err?.message,
|
|
578
|
+
});
|
|
579
|
+
this.runtime.pushTelemetry({
|
|
580
|
+
kind: "action.failed",
|
|
581
|
+
action: action.name,
|
|
582
|
+
attempt,
|
|
583
|
+
maxAttempts,
|
|
584
|
+
willRetry: attempt < maxAttempts,
|
|
585
|
+
error: serializeError(err),
|
|
586
|
+
envelope,
|
|
587
|
+
appName,
|
|
588
|
+
ts: new Date().toISOString(),
|
|
589
|
+
});
|
|
590
|
+
if (attempt >= maxAttempts) {
|
|
591
|
+
void this.runtime.hooks.ActionFailed.run({
|
|
532
592
|
action,
|
|
533
593
|
input: validated,
|
|
534
|
-
|
|
535
|
-
durationMs,
|
|
594
|
+
error: err,
|
|
595
|
+
durationMs: performance.now() - startedAt,
|
|
536
596
|
});
|
|
537
|
-
return rawResult ?? undefined;
|
|
538
|
-
}
|
|
539
|
-
catch (err) {
|
|
540
|
-
lastError = err;
|
|
541
|
-
log.error(`handler threw`, {
|
|
542
|
-
action: action.name,
|
|
543
|
-
attempt,
|
|
544
|
-
maxAttempts,
|
|
545
|
-
error: err?.message,
|
|
546
|
-
});
|
|
547
|
-
this.runtime.pushTelemetry({
|
|
548
|
-
kind: "action.failed",
|
|
549
|
-
action: action.name,
|
|
550
|
-
attempt,
|
|
551
|
-
maxAttempts,
|
|
552
|
-
willRetry: attempt < maxAttempts,
|
|
553
|
-
error: serializeError(err),
|
|
554
|
-
envelope,
|
|
555
|
-
appName,
|
|
556
|
-
ts: new Date().toISOString(),
|
|
557
|
-
});
|
|
558
|
-
if (attempt >= maxAttempts) {
|
|
559
|
-
void this.runtime.hooks.ActionFailed.run({
|
|
560
|
-
action,
|
|
561
|
-
input: validated,
|
|
562
|
-
error: err,
|
|
563
|
-
durationMs: performance.now() - startedAt,
|
|
564
|
-
});
|
|
565
|
-
}
|
|
566
597
|
}
|
|
567
598
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const hctx = { action, input: validated, ctx, coreFn: core };
|
|
586
|
-
await this.runtime.dispatchHook$.run(hctx);
|
|
587
|
-
return hctx.result;
|
|
599
|
+
}
|
|
600
|
+
const entry = buildDeadLetterEntry(action.name, validated, envelope, attempt, lastError);
|
|
601
|
+
await this.deadLetterSink.record(entry);
|
|
602
|
+
log.error(`dead-lettered after ${attempt} attempts`, {
|
|
603
|
+
action: action.name,
|
|
604
|
+
error: entry.lastError.message,
|
|
605
|
+
});
|
|
606
|
+
this.runtime.pushTelemetry({
|
|
607
|
+
kind: "dlq.recorded",
|
|
608
|
+
action: action.name,
|
|
609
|
+
attempts: attempt,
|
|
610
|
+
error: serializeError(lastError),
|
|
611
|
+
envelope,
|
|
612
|
+
appName,
|
|
613
|
+
ts: new Date().toISOString(),
|
|
614
|
+
});
|
|
615
|
+
throw lastError;
|
|
588
616
|
}
|
|
589
|
-
// ─── Publish
|
|
617
|
+
// ─── Publish ───────────────────────────────────────────────────────
|
|
590
618
|
/**
|
|
591
619
|
* Publish a batch of events. Each event flows through the
|
|
592
|
-
* `
|
|
620
|
+
* `LocalDelivery` hook chain — idempotency → actors → projections →
|
|
593
621
|
* workflows → bus — at structurally enforced priorities. The chain
|
|
594
622
|
* participants are attached once at plugin setup via
|
|
595
623
|
* {@link attachPublishChain}.
|
|
@@ -603,14 +631,10 @@ export class ForgeDispatcher {
|
|
|
603
631
|
const event = events[i];
|
|
604
632
|
const dedupKey = events.length === 1 ? parentEnvelope.messageId : `${parentEnvelope.messageId}#${i}`;
|
|
605
633
|
const childEnvelope = deriveEnvelope(parentEnvelope);
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
deduped: false,
|
|
611
|
-
};
|
|
612
|
-
await this.runtime.hooks.EventPublishing.run(payload);
|
|
613
|
-
if (payload.deduped) {
|
|
634
|
+
// Local delivery (incoming-to-all): the LocalDelivery fold chain
|
|
635
|
+
// (idempotency → actors → projections → workflows) + core listeners.
|
|
636
|
+
const { deduped } = await this.runtime.deliver(event.eventName, event.payload, childEnvelope, dedupKey);
|
|
637
|
+
if (deduped) {
|
|
614
638
|
this.runtime.pushTelemetry({
|
|
615
639
|
kind: "event.deduped",
|
|
616
640
|
event,
|
|
@@ -619,108 +643,79 @@ export class ForgeDispatcher {
|
|
|
619
643
|
appName,
|
|
620
644
|
ts: new Date().toISOString(),
|
|
621
645
|
});
|
|
646
|
+
continue;
|
|
622
647
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
648
|
+
// Outbound (publish = outgoing only): cross-process bus + sink drain
|
|
649
|
+
// for public, non-deduped events. Moved OUT of the local chain so
|
|
650
|
+
// local `emit` / `publishCapability` never double-drain (trap b), and
|
|
651
|
+
// a dedup veto skips outbound (trap c).
|
|
652
|
+
if (this.publicEventNames.has(event.eventName)) {
|
|
653
|
+
if (this.publishToBus && this.bus) {
|
|
654
|
+
await this.bus.publish({
|
|
655
|
+
eventName: event.eventName,
|
|
656
|
+
payload: event.payload,
|
|
657
|
+
envelope: childEnvelope,
|
|
658
|
+
origin: this.runtime.appName,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
const eventRef = { name: event.eventName };
|
|
662
|
+
await this.runtime.sinkDrain(eventRef, event.payload, childEnvelope);
|
|
632
663
|
}
|
|
664
|
+
this.runtime.pushTelemetry({
|
|
665
|
+
kind: "event.published",
|
|
666
|
+
event,
|
|
667
|
+
envelope: childEnvelope,
|
|
668
|
+
source: "in-process",
|
|
669
|
+
appName,
|
|
670
|
+
ts: new Date().toISOString(),
|
|
671
|
+
});
|
|
633
672
|
}
|
|
634
673
|
}
|
|
635
674
|
/**
|
|
636
|
-
* Attach forge's
|
|
675
|
+
* Attach forge's bundled fold steps to the core `LocalDelivery` hook.
|
|
637
676
|
* Priority slots are defined in `EVENT_PUBLISHING_PRIORITIES`:
|
|
638
677
|
*
|
|
639
|
-
* 1000 — idempotency gate. Short-circuits on duplicate.
|
|
678
|
+
* 1000 — idempotency gate. Short-circuits (vetoes) on duplicate.
|
|
640
679
|
* 800 — actor state transitions.
|
|
641
680
|
* 600 — projection folds.
|
|
642
681
|
* 400 — workflow correlation + fire.
|
|
643
|
-
* 200 — cross-process bus delivery (public events only).
|
|
644
|
-
* 100 — outbound sink drain (public events only) — feeds
|
|
645
|
-
* adopters that install via `installSinkStage` (bullmq,
|
|
646
|
-
* AMQP, telemetry-otel, …).
|
|
647
682
|
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
683
|
+
* Cross-process bus delivery and the outbound sink drain are NOT chain
|
|
684
|
+
* steps — they're the outgoing `publish` concern, applied by
|
|
685
|
+
* `dispatcher.publish` after `runtime.deliver` for public, non-deduped
|
|
686
|
+
* events. Keeping them out of the local chain means local `emit` (and
|
|
687
|
+
* `publishCapability`) never double-drain.
|
|
688
|
+
*
|
|
689
|
+
* Called once at plugin setup (bundled mode). NOTE: the hook engine does
|
|
690
|
+
* NOT dedup step names — bundled (`createForgePlugin`) and à-la-carte (the
|
|
691
|
+
* sub-plugins) are mutually exclusive; mixing them double-registers a fold
|
|
692
|
+
* step. Each sub-plugin warns at `AppReady` if it detects a duplicate
|
|
693
|
+
* (the `AppReady` hook fires `.on` listeners via `allSettled`, so a thrown
|
|
694
|
+
* guard would be swallowed — a hard-fail needs a registration-time check;
|
|
695
|
+
* tracked as a follow-up).
|
|
650
696
|
*/
|
|
651
697
|
attachPublishChain() {
|
|
652
|
-
const hook = this.runtime.hooks.
|
|
698
|
+
const hook = this.runtime.hooks.LocalDelivery;
|
|
653
699
|
hook.use(async (payload, next) => {
|
|
654
700
|
const isNew = await this.idempotencyStore.recordIfNew(payload.dedupKey);
|
|
655
701
|
if (!isNew) {
|
|
656
702
|
payload.deduped = true;
|
|
657
|
-
return; // veto — downstream steps skipped
|
|
703
|
+
return; // veto — downstream steps + listeners skipped
|
|
658
704
|
}
|
|
659
705
|
await next();
|
|
660
706
|
}, { name: "forge.publish.idempotency", priority: EVENT_PUBLISHING_PRIORITIES.idempotency });
|
|
661
707
|
hook.use(async (payload, next) => {
|
|
662
|
-
await this.applyToActors(payload.
|
|
708
|
+
await this.applyToActors({ eventName: payload.eventName, payload: payload.payload }, payload.envelope);
|
|
663
709
|
await next();
|
|
664
710
|
}, { name: "forge.publish.actors", priority: EVENT_PUBLISHING_PRIORITIES.actors });
|
|
665
711
|
hook.use(async (payload, next) => {
|
|
666
|
-
await this.foldProjections(payload.
|
|
712
|
+
await this.foldProjections({ eventName: payload.eventName, payload: payload.payload }, payload.envelope);
|
|
667
713
|
await next();
|
|
668
714
|
}, { name: "forge.publish.projections", priority: EVENT_PUBLISHING_PRIORITIES.projections });
|
|
669
715
|
hook.use(async (payload, next) => {
|
|
670
|
-
await this.runWorkflows(payload.
|
|
716
|
+
await this.runWorkflows({ eventName: payload.eventName, payload: payload.payload }, payload.envelope);
|
|
671
717
|
await next();
|
|
672
718
|
}, { name: "forge.publish.workflows", priority: EVENT_PUBLISHING_PRIORITIES.workflows });
|
|
673
|
-
hook.use(async (payload, next) => {
|
|
674
|
-
if (this.publishToBus && this.bus && this.publicEventNames.has(payload.event.eventName)) {
|
|
675
|
-
await this.bus.publish({
|
|
676
|
-
eventName: payload.event.eventName,
|
|
677
|
-
payload: payload.event.payload,
|
|
678
|
-
envelope: payload.envelope,
|
|
679
|
-
origin: this.runtime.appName,
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
await next();
|
|
683
|
-
}, { name: "forge.publish.bus", priority: EVENT_PUBLISHING_PRIORITIES.bus });
|
|
684
|
-
hook.use(async (payload, next) => {
|
|
685
|
-
if (this.publicEventNames.has(payload.event.eventName)) {
|
|
686
|
-
// Outbound sink chain — adopters like bullmq, AMQP, telemetry-otel
|
|
687
|
-
// install terminal stages via `installSinkStage`. `sinkDrain`
|
|
688
|
-
// reads `.name` off the event; the schema is not consulted here.
|
|
689
|
-
const eventRef = { name: payload.event.eventName };
|
|
690
|
-
await this.runtime.sinkDrain(eventRef, payload.event.payload, payload.envelope);
|
|
691
|
-
}
|
|
692
|
-
await next();
|
|
693
|
-
}, { name: "forge.publish.outbound", priority: EVENT_PUBLISHING_PRIORITIES.outbound });
|
|
694
|
-
}
|
|
695
|
-
async applyExternalEvent(eventName, payload, envelope) {
|
|
696
|
-
if (!this.externalEventNames.has(eventName)) {
|
|
697
|
-
throw new Error(`Runtime.applyExternalEvent: "${eventName}" was not declared in any module's needs.externalEvents.`);
|
|
698
|
-
}
|
|
699
|
-
const appName = this.runtime.appName;
|
|
700
|
-
const event = { eventName, payload };
|
|
701
|
-
const isNew = await this.idempotencyStore.recordIfNew(envelope.messageId);
|
|
702
|
-
if (!isNew) {
|
|
703
|
-
this.runtime.pushTelemetry({
|
|
704
|
-
kind: "event.deduped",
|
|
705
|
-
event,
|
|
706
|
-
envelope,
|
|
707
|
-
source: "external",
|
|
708
|
-
appName,
|
|
709
|
-
ts: new Date().toISOString(),
|
|
710
|
-
});
|
|
711
|
-
return;
|
|
712
|
-
}
|
|
713
|
-
await this.applyToActors(event, envelope);
|
|
714
|
-
await this.foldProjections(event, envelope);
|
|
715
|
-
await this.runWorkflows(event, envelope);
|
|
716
|
-
this.runtime.pushTelemetry({
|
|
717
|
-
kind: "event.published",
|
|
718
|
-
event,
|
|
719
|
-
envelope,
|
|
720
|
-
source: "external",
|
|
721
|
-
appName,
|
|
722
|
-
ts: new Date().toISOString(),
|
|
723
|
-
});
|
|
724
719
|
}
|
|
725
720
|
// ─── Projection folding ────────────────────────────────────────────
|
|
726
721
|
async foldProjections(event, envelope) {
|
|
@@ -923,14 +918,14 @@ export class ForgeDispatcher {
|
|
|
923
918
|
const log = loggerForEnvelope(this.runtime.logger, envelope).child({
|
|
924
919
|
event: event.eventName,
|
|
925
920
|
});
|
|
926
|
-
const handlerCtx = this.buildHandlerContext(envelope, log);
|
|
927
921
|
const self = this;
|
|
922
|
+
const container = this.runtime.getContainer();
|
|
928
923
|
const baseEffects = {
|
|
929
924
|
async send(action, input) {
|
|
930
|
-
return
|
|
925
|
+
return self.dispatch(action, input, envelope);
|
|
931
926
|
},
|
|
932
927
|
async enqueue(action, input) {
|
|
933
|
-
await
|
|
928
|
+
await self.dispatch(action, input, envelope);
|
|
934
929
|
},
|
|
935
930
|
async publish(eventMsg) {
|
|
936
931
|
await self.publish([eventMsg], envelope);
|
|
@@ -951,7 +946,7 @@ export class ForgeDispatcher {
|
|
|
951
946
|
// event is .public() and a sink is installed. Built via the event
|
|
952
947
|
// factory so messageId / correlation / idempotency hold.
|
|
953
948
|
emit: (eventDef, payload) => self.publish([eventFactory(eventDef)(payload)], envelope),
|
|
954
|
-
resolve: (name) =>
|
|
949
|
+
resolve: (name) => container.resolve(name),
|
|
955
950
|
logger: log,
|
|
956
951
|
load: (key) => store.get(key),
|
|
957
952
|
save: (key, instance) => store.set(key, instance),
|
|
@@ -1065,49 +1060,15 @@ export class ForgeDispatcher {
|
|
|
1065
1060
|
}
|
|
1066
1061
|
}
|
|
1067
1062
|
}
|
|
1068
|
-
// ─── Handler context (internal) ────────────────────────────────────
|
|
1069
|
-
buildHandlerContext(envelope, log, signal) {
|
|
1070
|
-
const self = this;
|
|
1071
|
-
const container = this.runtime.getContainer();
|
|
1072
|
-
const logger = log ?? loggerForEnvelope(this.runtime.logger, envelope);
|
|
1073
|
-
const ctxSignal = signal ?? new AbortController().signal;
|
|
1074
|
-
const ctx = {
|
|
1075
|
-
container,
|
|
1076
|
-
envelope,
|
|
1077
|
-
logger,
|
|
1078
|
-
signal: ctxSignal,
|
|
1079
|
-
resolve(name) {
|
|
1080
|
-
return container.resolve(name);
|
|
1081
|
-
},
|
|
1082
|
-
get requestId() {
|
|
1083
|
-
return envelope.messageId;
|
|
1084
|
-
},
|
|
1085
|
-
async request(action, input) {
|
|
1086
|
-
return self.dispatch(action, input, envelope, { signal: ctxSignal });
|
|
1087
|
-
},
|
|
1088
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1089
|
-
async query(
|
|
1090
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1091
|
-
queryDef, input) {
|
|
1092
|
-
return self.query(queryDef.name, input, envelope.tenant ?? "");
|
|
1093
|
-
},
|
|
1094
|
-
async send(action, input) {
|
|
1095
|
-
await self.dispatch(action, input, envelope, { signal: ctxSignal });
|
|
1096
|
-
},
|
|
1097
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1098
|
-
async use(actor, id) {
|
|
1099
|
-
return self.loadActorView(actor, id, envelope);
|
|
1100
|
-
},
|
|
1101
|
-
async externalCall(call, request) {
|
|
1102
|
-
return self.executeExternalCall(call, request, envelope);
|
|
1103
|
-
},
|
|
1104
|
-
};
|
|
1105
|
-
return ctx;
|
|
1106
|
-
}
|
|
1107
1063
|
// ─── Actor view (ctx.use) ──────────────────────────────────────────
|
|
1064
|
+
/** Public actor-view accessor — backs the `actor(actor, id)` ctx member. */
|
|
1065
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1066
|
+
actorView(actor, id, envelope) {
|
|
1067
|
+
return this.loadActorView(actor, id, envelope);
|
|
1068
|
+
}
|
|
1108
1069
|
async loadActorView(actor, id, envelope) {
|
|
1109
1070
|
if (!this.actors.has(actor.name)) {
|
|
1110
|
-
throw new Error(`
|
|
1071
|
+
throw new Error(`ctx.actor: actor "${actor.name}" is not registered. ` +
|
|
1111
1072
|
`Add it to a module's manifest.actors and pass that module to createApp.`);
|
|
1112
1073
|
}
|
|
1113
1074
|
const loaded = await this.actorStore.load(actor.name, id, envelope.tenant ?? "");
|
|
@@ -1166,3 +1127,4 @@ export class ForgeDispatcher {
|
|
|
1166
1127
|
return this.deadLetterSink;
|
|
1167
1128
|
}
|
|
1168
1129
|
}
|
|
1130
|
+
/** Local alias for the dispatch-hook ctx shape; lifted into kernel.Runtime. */
|
|
@@ -53,6 +53,14 @@ export interface ForgePluginOptions extends ForgeDispatcherOptions {
|
|
|
53
53
|
readonly queries?: readonly QueryDefinition<any, any, any>[];
|
|
54
54
|
readonly workflows?: readonly WorkflowDefinition[];
|
|
55
55
|
readonly externalCalls?: readonly ExternalCallDefinition<any, any>[];
|
|
56
|
+
/**
|
|
57
|
+
* Event names this app accepts from the bus (the inbound counterpart to
|
|
58
|
+
* `publishToBus`). When a `bus` is configured, each declared name is
|
|
59
|
+
* subscribed at boot and folded through `runtime.receive` — the same source
|
|
60
|
+
* chain + delivery path local events use. An app's own published echoes are
|
|
61
|
+
* skipped by origin.
|
|
62
|
+
*/
|
|
63
|
+
readonly externalEvents?: readonly string[];
|
|
56
64
|
}
|
|
57
65
|
/**
|
|
58
66
|
* Construct a forge plugin with custom stores, bus, and/or domain
|