@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.
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 -17
  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,41 +1,27 @@
1
1
  /**
2
- * `queriesPlugin` — standalone forge query concern.
2
+ * `queriesPlugin` — the forge read concern as a runtime-native plugin.
3
3
  *
4
4
  * import { createApp } from "@nwire/app";
5
- * import {
6
- * projectionsPlugin,
7
- * queriesPlugin,
8
- * forgePlugin,
9
- * } from "@nwire/forge";
5
+ * import { projectionsPlugin, queriesPlugin } from "@nwire/forge";
10
6
  *
11
7
  * const app = createApp({
12
8
  * appName: "orders",
13
- * plugins: [
14
- * forgePlugin,
15
- * projectionsPlugin([OrdersDashboard]),
16
- * queriesPlugin([listOrders, getOrderById]),
17
- * ],
9
+ * handlers: [listOrders, getOrderById], // the app registers queries
10
+ * plugins: [projectionsPlugin([OrdersDashboard]), queriesPlugin()],
18
11
  * });
19
12
  *
20
- * Owns:
21
- * - the query registry (private to the plugin's `QueryRunner`)
22
- * - the `forge.queryRunner` container binding
23
- * - the `forge.query` capability (binding consumed by handler ctx as
24
- * `ctx.resolve("useProjection")` and equivalent paths)
25
- *
26
- * Queries don't participate in the EventPublishing chain. They execute
27
- * on demand against either a projection's per-tenant state (when
28
- * `projection + execute` is set) or a handler closure. Projection state
29
- * comes from the `forge.projectionStore` binding, which `projectionsPlugin`
30
- * or `forgePlugin` provides.
13
+ * It owns no query list. A query IS a handler (`defineQuery =
14
+ * defineHandler.kind("query")`), registered by the app via `createApp({
15
+ * handlers })`. At setup this plugin scans the runtime for `config.kind ===
16
+ * "query"` and registers each into the read engine (`QueryRunner`), which loads
17
+ * projection state or runs the handler closure on demand. Queries do not
18
+ * participate in the LocalDelivery chain.
31
19
  *
32
- * If `forgePlugin`'s `options.queries` is also passed, two QueryRunners
33
- * exist and the bundled one is reachable through the existing forge
34
- * dispatcher binding. Both work, but the standalone plugin's runner is
35
- * the one bound on `FORGE_QUERY_RUNNER_BINDING`. A warning at AppReady
36
- * flags the duplicate install.
20
+ * Owns:
21
+ * - the `QueryRunner` (read registry + execution) and the
22
+ * `forge.queryRunner` container binding.
23
+ * - the `ctx.query` verb a light read scoped to the envelope's tenant.
37
24
  */
38
25
  import type { PluginDefinition } from "@nwire/app";
39
- import type { QueryDefinition } from "../primitives/define-query.js";
40
26
  export declare const FORGE_QUERY_RUNNER_BINDING: "forge.queryRunner";
41
- export declare function queriesPlugin(queries: readonly QueryDefinition<any, any, any>[]): PluginDefinition;
27
+ export declare function queriesPlugin(): PluginDefinition;
@@ -1,73 +1,60 @@
1
1
  /**
2
- * `queriesPlugin` — standalone forge query concern.
2
+ * `queriesPlugin` — the forge read concern as a runtime-native plugin.
3
3
  *
4
4
  * import { createApp } from "@nwire/app";
5
- * import {
6
- * projectionsPlugin,
7
- * queriesPlugin,
8
- * forgePlugin,
9
- * } from "@nwire/forge";
5
+ * import { projectionsPlugin, queriesPlugin } from "@nwire/forge";
10
6
  *
11
7
  * const app = createApp({
12
8
  * appName: "orders",
13
- * plugins: [
14
- * forgePlugin,
15
- * projectionsPlugin([OrdersDashboard]),
16
- * queriesPlugin([listOrders, getOrderById]),
17
- * ],
9
+ * handlers: [listOrders, getOrderById], // the app registers queries
10
+ * plugins: [projectionsPlugin([OrdersDashboard]), queriesPlugin()],
18
11
  * });
19
12
  *
20
- * Owns:
21
- * - the query registry (private to the plugin's `QueryRunner`)
22
- * - the `forge.queryRunner` container binding
23
- * - the `forge.query` capability (binding consumed by handler ctx as
24
- * `ctx.resolve("useProjection")` and equivalent paths)
25
- *
26
- * Queries don't participate in the EventPublishing chain. They execute
27
- * on demand against either a projection's per-tenant state (when
28
- * `projection + execute` is set) or a handler closure. Projection state
29
- * comes from the `forge.projectionStore` binding, which `projectionsPlugin`
30
- * or `forgePlugin` provides.
13
+ * It owns no query list. A query IS a handler (`defineQuery =
14
+ * defineHandler.kind("query")`), registered by the app via `createApp({
15
+ * handlers })`. At setup this plugin scans the runtime for `config.kind ===
16
+ * "query"` and registers each into the read engine (`QueryRunner`), which loads
17
+ * projection state or runs the handler closure on demand. Queries do not
18
+ * participate in the LocalDelivery chain.
31
19
  *
32
- * If `forgePlugin`'s `options.queries` is also passed, two QueryRunners
33
- * exist and the bundled one is reachable through the existing forge
34
- * dispatcher binding. Both work, but the standalone plugin's runner is
35
- * the one bound on `FORGE_QUERY_RUNNER_BINDING`. A warning at AppReady
36
- * flags the duplicate install.
20
+ * Owns:
21
+ * - the `QueryRunner` (read registry + execution) and the
22
+ * `forge.queryRunner` container binding.
23
+ * - the `ctx.query` verb a light read scoped to the envelope's tenant.
37
24
  */
38
25
  import { FORGE_PROJECTION_STORE_BINDING } from "./projections-plugin.js";
39
26
  import { QueryRunner } from "./queries-chain.js";
40
27
  export const FORGE_QUERY_RUNNER_BINDING = "forge.queryRunner";
41
- export function queriesPlugin(
42
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
- queries) {
28
+ /** Is this registered handler a forge query? */
29
+ function isQueryHandler(h) {
30
+ return (h != null &&
31
+ (typeof h === "object" || typeof h === "function") &&
32
+ h.config?.kind === "query");
33
+ }
34
+ export function queriesPlugin() {
44
35
  return {
45
36
  name: "forge.queries",
46
- setup({ runtime, container, on }) {
37
+ setup({ runtime, container }) {
47
38
  if (!container.has(FORGE_PROJECTION_STORE_BINDING)) {
48
39
  throw new Error(`queriesPlugin: ${FORGE_PROJECTION_STORE_BINDING} is not bound. ` +
49
- `Install projectionsPlugin or forgePlugin before queriesPlugin so the projection store exists.`);
40
+ `Install projectionsPlugin before queriesPlugin so the projection store exists.`);
50
41
  }
51
42
  const projectionStore = container.resolve(FORGE_PROJECTION_STORE_BINDING);
52
43
  const runner = new QueryRunner(runtime, container, projectionStore);
53
- for (const query of queries)
54
- runner.register(query);
44
+ // Scan the runtime's registered handlers — the app owns registration.
45
+ for (const name of runtime.listHandlers()) {
46
+ const handler = runtime.getHandler(name);
47
+ if (isQueryHandler(handler))
48
+ runner.register(handler);
49
+ }
55
50
  container.register(FORGE_QUERY_RUNNER_BINDING, runner);
56
- on("AppReady", () => {
57
- // Diagnostic only — the bundled forge dispatcher also exposes
58
- // queries via its own internal registry. Both work, but the
59
- // standalone runner is the binding consumers see at
60
- // FORGE_QUERY_RUNNER_BINDING.
61
- const dispatcher = container.has("forge.dispatcher")
62
- ? container.resolve("forge.dispatcher")
63
- : undefined;
64
- const bundledNames = dispatcher?.listQueries?.() ?? [];
65
- const overlap = runner.listQueries().filter((n) => bundledNames.includes(n));
66
- if (overlap.length > 0) {
67
- // eslint-disable-next-line no-console
68
- console.warn(`queriesPlugin: ${overlap.length} query name(s) (${overlap.join(", ")}) are also registered on forgePlugin's bundled queries. ` +
69
- `Install either queriesPlugin OR forgePlugin's options.queries path, not both.`);
70
- }
51
+ // Contribute the `ctx.query` verb — a light read scoped to the envelope's
52
+ // tenant; the runner routes projection-form vs handler-form.
53
+ runtime.add({
54
+ name: "forge.query-ctx",
55
+ provideCtx: ({ envelope }) => ({
56
+ query: (queryDef, input) => runner.run(queryDef.name, input, envelope.tenant ?? "", envelope),
57
+ }),
71
58
  });
72
59
  },
73
60
  };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Workflow chain runner — the EventPublishing step that drives every
2
+ * Workflow chain runner — the LocalDelivery step that drives every
3
3
  * registered workflow whose subscribed events include the current one.
4
4
  *
5
5
  * Self-contained: takes its dependencies (runtime, timer store, effects
@@ -22,7 +22,7 @@ import { type Runtime } from "@nwire/app";
22
22
  import type { WorkflowDefinition, WorkflowInstance, WorkflowEffects } from "../primitives/define-workflow.js";
23
23
  import { type EventMessage } from "../messages/event-message.js";
24
24
  import type { WorkflowFireHookCtx } from "../runtime/forge-types.js";
25
- import type { WorkflowTimerStore } from "../stores/workflow-timer-store.js";
25
+ import { type WorkflowTimerStore } from "../stores/workflow-timer-store.js";
26
26
  /**
27
27
  * Resolves the dispatch effects (send / enqueue / publish) the workflow
28
28
  * sees on each fire. Returned in a fresh closure per event so envelope
@@ -48,4 +48,11 @@ export declare class WorkflowChainRunner {
48
48
  instanceStore(workflowName: string): Map<string, WorkflowInstance>;
49
49
  /** Fire every workflow that subscribes to this event. */
50
50
  apply(event: EventMessage, envelope: MessageEnvelope, correlationKeyOverride?: string): Promise<void>;
51
+ /**
52
+ * Drain every saga timer due at `now` and route each back into its
53
+ * workflow. Each fire reuses the normal `apply` path with the timer's
54
+ * `correlationKey` as the override, so the timer reaches the saga instance
55
+ * that scheduled it. Returns the number of timers fired.
56
+ */
57
+ fireDueWorkflowTimers(now?: Date): Promise<number>;
51
58
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Workflow chain runner — the EventPublishing step that drives every
2
+ * Workflow chain runner — the LocalDelivery step that drives every
3
3
  * registered workflow whose subscribed events include the current one.
4
4
  *
5
5
  * Self-contained: takes its dependencies (runtime, timer store, effects
@@ -18,9 +18,11 @@
18
18
  */
19
19
  import { randomUUID } from "node:crypto";
20
20
  import { hook } from "@nwire/hooks";
21
+ import { seedEnvelope } from "@nwire/envelope";
21
22
  import { loggerForEnvelope } from "@nwire/logger";
22
23
  import { serializeError } from "@nwire/app";
23
24
  import { eventFactory } from "../messages/event-message.js";
25
+ import { timerEventName } from "../stores/workflow-timer-store.js";
24
26
  import { computeBackoff, parseDelay, sleep } from "../helpers/retry-helpers.js";
25
27
  export class WorkflowChainRunner {
26
28
  runtime;
@@ -205,4 +207,20 @@ export class WorkflowChainRunner {
205
207
  }
206
208
  }
207
209
  }
210
+ /**
211
+ * Drain every saga timer due at `now` and route each back into its
212
+ * workflow. Each fire reuses the normal `apply` path with the timer's
213
+ * `correlationKey` as the override, so the timer reaches the saga instance
214
+ * that scheduled it. Returns the number of timers fired.
215
+ */
216
+ async fireDueWorkflowTimers(now = new Date()) {
217
+ let fired = 0;
218
+ const envelope = seedEnvelope({});
219
+ for await (const timer of this.timerStore.drainDue(now)) {
220
+ const eventName = timerEventName(timer.workflowName, timer.timerName);
221
+ await this.apply({ eventName, payload: timer.payload }, envelope, timer.correlationKey);
222
+ fired++;
223
+ }
224
+ return fired;
225
+ }
208
226
  }
@@ -15,7 +15,7 @@
15
15
  * Owns:
16
16
  * - the `forge.workflowTimerStore` container binding
17
17
  * - the workflow registry (private to the plugin's `WorkflowChainRunner`)
18
- * - the `forge.publish.workflows` step on the EventPublishing chain
18
+ * - the `forge.publish.workflows` step on the LocalDelivery chain
19
19
  * (priority 400 — between projections and bus delivery)
20
20
  * - the `forge.workflowChain` container binding so other plugins can
21
21
  * resolve the runner (the timer scheduler fires through it)
@@ -15,7 +15,7 @@
15
15
  * Owns:
16
16
  * - the `forge.workflowTimerStore` container binding
17
17
  * - the workflow registry (private to the plugin's `WorkflowChainRunner`)
18
- * - the `forge.publish.workflows` step on the EventPublishing chain
18
+ * - the `forge.publish.workflows` step on the LocalDelivery chain
19
19
  * (priority 400 — between projections and bus delivery)
20
20
  * - the `forge.workflowChain` container binding so other plugins can
21
21
  * resolve the runner (the timer scheduler fires through it)
@@ -29,7 +29,7 @@
29
29
  * fires at `AppReady` when both attachments are present.
30
30
  */
31
31
  import { InMemoryWorkflowTimerStore, } from "../stores/workflow-timer-store.js";
32
- import { FORGE_DISPATCHER_BINDING } from "../runtime/forge-plugin.js";
32
+ import { FORGE_ACTION_RUNNER_BINDING, FORGE_PUBLISH_BINDING } from "./actions-plugin.js";
33
33
  import { EVENT_PUBLISHING_PRIORITIES } from "../framework-events.js";
34
34
  import { WorkflowChainRunner } from "./workflows-chain.js";
35
35
  export const FORGE_WORKFLOW_CHAIN_BINDING = "forge.workflowChain";
@@ -41,21 +41,22 @@ export function workflowsPlugin(workflows, opts = {}) {
41
41
  const store = opts.workflowTimerStore ?? new InMemoryWorkflowTimerStore();
42
42
  bind(FORGE_WORKFLOW_TIMER_STORE_BINDING, () => store);
43
43
  },
44
- setup({ runtime, container, on }) {
44
+ setup({ runtime, container }) {
45
45
  const timerStore = container.resolve(FORGE_WORKFLOW_TIMER_STORE_BINDING);
46
46
  const chain = new WorkflowChainRunner(runtime, timerStore, (envelope) => {
47
- // Effects resolve the dispatcher lazily so the binding exists by
48
- // the time a workflow actually fires.
49
- const dispatcher = container.resolve(FORGE_DISPATCHER_BINDING);
47
+ // Effects resolve the action runner + shared publish lazily so the
48
+ // bindings exist by the time a workflow actually fires.
49
+ const runner = container.resolve(FORGE_ACTION_RUNNER_BINDING);
50
+ const publish = container.resolve(FORGE_PUBLISH_BINDING);
50
51
  const effects = {
51
52
  async send(action, input) {
52
- return dispatcher.dispatch(action, input, envelope);
53
+ return runner.dispatch(action, input, envelope);
53
54
  },
54
55
  async enqueue(action, input) {
55
- void dispatcher.dispatch(action, input, envelope);
56
+ void runner.dispatch(action, input, envelope);
56
57
  },
57
58
  async publish(eventMsg) {
58
- await dispatcher.publish([eventMsg], envelope);
59
+ await publish([eventMsg], envelope);
59
60
  },
60
61
  };
61
62
  return effects;
@@ -63,19 +64,10 @@ export function workflowsPlugin(workflows, opts = {}) {
63
64
  for (const workflow of workflows)
64
65
  chain.register(workflow);
65
66
  container.register(FORGE_WORKFLOW_CHAIN_BINDING, chain);
66
- runtime.hooks.EventPublishing.use(async (payload, next) => {
67
- await chain.apply(payload.event, payload.envelope);
67
+ runtime.hooks.LocalDelivery.use(async (payload, next) => {
68
+ await chain.apply({ eventName: payload.eventName, payload: payload.payload }, payload.envelope);
68
69
  await next();
69
70
  }, { name: "forge.publish.workflows", priority: EVENT_PUBLISHING_PRIORITIES.workflows });
70
- on("AppReady", () => {
71
- const hookChain = runtime.hooks.EventPublishing;
72
- const steps = (hookChain.chain ?? []).filter((s) => s.name === "forge.publish.workflows");
73
- if (steps.length > 1) {
74
- // eslint-disable-next-line no-console
75
- console.warn(`workflowsPlugin: detected ${steps.length} "forge.publish.workflows" steps on the EventPublishing chain. ` +
76
- `Install either workflowsPlugin OR forgePlugin's options.workflows path, not both.`);
77
- }
78
- });
79
71
  },
80
72
  };
81
73
  }
@@ -1,41 +1,45 @@
1
1
  /**
2
- * `defineAction` — declares a typed message the system can handle. Actions
3
- * are the framework's center of gravity: named intent, typed input, no
4
- * transport concerns. Resolvers wrap actions for HTTP/GraphQL/CLI; workflows
5
- * dispatch actions in response to events.
2
+ * `defineAction` — declares a typed command the system can handle. It is sugar
3
+ * over the one operation primitive:
6
4
  *
7
- * export const submitAnswer = defineAction({
8
- * name: 'submissions.submit-answer',
9
- * description: 'Avi submits his answer to an exercise.',
10
- * schema: SubmitAnswerInput,
11
- * })
5
+ * export const defineAction = defineHandler.kind("action");
12
6
  *
13
- * An action carries a `name` (its routing key) and a `schema` (input type).
14
- * It is BOTH a meta record (`.name`, `.schema`, …) AND a callable factory:
7
+ * An action IS a handler (`@nwire/handler`) with `config.kind === "action"`,
8
+ * plus a thin forge decoration: it mints a typed `CommandMessage` when called,
9
+ * carries the command-side metadata (`retry`, `emits`, `policy`, persona /
10
+ * journey / SLO for Studio), and exposes `.public()`. There is no separate
11
+ * `.handler` object — the action object itself is what `runtime.execute`
12
+ * dispatches and what the app registers.
15
13
  *
16
- * execute(submitAnswer({ studentId, attemptId, choice })) // dispatch
17
- * defineHandler(submitAnswer, fn) // bind handler
18
- * ctx.send(submitAnswer, input) // also valid
14
+ * export const submitAnswer = defineAction({
15
+ * name: "submissions.submit-answer",
16
+ * input: SubmitAnswerInput,
17
+ * emits: [AnswerSubmitted],
18
+ * handler: async ({ input, request }) => {
19
+ * const grade = await request(evaluateGrade, { ... })
20
+ * return grade.ok ? AnswerSubmitted({ ... }) : undefined
21
+ * },
22
+ * })
19
23
  *
20
- * Calling the action mints a typed `CommandMessage` with input validated
21
- * against the zod schema — the dispatch site can't pass bad data. Mirrors
22
- * the `defineEvent → EventMessage` pattern (Phase 41.9).
24
+ * execute(submitAnswer({ ... })) // dispatch via minted CommandMessage
25
+ * ctx.request(submitAnswer, input) // dispatch by reference
26
+ * submitAnswer.name // "submissions.submit-answer"
23
27
  *
24
- * Inline shorthand: `defineAction({ ..., handler })` synthesizes a handler
25
- * alongside the contract. For cross-module orchestration where the handler
26
- * lives elsewhere, leave `handler` undefined and wire via `defineHandler`.
28
+ * The handler body takes one `ctx` arg `{ input, request, send, emit, query,
29
+ * actor, … }` — the same shape as the kernel handler. For cross-module
30
+ * orchestration where the handler lives elsewhere, leave `handler` undefined
31
+ * the action is then a schema-only contract you dispatch toward.
27
32
  */
28
33
  import type { z } from "zod";
29
34
  import type { ZodTypeAny } from "@nwire/messages";
30
- import type { EventDefinition, SourceLocation } from "@nwire/messages";
31
- import { type HandlerDefinition, type HandlerReturn, type HandlerContext } from "./define-handler.js";
35
+ import type { EventDefinition } from "@nwire/messages";
36
+ import type { HandlerContext, HandlerReturn } from "./define-handler.js";
32
37
  /** RBAC policy tag — string, string list, or CASL-style `[action, subject]` tuple. */
33
38
  export type ActionPolicy = string | readonly string[] | readonly [action: string, subject: string];
34
39
  /**
35
40
  * Retry policy for an action's handler. When a handler throws, the runtime
36
41
  * retries up to `max` times with `backoff` strategy between attempts. After
37
- * `max` attempts fail, the failure goes to the DLQ (dead-letter queue
38
- * currently a sink in the runtime; production wires plug in BullMQ DLQ).
42
+ * `max` attempts fail, the failure goes to the DLQ (dead-letter queue).
39
43
  */
40
44
  export interface RetryPolicy {
41
45
  /** Maximum retry attempts after the initial try. Default: 0 (no retry). */
@@ -48,10 +52,8 @@ export interface RetryPolicy {
48
52
  readonly maxDelayMs?: number;
49
53
  }
50
54
  /**
51
- * Service-level objective declaration for an action. Studio shows the
52
- * declared target side-by-side with observed reality (p95 latency from the
53
- * telemetry stream, success rate from action.completed vs .failed counts).
54
- * All fields optional; missing fields just don't get scored.
55
+ * Service-level objective declaration for an action. Studio shows the declared
56
+ * target side-by-side with observed reality.
55
57
  */
56
58
  export interface ActionSlo {
57
59
  /** Declared p95 latency target in milliseconds. */
@@ -64,29 +66,27 @@ export interface ActionSlo {
64
66
  *
65
67
  * const cmd = submitAnswer({ ... }) // { $kind, actionName, input }
66
68
  * execute(cmd) // dispatch
67
- *
68
- * The runtime's `execute`/`send`/`dispatch` accept either this command
69
- * message OR the legacy `(action, input)` positional form. Both routes
70
- * end at the same handler.
71
69
  */
72
70
  export interface CommandMessage<TSchema extends ZodTypeAny = ZodTypeAny> {
73
71
  readonly $kind: "command";
74
72
  readonly actionName: string;
75
73
  readonly input: z.output<TSchema>;
76
74
  }
75
+ /** Ctx a handler body receives — the forge handler ctx with the validated `input` on it. */
76
+ export type ActionCtx<TSchema extends ZodTypeAny = ZodTypeAny> = HandlerContext & {
77
+ readonly input: z.output<TSchema>;
78
+ };
79
+ /** The handler body — one ctx arg (`{ input, request, send, ... }`). */
80
+ export type ActionHandlerFn<TSchema extends ZodTypeAny = ZodTypeAny> = (ctx: ActionCtx<TSchema>) => Promise<HandlerReturn> | HandlerReturn;
77
81
  /**
78
- * The non-callable action shape — used as the abstract reference type in
79
- * function signatures (`runtime.dispatch(action: ActionDefinition, …)`,
80
- * `defineHandler(action: ActionDefinition, )`, etc.). Concrete actions
81
- * returned from `defineAction` are `CallableActionDefinition` (this + a
82
- * call signature); they're assignable wherever `ActionDefinition` is
83
- * required because the call signature is purely additive.
82
+ * The non-callable action shape — the abstract reference type used in function
83
+ * signatures across the runtime. An action is a handler: it carries the kernel
84
+ * `config` + the hook surface (`run`/`use`) so `runtime.execute` dispatches it
85
+ * directly, plus the command-side metadata.
84
86
  *
85
- * Why split: TS call signatures are contravariant in their parameter, so
86
- * a typed `(input: MySchema) => …` is NOT assignable to `(input: unknown)
87
- * => …`. Keeping the abstract reference type non-callable preserves
88
- * covariance over `TSchema` — `ActionDefinition<MySchema>` flows freely
89
- * into `ActionDefinition<ZodTypeAny>` positions across the runtime.
87
+ * Concrete actions from `defineAction` are `CallableActionDefinition` (this + a
88
+ * call signature minting a `CommandMessage`); the call signature is additive so
89
+ * they're assignable wherever `ActionDefinition` is required.
90
90
  */
91
91
  export interface ActionDefinition<TSchema extends ZodTypeAny = ZodTypeAny> {
92
92
  /** Truthy when this definition was marked via `.public()` in a manifest. */
@@ -94,104 +94,72 @@ export interface ActionDefinition<TSchema extends ZodTypeAny = ZodTypeAny> {
94
94
  readonly $kind: "action";
95
95
  readonly name: string;
96
96
  readonly description?: string;
97
- readonly schema: TSchema;
97
+ /** Input schema (zod). */
98
+ readonly input: TSchema;
98
99
  readonly retry?: RetryPolicy;
99
- /**
100
- * Optional policy tag(s) consumed by authz middleware (`@nwire/auth`).
101
- * The framework treats this as an opaque string — interpretation is
102
- * the authorizer's job. Examples: `'admin-only'`, `['student', 'self']`.
103
- */
100
+ /** Optional policy tag(s) consumed by authz middleware (`@nwire/auth`). */
104
101
  readonly policy?: ActionPolicy;
105
- /**
106
- * Named persona who triggers this action. Studio uses it to build
107
- * persona-grouped journey views ("show me Avi's full flow"). Free-form
108
- * label — typically the persona's name with a short qualifier:
109
- * `'Avi (9, beginner)'`, `'Dina (curriculum designer)'`.
110
- */
102
+ /** Named persona who triggers this action — Studio journey views. */
111
103
  readonly persona?: string;
112
- /**
113
- * Journey step identifier this action belongs to. Lets Studio reconstruct
114
- * the user journey across BCs. Free-form — by convention an id + label:
115
- * `'J3-submit-exercise'`.
116
- */
104
+ /** Journey step identifier this action belongs to. */
117
105
  readonly journeyStep?: string;
118
- /**
119
- * High-level capability name (UDOO-framework concept). Groups actions
120
- * by what they enable. Example: `'submit-answer'`, `'review-flagged'`.
121
- */
106
+ /** High-level capability name (UDOO concept). */
122
107
  readonly capability?: string;
123
108
  /** Declared SLO target — Studio scores observed reality against this. */
124
109
  readonly slo?: ActionSlo;
125
110
  /** Free-form tags for filtering / grouping in Studio. */
126
111
  readonly tags?: readonly string[];
127
112
  /**
128
- * Inline handler synthesized when `defineAction({..., handler})` was used.
129
- * `createApp` walks `manifest.actions` and auto-registers this handler if
130
- * present. For action contracts that have NO handler in the current module
131
- * (cross-module dispatch, public schema-only declarations), leave this
132
- * undefined and register the handler separately via `manifest.handlers`.
133
- *
134
- * Typed as `HandlerDefinition<ZodTypeAny>` (not `HandlerDefinition<TSchema>`)
135
- * to preserve ActionDefinition's covariance over TSchema — without this,
136
- * `ActionDefinition<MySchema>` would not be assignable to
137
- * `ActionDefinition<ZodTypeAny>` (the abstract action reference used by
138
- * the runtime, `defineHandler`, ctx.request, etc.).
113
+ * Declared events this action emits the *declared intent* the scanner
114
+ * reads into the static graph. The handler's runtime return is the source
115
+ * of truth.
139
116
  */
140
- readonly handler?: HandlerDefinition;
117
+ readonly emits?: readonly EventDefinition[];
141
118
  /**
142
- * Declared events this action emits. The handler's runtime return is the
143
- * source of truth; this field is the *declared intent* what the system
144
- * promises to emit. The scanner reads it into the static graph so Studio
145
- * (and any audit tooling) can draw causation chains at design time, and
146
- * runtime observers can detect drift (emitted-but-not-declared or
147
- * declared-but-never-fired).
148
- *
149
- * Carries event names only (event refs are erased to `{ name }` to avoid
150
- * coupling the action definition to its event modules at the type level).
119
+ * The kernel `@nwire/handler` config `kind:"action"`, the adapted
120
+ * `(ctx)` handler fn, `input`, `emits`. Present iff an inline handler was
121
+ * declared. `actionsPlugin` reads `config.handler` to run the pipeline; the
122
+ * runtime reads it via the registry.
151
123
  */
152
- readonly emits?: readonly EventDefinition[];
153
- /** Where the action was declared. Studio uses this for IDE-open links. */
154
- readonly $source?: SourceLocation;
124
+ readonly config?: any;
125
+ /** Hook-chain run delegates to the underlying handler. `runtime.execute` calls this. */
126
+ run?(ctx: unknown, opts?: {
127
+ readonly signal?: AbortSignal;
128
+ }): Promise<unknown>;
129
+ /** Attach a chain step to the underlying handler's hook. Returns the action. */
130
+ use?(fn: any, opts?: any): ActionDefinition<TSchema>;
131
+ /** Attach a listener to the underlying handler's hook. Returns the action. */
132
+ on?(fn: any, opts?: any): ActionDefinition<TSchema>;
133
+ /** Detach a previously attached chain/listener fn. Returns the action. */
134
+ off?(fn: any): ActionDefinition<TSchema>;
155
135
  /** Return a callable action clone with `$public: true`. */
156
136
  public: () => CallableActionDefinition<TSchema>;
157
137
  }
158
138
  /**
159
- * The full action shape returned from `defineAction` — adds the call
160
- * signature so consumers can mint a typed `CommandMessage` inline:
161
- *
162
- * submitAnswer.name // "submissions.submit-answer"
163
- * submitAnswer({ studentId, ... }) // CommandMessage
164
- * submitAnswer.public() // callable clone, $public: true
139
+ * The full action shape returned from `defineAction` — adds the call signature
140
+ * so consumers can mint a typed `CommandMessage` inline.
165
141
  */
166
142
  export type CallableActionDefinition<TSchema extends ZodTypeAny = ZodTypeAny> = ActionDefinition<TSchema> & ((input: z.input<TSchema>) => CommandMessage<TSchema>);
167
143
  export interface ActionMeta<TSchema extends ZodTypeAny = ZodTypeAny> {
168
144
  readonly name: string;
169
145
  readonly description?: string;
170
- readonly schema: TSchema;
146
+ /** Input schema (zod). */
147
+ readonly input: TSchema;
171
148
  readonly retry?: RetryPolicy;
172
149
  readonly policy?: ActionPolicy;
173
- /** See `ActionDefinition.persona`. */
174
150
  readonly persona?: string;
175
- /** See `ActionDefinition.journeyStep`. */
176
151
  readonly journeyStep?: string;
177
- /** See `ActionDefinition.capability`. */
178
152
  readonly capability?: string;
179
- /** See `ActionDefinition.slo`. */
180
153
  readonly slo?: ActionSlo;
181
- /** See `ActionDefinition.tags`. */
182
154
  readonly tags?: readonly string[];
183
- /** Declared events this action emits. See `ActionDefinition.emits`. */
155
+ /** Declared events this action emits. */
184
156
  readonly emits?: readonly EventDefinition[];
185
157
  /**
186
- * Optional inline handler. When set, `defineAction` synthesizes a
187
- * `HandlerDefinition` linked to this action and attaches it to the
188
- * returned `ActionDefinition.handler`. Use this for the common CRUD case
189
- * where contract and handler live together. For cross-module orchestration
190
- * (handler wired separately, action referenced from another module),
191
- * declare the action without `handler` and use `defineHandler(action, fn)`
192
- * in the owning module.
158
+ * Optional inline handler the common case where contract and handler live
159
+ * together. The body takes one `ctx` arg. For cross-module
160
+ * orchestration, omit it a schema-only contract.
193
161
  */
194
- readonly handler?: (input: z.output<TSchema>, ctx: HandlerContext) => Promise<HandlerReturn> | HandlerReturn;
162
+ readonly handler?: ActionHandlerFn<TSchema>;
195
163
  }
196
164
  export declare function defineAction<TSchema extends ZodTypeAny>(meta: ActionMeta<TSchema>): CallableActionDefinition<TSchema>;
197
165
  export declare function defineAction<TSchema extends ZodTypeAny>(name: string, meta: Omit<ActionMeta<TSchema>, "name">): CallableActionDefinition<TSchema>;
@@ -200,8 +168,8 @@ export type ActionInput<A> = A extends ActionDefinition<infer S> ? z.output<S> :
200
168
  export type ActionResult<A> = A extends ActionDefinition ? HandlerReturn : never;
201
169
  /**
202
170
  * Optional string-literal dispatch registry. Apps implement this on the
203
- * container so `dispatch("orders.place-order", input)` typechecks when
204
- * a module augments the registry via declaration merging.
171
+ * container so `dispatch("orders.place-order", input)` typechecks when a
172
+ * module augments the registry via declaration merging.
205
173
  */
206
174
  export interface NwireActionRegistry {
207
175
  readonly [actionName: string]: ActionDefinition;
@@ -214,10 +182,7 @@ export interface NwireActionRegistry {
214
182
  export declare function isCommandMessage(x: unknown): x is CommandMessage;
215
183
  /**
216
184
  * Resolve either dispatch form into `(action, input)`. Centralised so every
217
- * transport surface (execute, send, ctx.request) accepts both call shapes
218
- * with one helper. When the caller passes a `CommandMessage`, the resolver
219
- * looks up the action by name on the runtime; when they pass an
220
- * `ActionDefinition` + input, it short-circuits.
185
+ * transport surface accepts both call shapes with one helper.
221
186
  */
222
187
  export declare function resolveDispatch(actionOrCmd: ActionDefinition | CommandMessage, input: unknown, lookup: (name: string) => ActionDefinition | undefined): {
223
188
  action: ActionDefinition;