@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.
Files changed (53) hide show
  1. package/README.md +100 -83
  2. package/dist/framework-events.d.ts +8 -37
  3. package/dist/framework-events.js +7 -3
  4. package/dist/helpers/cli-runner.js +21 -10
  5. package/dist/index.d.ts +8 -7
  6. package/dist/index.js +7 -6
  7. package/dist/plugins/actions-chain.d.ts +39 -22
  8. package/dist/plugins/actions-chain.js +117 -78
  9. package/dist/plugins/actions-plugin.d.ts +26 -23
  10. package/dist/plugins/actions-plugin.js +122 -44
  11. package/dist/plugins/actors-chain.d.ts +9 -2
  12. package/dist/plugins/actors-chain.js +62 -2
  13. package/dist/plugins/actors-plugin.d.ts +1 -1
  14. package/dist/plugins/actors-plugin.js +24 -14
  15. package/dist/plugins/external-calls-plugin.d.ts +28 -0
  16. package/dist/plugins/external-calls-plugin.js +136 -0
  17. package/dist/plugins/idempotency-plugin.d.ts +15 -1
  18. package/dist/plugins/idempotency-plugin.js +56 -11
  19. package/dist/plugins/projections-chain.d.ts +2 -2
  20. package/dist/plugins/projections-chain.js +2 -2
  21. package/dist/plugins/projections-plugin.d.ts +1 -1
  22. package/dist/plugins/projections-plugin.js +4 -13
  23. package/dist/plugins/queries-chain.d.ts +4 -3
  24. package/dist/plugins/queries-chain.js +8 -5
  25. package/dist/plugins/queries-plugin.d.ts +15 -29
  26. package/dist/plugins/queries-plugin.js +36 -49
  27. package/dist/plugins/workflows-chain.d.ts +9 -2
  28. package/dist/plugins/workflows-chain.js +19 -1
  29. package/dist/plugins/workflows-plugin.d.ts +1 -1
  30. package/dist/plugins/workflows-plugin.js +12 -20
  31. package/dist/primitives/define-action.d.ts +80 -115
  32. package/dist/primitives/define-action.js +111 -56
  33. package/dist/primitives/define-actor.d.ts +103 -214
  34. package/dist/primitives/define-actor.js +157 -216
  35. package/dist/primitives/define-handler.d.ts +42 -112
  36. package/dist/primitives/define-handler.js +14 -45
  37. package/dist/primitives/define-projection.d.ts +23 -28
  38. package/dist/primitives/define-projection.js +29 -32
  39. package/dist/primitives/define-query.d.ts +52 -42
  40. package/dist/primitives/define-query.js +65 -28
  41. package/dist/primitives/define-workflow.d.ts +8 -11
  42. package/dist/primitives/define-workflow.js +14 -8
  43. package/dist/runtime/forge-dispatcher.d.ts +30 -12
  44. package/dist/runtime/forge-dispatcher.js +199 -237
  45. package/dist/runtime/forge-plugin.d.ts +8 -0
  46. package/dist/runtime/forge-plugin.js +113 -31
  47. package/dist/runtime/forge-plugins.d.ts +55 -0
  48. package/dist/runtime/forge-plugins.js +57 -0
  49. package/dist/runtime/forge-types.d.ts +8 -2
  50. package/dist/runtime/with-forge.d.ts +8 -11
  51. package/dist/runtime/with-forge.js +9 -11
  52. package/dist/stores/idempotency-store.d.ts +1 -1
  53. package/package.json +12 -12
@@ -1,170 +1,180 @@
1
1
  /**
2
2
  * `defineActor` — the primitive for entities that carry state, transitions,
3
- * and timers. The actor is data + state machine in one declaration.
3
+ * and timers. An actor is a thing with identity that guards an invariant: one
4
+ * instance per id, it owns its state, and it's the only thing that changes it.
4
5
  *
5
- * export const Submission = defineActor('submission', {
6
- * schema: SubmissionSchema,
7
- * key: 'submissionId',
8
- * initial: 'submitted',
9
- * states: {
10
- * submitted: {
11
- * on: {
12
- * [AnswerFlaggedEvent.name]: {
13
- * target: 'under-review',
14
- * assign: (ctx, event) => ({ confidence: event.confidence })
15
- * },
16
- * [AnswerAutoGradedEvent.name]: {
17
- * target: 'graded',
18
- * assign: (ctx, event) => ({
19
- * verdict: event.verdict,
20
- * confidence: event.confidence,
21
- * gradedAt: event.gradedAt
22
- * })
23
- * }
24
- * }
25
- * },
26
- * 'under-review': {
27
- * on: {
28
- * [SubmissionManuallyGradedEvent.name]: {
29
- * target: 'graded',
30
- * assign: (ctx, event) => ({ verdict: event.verdict })
31
- * }
32
- * },
33
- * after: {
34
- * '3d': sendReviewReminder
35
- * }
36
- * },
37
- * graded: { final: true }
38
- * }
39
- * })
6
+ * Same builder shape as `defineWorkflow` — a closure with the one event verb
7
+ * `when` — plus identity + durability. State, key, and lifecycle come from a
8
+ * `defineSchema`; the closure declares the transitions:
40
9
  *
41
- * See:
42
- * - `__docs__/framework/foundation-v3-spec.md` design rationale
43
- * - `ADR-006-actors-vs-reactions.md` when to use defineActor vs `when`
44
- * - `ADR-007-declarative-state-via-assign.md` why `assign` returns partials
10
+ * export const Submission = defineActor("submission",
11
+ * ({ data, id, validate, recordThat, states, when, after }) => {
12
+ * const { submitted, underReview, graded } = states; // callable state bodies
13
+ * const flag = (reason: string) => { validate(data, [isSubmitted]); recordThat(Flagged({ id, reason })); };
14
+ * submitted(() => {
15
+ * when(AnswerWasSubmitted, (e, { assign }) => { assign({ ...e }); }); // stay
16
+ * when(SubmissionWasAutoGraded, (e, { assign }) => { assign({ ...e }); return graded; });
17
+ * when(SubmissionWasFlagged, () => underReview);
18
+ * });
19
+ * when(SubmissionDeleted, () => deleted); // top-level = always-listener
20
+ * underReview(() => after("review-reminder", "3d", sendReviewReminder));
21
+ * return { flag }; // methods: a plain object from the closure
22
+ * },
23
+ * { schema: SubmissionData, stuckThresholds, slas },
24
+ * );
25
+ *
26
+ * A `when` at the closure top is active in every state; a `when` inside a state
27
+ * body fires only in that state; the state-scoped one wins. A `when` returns a
28
+ * state to transition (nothing to stay); `ctx.assign(patch)` folds the actor's
29
+ * data. Identical to a workflow reaction — the difference is identity (keyed by
30
+ * the schema's `key`) and durability (an actor always persists).
31
+ *
32
+ * Default lifecycle: an actor always has a terminal. If the schema declares no
33
+ * `final` state, `deleted` (final) is injected — a free soft-delete terminal.
34
+ *
35
+ * `assign` vs `methods`: `assign(patch)` is a pure data fold during a
36
+ * transition. `methods` (in the options) are pure invariant-enforcers called
37
+ * from a handler via `ctx.actor(Actor, id)` — they receive state, return an
38
+ * event (or throw), and never do I/O.
45
39
  */
46
- import { captureSourceLocation } from "@nwire/messages";
47
- import { isSchemaDefinition } from "./define-schema.js";
48
- export function defineActor(
49
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
- ...args) {
51
- const $source = captureSourceLocation();
52
- // Three signatures:
53
- // (name: string, options: ActorOptions) → classic
54
- // (options: ActorOptionsBound) → schema-bound object form
55
- // (schema: SchemaDefinition, body: (ctx) => methods, options?: {...}) → closure form
56
- const [first, second, third] = args;
57
- const attach = (def) => Object.defineProperty(def, "$source", {
58
- value: $source,
59
- enumerable: false,
60
- configurable: true,
61
- });
62
- // Closure form: first arg is a SchemaDefinition, second is a function.
63
- if (isSchemaDefinition(first) && typeof second === "function") {
64
- return attach(defineActorClosure(first,
65
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
66
- second, (third ?? {})));
67
- }
68
- // Schema-bound object form: first arg is an options object with a schema field.
69
- if (typeof first === "object" &&
70
- first !== null &&
71
- isSchemaDefinition(first.schema)) {
72
- return attach(defineActorBound(first));
73
- }
74
- // Classic form.
75
- if (typeof first !== "string" || !second) {
76
- throw new Error("defineActor: signatures are (name, options) | (boundOptions) | (schema, closure, options?).");
77
- }
78
- return attach(defineActorClassic(first, second));
79
- }
80
40
  /**
81
- * Closure-form implementation.
82
- *
83
- * Define-time pass: run the body with a recording context. We capture:
84
- * - each `when(Event, fn)` call → one transition per state (initially
85
- * applied to all non-final states; per-state scoping is also supported).
86
- * - the methods object returned (or via `methods(obj)`).
41
+ * Define an actor — one builder closure + a `defineSchema` for the shape.
87
42
  *
88
- * Runtime pass (per `use()` call): the binder re-invokes the body with the
89
- * live state + id, returning the bound methods. `recordThat` calls push to
90
- * an array that `loadActorView` exposes; the runtime publishes them after
91
- * the method returns.
43
+ * The closure runs once at define time (stub ctx) to collect transitions
44
+ * (`when`) and timers (`after`), with state bodies scoping them; the runtime
45
+ * folds events through `actors-chain` (locking, OCC, persistence, the
46
+ * transition hook). Methods are defined in the closure and returned as a plain
47
+ * object — `return { create, update }` — and bound over the live instance per
48
+ * `ctx.actor(Actor, id)` (no `methods()` wrapper, no static methods option).
92
49
  */
93
- function defineActorClosure(schema, body, options) {
94
- const name = options.name ?? schema.name;
95
- const schemaStates = schema.states;
96
- // Build state refs once same identity across all closure invocations.
50
+ export function defineActor(name, body, options) {
51
+ const schema = options.schema;
52
+ // ── State set: the schema's states, plus an injected `deleted` terminal
53
+ // when the schema declares no `final` state (free soft-delete lifecycle).
54
+ const stateSpecs = { ...schema.states };
55
+ if (schema.finals.length === 0) {
56
+ stateSpecs.deleted = { final: true };
57
+ }
58
+ // ── Callable state bodies. Calling `x(() => …)` scopes the `when`s inside
59
+ // to "x"; returning `x` from a reaction transitions there.
60
+ let currentScope;
97
61
  const stateRefs = {};
98
- for (const stateName of Object.keys(schemaStates)) {
99
- stateRefs[stateName] = Object.freeze({ $kind: "state-ref", name: stateName });
100
- // Provide camelCase alias for kebab-case state names: "under-review" → underReview
62
+ const makeStateRef = (stateName) => {
63
+ const ref = ((bodyFn) => {
64
+ const prev = currentScope;
65
+ currentScope = stateName;
66
+ try {
67
+ bodyFn();
68
+ }
69
+ finally {
70
+ currentScope = prev;
71
+ }
72
+ });
73
+ Object.defineProperties(ref, {
74
+ $kind: { value: "state-ref", enumerable: true },
75
+ name: { value: stateName, enumerable: true, configurable: true },
76
+ });
77
+ return ref;
78
+ };
79
+ for (const stateName of Object.keys(stateSpecs)) {
80
+ stateRefs[stateName] = makeStateRef(stateName);
81
+ // camelCase alias for kebab names: "under-review" → underReview
101
82
  const camel = stateName.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
102
83
  if (camel !== stateName)
103
84
  stateRefs[camel] = stateRefs[stateName];
104
85
  }
105
86
  const captured = [];
106
- const defineTimeCtx = {
107
- data: new Proxy({}, {
108
- get: () => undefined,
109
- has: () => false,
110
- }),
111
- id: "",
112
- validate: () => { },
113
- recordThat: () => { },
87
+ const timers = [];
88
+ // Live data view `buildReaction` points `liveData` at the instance's
89
+ // current data before running a reaction, so `data.x` reads the right value.
90
+ let liveData = {};
91
+ const dataProxy = new Proxy({}, {
92
+ get: (_t, p) => liveData[p],
93
+ has: (_t, p) => p in liveData,
94
+ });
95
+ const ctx = {
96
+ data: dataProxy,
97
+ id: "", // placeholder at define time; live in the per-use binder below
98
+ validate: () => { }, // no-op at define time
99
+ recordThat: () => { }, // no-op at define time
114
100
  states: stateRefs,
115
101
  when: (event, fn) => {
116
102
  captured.push({
117
103
  eventName: event.name,
104
+ scope: currentScope,
118
105
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
119
106
  fn: fn,
120
107
  });
121
108
  },
122
- methods: (obj) => obj,
109
+ after: (timerName, delay, action) => {
110
+ const actionName = typeof action === "string" ? action : action.name;
111
+ timers.push({ scope: currentScope, name: timerName, spec: { delay, action: actionName } });
112
+ },
113
+ };
114
+ // Define-time pass: collects when/after (above) and the method names from the
115
+ // returned object. The returned method bodies are discarded here — they bind
116
+ // over live state in `closureBinder` below.
117
+ body(ctx);
118
+ // Final states are absorbing — a reaction scoped to one is a mistake.
119
+ for (const c of captured) {
120
+ if (c.scope && stateSpecs[c.scope]?.final) {
121
+ throw new Error(`defineActor("${name}"): state "${c.scope}" is final and cannot declare event reactions.`);
122
+ }
123
+ }
124
+ // ── BUILD the runtime state machine. For each non-final state, lay down the
125
+ // always-listeners first, then let state-scoped reactions override
126
+ // (most-specific wins). Final states get no reactions.
127
+ const buildReaction = (fn) => {
128
+ let target;
129
+ let delta = {};
130
+ return {
131
+ assign: (state, event) => {
132
+ target = undefined;
133
+ delta = {};
134
+ liveData = state;
135
+ const result = fn(event, {
136
+ assign: (d) => {
137
+ delta = { ...delta, ...d };
138
+ },
139
+ });
140
+ if (result &&
141
+ typeof result === "object" &&
142
+ result.$kind === "state-ref") {
143
+ target = result.name;
144
+ }
145
+ return delta;
146
+ },
147
+ get target() {
148
+ return target;
149
+ },
150
+ };
123
151
  };
124
- const defineTimeReturn = body(defineTimeCtx) ?? {};
125
- const methodNames = Object.keys(defineTimeReturn);
126
- // ── BUILD ACTOR STATE MACHINE ────────────────────────────────────
127
- // Each captured `when(Event, fn)` becomes a reaction entry in every
128
- // non-final state. The reaction's `assign` runs the fn against a buffer;
129
- // its `target` is set if the fn returned a state ref.
130
152
  const mergedStates = {};
131
- for (const [stateName, stateSpec] of Object.entries(schemaStates)) {
132
- if (stateSpec.final) {
153
+ for (const [stateName, spec] of Object.entries(stateSpecs)) {
154
+ if (spec.final) {
133
155
  mergedStates[stateName] = { final: true };
134
156
  continue;
135
157
  }
136
- const onEntries = {};
137
- for (const { eventName, fn } of captured) {
138
- // If multiple `when()` for the same event, only the last wins per
139
- // state. (Closure-form de-duplication semantics; document this.)
140
- let target;
141
- let delta = {};
142
- onEntries[eventName] = {
143
- assign: (_currentState, event) => {
144
- target = undefined;
145
- delta = {};
146
- const transCtx = {
147
- assign: (d) => {
148
- delta = { ...delta, ...d };
149
- },
150
- };
151
- const result = fn(event, transCtx);
152
- if (result &&
153
- typeof result === "object" &&
154
- "$kind" in result &&
155
- result.$kind === "state-ref") {
156
- target = result.name;
157
- }
158
- return delta;
159
- },
160
- get target() {
161
- return target;
162
- },
163
- };
164
- }
165
- mergedStates[stateName] = { on: onEntries };
158
+ const on = {};
159
+ for (const c of captured)
160
+ if (c.scope === undefined)
161
+ on[c.eventName] = buildReaction(c.fn);
162
+ for (const c of captured)
163
+ if (c.scope === stateName)
164
+ on[c.eventName] = buildReaction(c.fn);
165
+ const after = {};
166
+ for (const t of timers)
167
+ if (t.scope === stateName)
168
+ after[t.name] = t.spec;
169
+ mergedStates[stateName] = {
170
+ on,
171
+ ...(Object.keys(after).length > 0 ? { after } : {}),
172
+ };
166
173
  }
167
- // ── PER-USE BINDER — re-invoke body with live state + id ─────────
174
+ // ── PER-USE BINDER — re-invoke the closure with live state + id so methods
175
+ // bind over real values. `validate` enforces invariants; `recordThat`
176
+ // buffers events the runtime collects after the method returns. `when` /
177
+ // `after` are no-ops here (transitions were collected at define time).
168
178
  const closureBinder = (state, id) => {
169
179
  const recorded = [];
170
180
  const liveCtx = {
@@ -189,93 +199,28 @@ function defineActorClosure(schema, body, options) {
189
199
  },
190
200
  states: stateRefs,
191
201
  when: () => { }, // no-op at runtime
192
- methods: (obj) => obj,
202
+ after: () => { }, // no-op at runtime
193
203
  };
194
- const methodsObj = (body(liveCtx) ?? {});
195
- return { methods: methodsObj, recorded };
204
+ const methods = (body(liveCtx) ?? {});
205
+ return { methods, recorded };
196
206
  };
197
- // Runtime schema = partial of the schema's zodSchema, like schema-bound form.
207
+ // Actor instances accumulate state across events every field present-or-
208
+ // pending — so the runtime parser is the schema's `.partial()`.
198
209
  const runtimeSchema = schema.zodSchema.partial();
199
- return {
210
+ const def = {
200
211
  $kind: "actor",
201
212
  name,
202
213
  schema: runtimeSchema,
203
214
  key: schema.key,
204
215
  initial: schema.initial,
205
216
  states: mergedStates,
206
- // No object-form methods — runtime detects closureBinder and uses it.
207
217
  methods: undefined,
208
- stuckThresholds: options.stuckThresholds,
209
- slas: options.slas,
210
- eventIndex: buildEventIndex(mergedStates),
211
218
  closureBinder,
212
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
213
- };
214
- }
215
- function defineActorClassic(name, options) {
216
- if (!options.states[options.initial]) {
217
- throw new Error(`defineActor("${name}"): initial state "${options.initial}" is not declared in states.`);
218
- }
219
- for (const [stateName, stateConfig] of Object.entries(options.states)) {
220
- if (stateConfig.on && stateConfig.final) {
221
- throw new Error(`defineActor("${name}"): final state "${stateName}" must not declare event reactions (final states are absorbing).`);
222
- }
223
- }
224
- return {
225
- $kind: "actor",
226
- name,
227
- schema: options.schema,
228
- key: options.key,
229
- initial: options.initial,
230
- states: options.states,
231
- methods: options.methods,
232
- stuckThresholds: options.stuckThresholds,
233
- slas: options.slas,
234
- eventIndex: buildEventIndex(options.states),
235
- };
236
- }
237
- function defineActorBound(options) {
238
- const name = options.name ?? options.schema.name;
239
- const schemaStates = options.schema.states;
240
- const transitions = options.states ?? {};
241
- // Every state we declare transitions for must exist in the schema.
242
- for (const stateName of Object.keys(transitions)) {
243
- if (!(stateName in schemaStates)) {
244
- throw new Error(`defineActor("${name}"): state "${stateName}" is not declared in schema "${options.schema.name}".states.`);
245
- }
246
- if (schemaStates[stateName]?.final && transitions[stateName]?.on) {
247
- throw new Error(`defineActor("${name}"): state "${stateName}" is final in schema "${options.schema.name}" and cannot declare \`on:\` reactions.`);
248
- }
249
- }
250
- // Build the merged states map: every schema state appears, with the actor's
251
- // transitions overlaid + `final: true` propagated from the schema.
252
- const mergedStates = {};
253
- for (const [stateName, stateSpec] of Object.entries(schemaStates)) {
254
- const t = transitions[stateName];
255
- mergedStates[stateName] = {
256
- ...(t ?? {}),
257
- ...(stateSpec.final ? { final: true } : {}),
258
- };
259
- }
260
- // Actor instances accumulate state across events — every field is
261
- // present-or-pending. `.partial()` keeps each field's *type* validation
262
- // (a string field still rejects a number) but lets the object be empty
263
- // mid-lifecycle, matching how the classic positional form is used.
264
- // The static type stays z.ZodObject<TFields> (what callers see); the
265
- // runtime parser is the partial.
266
- const runtimeSchema = options.schema.zodSchema.partial();
267
- return {
268
- $kind: "actor",
269
- name,
270
- schema: runtimeSchema,
271
- key: options.schema.key,
272
- initial: options.schema.initial,
273
- states: mergedStates,
274
- methods: options.methods,
275
219
  stuckThresholds: options.stuckThresholds,
276
220
  slas: options.slas,
277
221
  eventIndex: buildEventIndex(mergedStates),
278
222
  };
223
+ return def;
279
224
  }
280
225
  function buildEventIndex(states) {
281
226
  const eventIndex = new Map();
@@ -290,11 +235,7 @@ function buildEventIndex(states) {
290
235
  }
291
236
  return eventIndex;
292
237
  }
293
- /**
294
- * Helper: turn an `EventDefinition` reference into a string suitable for the
295
- * `on` map key. Equivalent to `Event.name` but reads as `eventKey(Event)` at
296
- * call sites where the import is shorter.
297
- */
238
+ /** Helper: `Event.name` as a string. Reads as `eventKey(Event)` at call sites. */
298
239
  export function eventKey(event) {
299
240
  return event.name;
300
241
  }
@@ -1,35 +1,26 @@
1
1
  /**
2
- * `defineHandler` handlers orchestrate and return events.
2
+ * Handler ctx + return types for forge actions.
3
3
  *
4
- * The handler reads the world (external APIs, cross-module `request`s), decides
5
- * what happened, and returns event(s). It never touches actor state, never
6
- * persists, never knows what state any actor is in. State changes happen
7
- * inside actors, driven by the events the handler returns.
4
+ * A forge action IS a `@nwire/handler` handler (`defineAction =
5
+ * defineHandler.kind("action")`). There is no separate forge handler object:
6
+ * this module only declares the *ctx* a forge handler body receives and what
7
+ * it may *return*. The handler body takes one `ctx` arg (`{ input, ... }`),
8
+ * the same shape as the kernel handler.
8
9
  *
9
- * defineHandler(autoGrade, async (input, { request }) => {
10
- * const grade = await request(evaluateGrade, { ... })
11
- * return grade.confidence >= THRESHOLD
12
- * ? AnswerAutoGradedEvent({ ... })
13
- * : AnswerFlaggedEvent({ ... })
14
- * })
15
- *
16
- * The first argument is the action's `ActionDefinition`. The action carries
17
- * the name + schema; the handler reads input typed against the schema. The
18
- * returned events route to actors + workflows and commit atomically.
19
- *
20
- * If the handler does pure side effects (no events to commit), return `void`.
21
- *
22
- * For CRUD-style handlers that don't emit events but want to surface a small
23
- * record to the caller (e.g. `{ userId }` for the HTTP layer to echo back),
24
- * return a plain object. The runtime treats it as a non-event return: nothing
25
- * is published, and the value flows through `runtime.dispatch`'s return + the
26
- * `ActionCompleted` framework event. Events vs records are disambiguated by
27
- * the `eventName` + `payload` shape — plain objects pass straight through.
10
+ * A handler orchestrates and returns events: it reads the world (external
11
+ * APIs, cross-module `request`s), decides what happened, and returns event(s).
12
+ * It never touches actor state, never persists. State changes happen inside
13
+ * actors, driven by the events the handler returns. For pure side effects
14
+ * (no events to commit) return `void`; for a CRUD record to echo to the
15
+ * caller, return a plain object or a `ResponseInstance` (`created(...)` /
16
+ * `ok(...)`).
28
17
  */
29
18
  import type { z } from "zod";
30
- import type { ZodTypeAny, SourceLocation } from "@nwire/messages";
19
+ import type { ZodTypeAny, EventDefinition, EventPayload } from "@nwire/messages";
31
20
  import type { Container } from "@nwire/container";
32
21
  import type { MessageEnvelope } from "@nwire/envelope";
22
+ import type { MessageRef } from "@nwire/app";
23
+ import type { ResponseInstance } from "@nwire/handler";
33
24
  import type { EventMessage } from "../messages/event-message.js";
34
25
  import type { ActionDefinition, ActionInput, ActionResult } from "./define-action.js";
35
26
  import type { ActorDefinition, ActorInstanceView } from "./define-actor.js";
@@ -42,73 +33,49 @@ export interface HandlerContext {
42
33
  /** Convenience getter — equivalent to `envelope.messageId`. */
43
34
  readonly requestId: string;
44
35
  /**
45
- * Cancellation signal for this dispatch. Linked to the caller (e.g.
46
- * the HTTP request); when the caller closes the connection mid-flight
47
- * the wire layer aborts the controller and this signal flips.
48
- *
49
- * Observation is opt-in — handlers may call `ctx.signal.throwIfAborted()`
50
- * at await points to bail early. The runtime itself checks
51
- * `signal.aborted` between retry attempts and skips remaining retries
52
- * + DLQ when the caller has already given up.
53
- *
54
- * When `runtime.dispatch` is invoked without an explicit `signal`, this
55
- * exposes an `AbortSignal` from a controller the runtime owns — never
56
- * aborted, so existing handlers see the same "always runs to completion"
57
- * behavior. Adding `{ signal }` to the dispatch call is the migration
58
- * point.
36
+ * Cancellation signal for this dispatch. Linked to the caller (HTTP request,
37
+ * queue lock); handlers may call `ctx.signal.throwIfAborted()` at await
38
+ * points. The runtime checks it between retry attempts.
59
39
  */
60
40
  readonly signal: AbortSignal;
61
41
  /** Look up a dependency by name. Shortcut for `container.resolve(name)`. */
62
42
  resolve<T = unknown>(name: string): T;
63
43
  /**
64
- * Logger scoped to this envelope — `messageId`, `correlationId`,
65
- * `causationId`, `tenant`, `userId` are auto-attached to every line.
66
- * Domain code never threads ids manually.
44
+ * Logger scoped to this envelope — ids auto-attached to every line. Domain
45
+ * code never threads ids manually.
67
46
  */
68
47
  readonly logger: Logger;
69
48
  /**
70
49
  * Universal "ask and wait." Calls another action by reference, returns its
71
- * result. The framework derives a child envelope (same correlationId,
72
- * causationId = this handler's messageId) and handles transport routing.
50
+ * result. The framework derives a child envelope and routes transport.
73
51
  */
74
52
  request<A extends ActionDefinition>(action: A, input: ActionInput<A>): Promise<ActionResult<A>>;
75
53
  /**
76
- * Read-during-handler — invoke a query (projection-backed read function)
77
- * scoped to the envelope's tenant. Used by reactions that need to look up
78
- * state to decide what action to dispatch.
79
- *
80
- * Per ADR-008, queries always read projections, never actor stores. Per
81
- * ADR-007, handlers stay pure of state mutation; reads via `query` are the
82
- * sanctioned way to inform a handler's decision.
54
+ * Read-during-handler — invoke a query (projection-backed read) scoped to the
55
+ * envelope's tenant.
83
56
  */
84
57
  query<TResult = unknown>(query: QueryDefinition<any, any, any>, input: unknown): Promise<TResult>;
85
58
  /**
86
- * Fire-and-forget dispatch of another action. Returns when the message is
87
- * accepted by the transport, not when the handler completes.
59
+ * Fire-and-forget dispatch of another action. Returns a {@link MessageRef}
60
+ * naming the dispatched message as soon as it is accepted.
88
61
  */
89
- send<A extends ActionDefinition>(action: A, input: ActionInput<A>): Promise<void>;
62
+ send<A extends ActionDefinition>(action: A, input: ActionInput<A>): Promise<MessageRef>;
63
+ /**
64
+ * Emit a domain event as a side-effect of this handler, in addition to
65
+ * whatever the handler returns. Publishes through the forge chain under this
66
+ * handler's envelope.
67
+ */
68
+ emit<E extends EventDefinition>(event: E, payload: EventPayload<E>): Promise<void>;
90
69
  /**
91
70
  * Load an actor instance by id (envelope.tenant-scoped) and return a view
92
71
  * with `state`, `stateName`, `key`, plus every method declared on the actor
93
- * pre-bound to the loaded state. The view is a snapshot — subsequent
94
- * dispatches do not refresh it. Re-call `ctx.use` after a dispatch that
95
- * mutated the actor to observe the new state.
96
- *
97
- * Methods are pure: they take state, return event(s) or read values. To
98
- * apply an event the method minted, return it from the handler.
99
- *
100
- * const submission = await ctx.use(Submission, input.submissionId)
101
- * if (!submission.isGradable()) throw new Error('not gradable')
102
- * return submission.grade(input.verdict) // event consumed by handler
72
+ * pre-bound to the loaded state.
103
73
  */
104
- use<TActor extends ActorDefinition>(actor: TActor, id: string): Promise<ActorInstanceView<TActor>>;
74
+ actor<TActor extends ActorDefinition>(actor: TActor, id: string): Promise<ActorInstanceView<TActor>>;
105
75
  /**
106
- * Invoke a declared external call (HTTP/RPC to another system). The
107
- * runtime threads the declared idempotency key, applies retry policy,
108
- * and emits `external.call.started/.completed/.failed` telemetry. The
109
- * actual transport is the registered `ExternalCallExecutor` for this
110
- * definition; if none is registered the call throws at runtime so
111
- * misconfigured wiring surfaces loudly.
76
+ * Invoke a declared external call (HTTP/RPC to another system). The runtime
77
+ * threads the declared idempotency key, applies retry policy, and emits
78
+ * `external.call.*` telemetry.
112
79
  */
113
80
  externalCall<TRequest extends ZodTypeAny, TResponse extends ZodTypeAny>(call: ExternalCallDefinition<TRequest, TResponse>, request: z.output<TRequest>): Promise<z.output<TResponse>>;
114
81
  }
@@ -117,45 +84,8 @@ export interface HandlerContext {
117
84
  *
118
85
  * - `void` / `null` / `undefined` — pure side-effect handler, nothing to publish.
119
86
  * - `EventMessage` / `EventMessage[]` — events the runtime publishes atomically.
120
- * - `Record<string, unknown>` — a plain data record (e.g. `{ userId }`) that
121
- * is NOT published. It flows out via `runtime.dispatch`'s return value and
122
- * the `ActionCompleted` framework event. Distinguished from `EventMessage`
123
- * at runtime by the missing `eventName` + `payload` shape.
124
- */
125
- export type HandlerReturn = void | EventMessage | EventMessage[] | Record<string, unknown> | null;
126
- /**
127
- * Outer-ctx shape `HandlerDefinition.run` consumes. Matches what
128
- * `@nwire/app`'s `Runtime.execute` builds: `input`, `envelope`, and a
129
- * `resolve` looking up bindings on the per-request scope. Forge's `.run`
130
- * implementation pulls the forge dispatcher off the container and
131
- * delegates so the full dispatcher pipeline (validation, action.before/
132
- * after hooks, retry, telemetry, persistence, …) fires.
87
+ * - `Record<string, unknown>` — a plain data record (e.g. `{ userId }`), NOT
88
+ * published; flows out via the dispatch return + `ActionCompleted`.
89
+ * - `ResponseInstance` a transport-shaped result (`created(...)` / `ok(...)`).
133
90
  */
134
- export interface HandlerRunCtx<TSchema extends ZodTypeAny = ZodTypeAny> {
135
- readonly input: z.output<TSchema>;
136
- readonly envelope?: MessageEnvelope;
137
- resolve<T = unknown>(name: string): T;
138
- }
139
- export interface HandlerRunOptions {
140
- readonly signal?: AbortSignal;
141
- }
142
- export interface HandlerDefinition<TSchema extends ZodTypeAny = ZodTypeAny> {
143
- readonly $kind: "handler";
144
- readonly action: ActionDefinition<TSchema>;
145
- readonly handler: (input: z.output<TSchema>, ctx: HandlerContext) => Promise<HandlerReturn> | HandlerReturn;
146
- /** Where the handler was declared. Studio uses this for IDE-open links. */
147
- readonly $source?: SourceLocation;
148
- /**
149
- * Forge handlers satisfy the `@nwire/handler.HandlerDefinition` shape
150
- * structurally so `Runtime.execute(handler, input, envelope)` works
151
- * directly without the adopter knowing about forge. The fields below
152
- * expose the metadata `Runtime` reads + the canonical `run()` entry
153
- * point that routes through the forge dispatcher.
154
- */
155
- readonly name: string;
156
- readonly input: TSchema;
157
- run(ctx: HandlerRunCtx<TSchema>, opts?: HandlerRunOptions): Promise<{
158
- result: ActionResult<ActionDefinition<TSchema>>;
159
- }>;
160
- }
161
- export declare function defineHandler<TSchema extends ZodTypeAny>(action: ActionDefinition<TSchema>, handler: HandlerDefinition<TSchema>["handler"]): HandlerDefinition<TSchema>;
91
+ export type HandlerReturn = void | EventMessage | EventMessage[] | Record<string, unknown> | ResponseInstance | null;