@nwire/forge 0.12.0 → 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 -17
- 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
|
@@ -1,170 +1,180 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `defineActor` — the primitive for entities that carry state, transitions,
|
|
3
|
-
* and timers.
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* the
|
|
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
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
//
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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,
|
|
132
|
-
if (
|
|
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
|
|
137
|
-
for (const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
-
|
|
202
|
+
after: () => { }, // no-op at runtime
|
|
193
203
|
};
|
|
194
|
-
const
|
|
195
|
-
return { methods
|
|
204
|
+
const methods = (body(liveCtx) ?? {});
|
|
205
|
+
return { methods, recorded };
|
|
196
206
|
};
|
|
197
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
2
|
+
* Handler ctx + return types for forge actions.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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,
|
|
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 (
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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 —
|
|
65
|
-
*
|
|
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
|
|
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
|
|
77
|
-
*
|
|
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
|
|
87
|
-
*
|
|
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<
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
108
|
-
*
|
|
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 }`)
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
|
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;
|