@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,49 +1,18 @@
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
- import { captureSourceLocation } from "@nwire/messages";
30
- // Forge dispatcher binding name — duplicated here to avoid a runtime →
31
- // primitives import cycle. Matches `FORGE_DISPATCHER_BINDING` in
32
- // `runtime/forge-plugin.ts`.
33
- const FORGE_DISPATCHER_BINDING = "forge.dispatcher";
34
- export function defineHandler(action, handler) {
35
- const def = {
36
- $kind: "handler",
37
- action,
38
- handler,
39
- $source: captureSourceLocation(),
40
- name: action.name,
41
- input: action.schema,
42
- async run(ctx, opts) {
43
- const dispatcher = ctx.resolve(FORGE_DISPATCHER_BINDING);
44
- const result = await dispatcher.dispatch(action, ctx.input, ctx.envelope, { signal: opts?.signal });
45
- return { result };
46
- },
47
- };
48
- return def;
49
- }
18
+ export {};
@@ -1,43 +1,38 @@
1
1
  /**
2
2
  * `defineProjection` — first-class CQRS read models.
3
3
  *
4
- * export const SubmissionsByStudent = defineProjection('submissions-by-student', {
5
- * listens: [AnswerSubmittedEvent, SubmissionAutoGradedEvent, ...],
6
- * initial: () => ({}),
7
- * on: {
8
- * [AnswerSubmittedEvent.name]: (state, event) => ({
9
- * ...state,
10
- * [event.submissionId]: { ... }
11
- * }),
12
- * ...
13
- * }
14
- * })
4
+ * export const SubmissionsByStudent = defineProjection("submissions-by-student",
5
+ * ({ when }) => {
6
+ * when(AnswerWasSubmitted, (state, e) => ({ ...state, [e.submissionId]: e }));
7
+ * when(SubmissionWasAutoGraded, (state, e) => ({ ...state, [e.submissionId]: { ...state[e.submissionId], verdict: e.verdict } }));
8
+ * },
9
+ * { initial: () => ({}) },
10
+ * );
15
11
  *
16
- * Projections are pure folds over event streams: `(state, event) => state`.
17
- * The runtime registers a `when` listener for each event in `listens` and
18
- * applies the corresponding `on` reducer to the projection's state.
12
+ * Projections are pure folds over event streams: `(state, event) => state`. The
13
+ * builder is a closure exposing the one event verb, `when` — each `when(Event,
14
+ * reducer)` registers a fold and adds the event to what the projection listens
15
+ * for (no separate `listens` list, no `on:{}` keyed by string). The runtime
16
+ * registers a `when` listener per event and applies the matching reducer.
19
17
  *
20
- * Queries (`defineQuery`) read from projections, not from actors. This
21
- * preserves the CQRS separation: writes go through actors, reads through
22
- * projections.
23
- *
24
- * Side effects (notifications, external sync) belong in `when` reactions, not
25
- * in projections — projections are pure folds, like `assign`.
18
+ * Queries (`defineQuery`) read from projections, not actors — CQRS separation:
19
+ * writes through actors, reads through projections. Side effects belong in `when`
20
+ * reactions (listeners / workflows), never in a projection — folds stay pure.
26
21
  */
27
- import type { EventDefinition, EventPayload, SourceLocation } from "@nwire/messages";
22
+ import type { EventDefinition, EventPayload } from "@nwire/messages";
28
23
  export type ProjectionReducer<TState, E extends EventDefinition = EventDefinition> = (state: TState, event: EventPayload<E>) => TState;
29
24
  /** Studio-aware: declared read-model freshness target. */
30
25
  export interface ProjectionFreshness {
31
26
  /** Declared p95 milliseconds the projection lags behind the event stream. */
32
27
  readonly p95MsBehindStream?: number;
33
28
  }
29
+ /** The one event verb, in the projection builder: register a fold for an event. */
30
+ export interface ProjectionBuilderContext<TState> {
31
+ when<E extends EventDefinition>(event: E, reducer: (state: TState, event: EventPayload<E>) => TState): void;
32
+ }
34
33
  export interface ProjectionOptions<TState> {
35
- /** The events this projection folds over. Order matters for `on` lookup. */
36
- readonly listens: readonly EventDefinition[];
37
34
  /** Factory for the initial state when no events have been seen yet. */
38
35
  readonly initial: () => TState;
39
- /** Pure reducers, keyed by event name. */
40
- readonly on: Readonly<Record<string, ProjectionReducer<TState>>>;
41
36
  /** Free-form description (Studio-aware). */
42
37
  readonly description?: string;
43
38
  /** Declared freshness SLO (Studio-aware). */
@@ -46,12 +41,12 @@ export interface ProjectionOptions<TState> {
46
41
  export interface ProjectionDefinition<TState = unknown> {
47
42
  readonly $kind: "projection";
48
43
  readonly name: string;
44
+ /** Events folded over — derived from the `when` calls. */
49
45
  readonly listens: readonly EventDefinition[];
50
46
  readonly initial: () => TState;
47
+ /** Pure reducers, keyed by event name — derived from the `when` calls. */
51
48
  readonly on: Readonly<Record<string, ProjectionReducer<TState>>>;
52
49
  readonly description?: string;
53
50
  readonly freshness?: ProjectionFreshness;
54
- /** Where the projection was declared. Studio uses this for IDE-open links. */
55
- readonly $source?: SourceLocation;
56
51
  }
57
- export declare function defineProjection<TState>(name: string, options: ProjectionOptions<TState>): ProjectionDefinition<TState>;
52
+ export declare function defineProjection<TState>(name: string, build: (ctx: ProjectionBuilderContext<TState>) => void, options: ProjectionOptions<TState>): ProjectionDefinition<TState>;
@@ -1,46 +1,43 @@
1
1
  /**
2
2
  * `defineProjection` — first-class CQRS read models.
3
3
  *
4
- * export const SubmissionsByStudent = defineProjection('submissions-by-student', {
5
- * listens: [AnswerSubmittedEvent, SubmissionAutoGradedEvent, ...],
6
- * initial: () => ({}),
7
- * on: {
8
- * [AnswerSubmittedEvent.name]: (state, event) => ({
9
- * ...state,
10
- * [event.submissionId]: { ... }
11
- * }),
12
- * ...
13
- * }
14
- * })
4
+ * export const SubmissionsByStudent = defineProjection("submissions-by-student",
5
+ * ({ when }) => {
6
+ * when(AnswerWasSubmitted, (state, e) => ({ ...state, [e.submissionId]: e }));
7
+ * when(SubmissionWasAutoGraded, (state, e) => ({ ...state, [e.submissionId]: { ...state[e.submissionId], verdict: e.verdict } }));
8
+ * },
9
+ * { initial: () => ({}) },
10
+ * );
15
11
  *
16
- * Projections are pure folds over event streams: `(state, event) => state`.
17
- * The runtime registers a `when` listener for each event in `listens` and
18
- * applies the corresponding `on` reducer to the projection's state.
12
+ * Projections are pure folds over event streams: `(state, event) => state`. The
13
+ * builder is a closure exposing the one event verb, `when` — each `when(Event,
14
+ * reducer)` registers a fold and adds the event to what the projection listens
15
+ * for (no separate `listens` list, no `on:{}` keyed by string). The runtime
16
+ * registers a `when` listener per event and applies the matching reducer.
19
17
  *
20
- * Queries (`defineQuery`) read from projections, not from actors. This
21
- * preserves the CQRS separation: writes go through actors, reads through
22
- * projections.
23
- *
24
- * Side effects (notifications, external sync) belong in `when` reactions, not
25
- * in projections — projections are pure folds, like `assign`.
18
+ * Queries (`defineQuery`) read from projections, not actors — CQRS separation:
19
+ * writes through actors, reads through projections. Side effects belong in `when`
20
+ * reactions (listeners / workflows), never in a projection — folds stay pure.
26
21
  */
27
- import { captureSourceLocation } from "@nwire/messages";
28
- export function defineProjection(name, options) {
29
- const $source = captureSourceLocation();
30
- // Validate that every listened event has a reducer.
31
- for (const event of options.listens) {
32
- if (!options.on[event.name]) {
33
- throw new Error(`defineProjection("${name}"): event "${event.name}" is in 'listens' but has no reducer in 'on'.`);
34
- }
35
- }
22
+ export function defineProjection(name, build, options) {
23
+ const listens = [];
24
+ const on = {};
25
+ build({
26
+ when(event, reducer) {
27
+ if (on[event.name]) {
28
+ throw new Error(`defineProjection("${name}"): event "${event.name}" has more than one when() reducer.`);
29
+ }
30
+ listens.push(event);
31
+ on[event.name] = reducer;
32
+ },
33
+ });
36
34
  return {
37
35
  $kind: "projection",
38
36
  name,
39
- listens: options.listens,
37
+ listens,
40
38
  initial: options.initial,
41
- on: options.on,
39
+ on,
42
40
  description: options.description,
43
41
  freshness: options.freshness,
44
- $source,
45
42
  };
46
43
  }
@@ -1,36 +1,37 @@
1
1
  /**
2
- * `defineQuery` — read function over a projection or a direct adapter (DB,
3
- * cache, search index). Two forms:
2
+ * `defineQuery` — a read over a projection or a direct adapter (DB, cache,
3
+ * search). A query IS a handler: like actions it is built via the one definer
4
+ * (`defineHandler.kind("query")`), so it carries `.run` / `config` and can be
5
+ * wired to a transport (HTTP GET), dispatched via `runtime.execute`, and
6
+ * registered by the app (`createApp({ handlers })`) — same surface as any
7
+ * handler. What's query-specific stays query-specific: it does NOT go through
8
+ * the command pipeline (no retry / publish / commit), and execution delegates
9
+ * to the read engine (`QueryRunner`), which loads projection state or runs the
10
+ * handler closure.
4
11
  *
5
12
  * // Projection-driven — runtime hands you `state` + validated input.
6
13
  * export const submissionsByStudent = defineQuery(SubmissionsByStudent, {
7
- * name: 'submissions.by-student',
8
- * schema: z.object({ studentId: StudentId }),
14
+ * name: "submissions.by-student",
15
+ * input: z.object({ studentId: StudentId }),
9
16
  * execute: (state, { studentId }) =>
10
- * Object.values(state).filter(s => s.studentId === studentId),
17
+ * Object.values(state).filter((s) => s.studentId === studentId),
11
18
  * })
12
19
  *
13
- * // Handler form — no projection, you reach into the container yourself.
20
+ * // Handler form — no projection; reach into the container yourself.
14
21
  * export const usersById = defineQuery({
15
- * name: 'users.by-id',
16
- * schema: z.object({ id: UserId }),
17
- * handler: async ({ id }, { resolve }) => {
18
- * const db = resolve<DrizzleDb>('db');
22
+ * name: "users.by-id",
23
+ * input: z.object({ id: UserId }),
24
+ * handler: async ({ input: { id }, resolve }) => {
25
+ * const db = resolve<DrizzleDb>("db");
19
26
  * return db.select().from(users).where(eq(users.id, id)).limit(1);
20
27
  * },
21
28
  * })
22
29
  *
23
- * Pick projection form when the read is "eventually-consistent over events
24
- * I publish in-process." Pick handler form when reading from any external
25
- * source of truth (Postgres rows, Redis, OpenSearch) — no projection layer
26
- * needed. Queries are always direct calls: no envelope, no bus, no commit.
27
- *
28
- * Naming follows actions: `<domain>.<verb-noun>` so a query is addressable
29
- * on the same routing keyspace as actions when transports need it (HTTP GET
30
- * routes wire queries; HTTP POST routes wire actions).
30
+ * Naming follows actions: `<domain>.<verb-noun>`.
31
31
  */
32
32
  import type { z } from "zod";
33
- import type { ZodTypeAny, SourceLocation } from "@nwire/messages";
33
+ import type { ZodTypeAny } from "@nwire/messages";
34
+ import type { MessageEnvelope } from "@nwire/envelope";
34
35
  import type { ProjectionDefinition } from "./define-projection.js";
35
36
  import { type PublicMarker } from "../helpers/public-marker.js";
36
37
  /** Studio-aware: declared read-path SLO. */
@@ -51,13 +52,22 @@ export interface QueryContext {
51
52
  readonly tenant?: string;
52
53
  /** Abort signal — wired from the caller's request when available. */
53
54
  readonly signal?: AbortSignal;
55
+ /**
56
+ * Dispatch envelope when the query runs under one (tenant, userId,
57
+ * correlation/causation ids). Present when invoked via `ctx.query` from a
58
+ * handler or a transport that carries an envelope.
59
+ */
60
+ readonly envelope?: MessageEnvelope;
54
61
  }
62
+ /** Ctx a handler-form query body receives — the read ctx with `input` on it. */
63
+ export type QueryCtx<TSchema extends ZodTypeAny = ZodTypeAny> = QueryContext & {
64
+ readonly input: z.output<TSchema>;
65
+ };
55
66
  export interface QueryOptions<TState, TSchema extends ZodTypeAny, TResult> {
56
67
  readonly name: string;
57
68
  readonly description?: string;
58
- /** Input schema — `input` is an alias accepted for docs parity with actions. */
59
- readonly schema: TSchema;
60
- readonly input?: TSchema;
69
+ /** Input schema (zod). */
70
+ readonly input: TSchema;
61
71
  readonly execute: (state: TState, input: z.output<TSchema>) => TResult | Promise<TResult>;
62
72
  /** Studio-aware: declared latency target. */
63
73
  readonly slo?: QuerySlo;
@@ -65,17 +75,17 @@ export interface QueryOptions<TState, TSchema extends ZodTypeAny, TResult> {
65
75
  readonly cacheable?: boolean;
66
76
  }
67
77
  /**
68
- * Options for handler-form `defineQuery({ name, schema, handler })`. No
69
- * projection, no state — you read from whatever source you care to via
78
+ * Options for handler-form `defineQuery({ name, input, handler })`. No
79
+ * projection, no state — read from whatever source you care to via
70
80
  * `ctx.resolve<DbType>("db")` (or any registered binding).
71
81
  */
72
82
  export interface QueryHandlerOptions<TSchema extends ZodTypeAny, TResult> {
73
83
  readonly name: string;
74
84
  readonly description?: string;
75
- /** Input schema — `input` is an alias accepted for docs parity with actions. */
76
- readonly schema: TSchema;
77
- readonly input?: TSchema;
78
- readonly handler: (input: z.output<TSchema>, ctx: QueryContext) => TResult | Promise<TResult>;
85
+ /** Input schema (zod). */
86
+ readonly input: TSchema;
87
+ /** The read body — one ctx arg (`{ input, resolve, tenant, envelope }`). */
88
+ readonly handler: (ctx: QueryCtx<TSchema>) => TResult | Promise<TResult>;
79
89
  readonly slo?: QuerySlo;
80
90
  readonly cacheable?: boolean;
81
91
  }
@@ -83,27 +93,27 @@ export interface QueryDefinition<TState = unknown, TSchema extends ZodTypeAny =
83
93
  readonly $kind: "query";
84
94
  readonly name: string;
85
95
  readonly description?: string;
86
- readonly schema: TSchema;
96
+ /** Input schema (zod). */
97
+ readonly input: TSchema;
87
98
  /**
88
- * Backing projection. `undefined` for handler-form queries — runtime
99
+ * Backing projection. `undefined` for handler-form queries — the runner
89
100
  * detects this and routes through `handler` directly (no state load).
90
101
  */
91
102
  readonly projection?: ProjectionDefinition<TState>;
92
- /**
93
- * Projection-form executor. Called with the projection's full state + the
94
- * validated input. Present iff `projection` is present.
95
- */
103
+ /** Projection-form executor. Present iff `projection` is present. */
96
104
  readonly execute?: (state: TState, input: z.output<TSchema>) => TResult | Promise<TResult>;
97
- /**
98
- * Handler-form executor. Called with the validated input + a
99
- * `QueryContext` (DI resolution, tenant, abort signal). Present iff
100
- * `projection` is absent.
101
- */
102
- readonly handler?: (input: z.output<TSchema>, ctx: QueryContext) => TResult | Promise<TResult>;
105
+ /** Handler-form executor. Present iff `projection` is absent. */
106
+ readonly handler?: (ctx: QueryCtx<TSchema>) => TResult | Promise<TResult>;
103
107
  readonly slo?: QuerySlo;
104
108
  readonly cacheable?: boolean;
105
- /** Where the query was declared. Studio uses this for IDE-open links. */
106
- readonly $source?: SourceLocation;
109
+ /** Kernel `@nwire/handler` config `kind:"query"`. Read by the runtime registry. */
110
+ readonly config?: any;
111
+ /** Hook-chain run — `runtime.execute` / a wired GET calls this; delegates to the read engine. */
112
+ run?(ctx: unknown, opts?: {
113
+ readonly signal?: AbortSignal;
114
+ }): Promise<unknown>;
115
+ /** Attach a chain step to the underlying hook. */
116
+ use?(fn: any, opts?: any): QueryDefinition<TState, TSchema, TResult>;
107
117
  }
108
118
  export declare function defineQuery<TState, TSchema extends ZodTypeAny, TResult>(projection: ProjectionDefinition<TState>, options: QueryOptions<TState, TSchema, TResult>): QueryDefinition<TState, TSchema, TResult>;
109
119
  export declare function defineQuery<TSchema extends ZodTypeAny, TResult>(options: QueryHandlerOptions<TSchema, TResult>): QueryDefinition<any, TSchema, TResult>;
@@ -1,58 +1,55 @@
1
1
  /**
2
- * `defineQuery` — read function over a projection or a direct adapter (DB,
3
- * cache, search index). Two forms:
2
+ * `defineQuery` — a read over a projection or a direct adapter (DB, cache,
3
+ * search). A query IS a handler: like actions it is built via the one definer
4
+ * (`defineHandler.kind("query")`), so it carries `.run` / `config` and can be
5
+ * wired to a transport (HTTP GET), dispatched via `runtime.execute`, and
6
+ * registered by the app (`createApp({ handlers })`) — same surface as any
7
+ * handler. What's query-specific stays query-specific: it does NOT go through
8
+ * the command pipeline (no retry / publish / commit), and execution delegates
9
+ * to the read engine (`QueryRunner`), which loads projection state or runs the
10
+ * handler closure.
4
11
  *
5
12
  * // Projection-driven — runtime hands you `state` + validated input.
6
13
  * export const submissionsByStudent = defineQuery(SubmissionsByStudent, {
7
- * name: 'submissions.by-student',
8
- * schema: z.object({ studentId: StudentId }),
14
+ * name: "submissions.by-student",
15
+ * input: z.object({ studentId: StudentId }),
9
16
  * execute: (state, { studentId }) =>
10
- * Object.values(state).filter(s => s.studentId === studentId),
17
+ * Object.values(state).filter((s) => s.studentId === studentId),
11
18
  * })
12
19
  *
13
- * // Handler form — no projection, you reach into the container yourself.
20
+ * // Handler form — no projection; reach into the container yourself.
14
21
  * export const usersById = defineQuery({
15
- * name: 'users.by-id',
16
- * schema: z.object({ id: UserId }),
17
- * handler: async ({ id }, { resolve }) => {
18
- * const db = resolve<DrizzleDb>('db');
22
+ * name: "users.by-id",
23
+ * input: z.object({ id: UserId }),
24
+ * handler: async ({ input: { id }, resolve }) => {
25
+ * const db = resolve<DrizzleDb>("db");
19
26
  * return db.select().from(users).where(eq(users.id, id)).limit(1);
20
27
  * },
21
28
  * })
22
29
  *
23
- * Pick projection form when the read is "eventually-consistent over events
24
- * I publish in-process." Pick handler form when reading from any external
25
- * source of truth (Postgres rows, Redis, OpenSearch) — no projection layer
26
- * needed. Queries are always direct calls: no envelope, no bus, no commit.
27
- *
28
- * Naming follows actions: `<domain>.<verb-noun>` so a query is addressable
29
- * on the same routing keyspace as actions when transports need it (HTTP GET
30
- * routes wire queries; HTTP POST routes wire actions).
30
+ * Naming follows actions: `<domain>.<verb-noun>`.
31
31
  */
32
- import { captureSourceLocation } from "@nwire/messages";
32
+ import { defineHandler } from "@nwire/handler";
33
33
  import { markable } from "../helpers/public-marker.js";
34
+ import { FORGE_QUERY_RUNNER_BINDING } from "../plugins/queries-plugin.js";
34
35
  export function defineQuery(projectionOrOptions, maybeOptions) {
35
- const $source = captureSourceLocation();
36
36
  // Handler form — first arg is the options object with `handler`.
37
37
  if (maybeOptions === undefined) {
38
38
  const opts = projectionOrOptions;
39
- const schema = opts.schema ?? opts.input;
40
- return markable({
41
- $kind: "query",
39
+ const schema = opts.input;
40
+ return buildQuery({
42
41
  name: opts.name,
43
42
  description: opts.description,
44
43
  schema,
45
44
  handler: opts.handler,
46
45
  slo: opts.slo,
47
46
  cacheable: opts.cacheable,
48
- $source,
49
47
  });
50
48
  }
51
49
  // Projection form — first arg is the projection.
52
50
  const projection = projectionOrOptions;
53
- const schema = maybeOptions.schema ?? maybeOptions.input;
54
- return markable({
55
- $kind: "query",
51
+ const schema = maybeOptions.input;
52
+ return buildQuery({
56
53
  name: maybeOptions.name,
57
54
  description: maybeOptions.description,
58
55
  schema,
@@ -60,6 +57,46 @@ export function defineQuery(projectionOrOptions, maybeOptions) {
60
57
  execute: maybeOptions.execute,
61
58
  slo: maybeOptions.slo,
62
59
  cacheable: maybeOptions.cacheable,
63
- $source,
64
60
  });
65
61
  }
62
+ /**
63
+ * Build the query as a kernel handler (kind:"query") whose body delegates to
64
+ * the per-app `QueryRunner` (resolved from ctx), then attach the query
65
+ * metadata + the `$kind:"query"` discriminator + the public marker.
66
+ */
67
+ function buildQuery(f) {
68
+ if (!f.schema) {
69
+ throw new Error(`defineQuery("${f.name}"): an \`input\` (zod) schema is required.`);
70
+ }
71
+ const kernel = defineHandler.kind("query")(f.name, {
72
+ input: f.schema,
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ handler: async (ctx) => {
75
+ const runner = ctx.resolve(FORGE_QUERY_RUNNER_BINDING);
76
+ return runner.run(f.name, ctx.input, ctx.envelope?.tenant ?? "", ctx.envelope);
77
+ },
78
+ description: f.description,
79
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
80
+ meta: { slo: f.slo, cacheable: f.cacheable },
81
+ });
82
+ const q = markable({
83
+ $kind: "query",
84
+ name: f.name,
85
+ description: f.description,
86
+ input: f.schema,
87
+ projection: f.projection,
88
+ execute: f.execute,
89
+ handler: f.handler,
90
+ slo: f.slo,
91
+ cacheable: f.cacheable,
92
+ config: kernel.config,
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
+ run: (ctx, opts) => kernel.run(ctx, opts),
95
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
96
+ use: (fn, opts) => {
97
+ kernel.use(fn, opts);
98
+ return q;
99
+ },
100
+ });
101
+ return q;
102
+ }
@@ -17,7 +17,7 @@
17
17
  * Example shapes appear in `docs/concepts/workflow.md`.
18
18
  */
19
19
  import type { z } from "zod";
20
- import type { ZodTypeAny, SourceLocation } from "@nwire/messages";
20
+ import type { ZodTypeAny } from "@nwire/messages";
21
21
  import type { EventDefinition, EventPayload } from "@nwire/messages";
22
22
  import type { MessageEnvelope } from "@nwire/envelope";
23
23
  import type { Logger } from "@nwire/logger";
@@ -26,7 +26,7 @@ import type { EventMessage } from "../messages/event-message.js";
26
26
  import { type PublicMarker } from "../helpers/public-marker.js";
27
27
  /**
28
28
  * Timer definition produced by `timeout(name, delay)`. Acts like an event
29
- * for subscription purposes — `on(timer, handler)` registers a handler
29
+ * for subscription purposes — `when(timer, handler)` registers a handler
30
30
  * that fires when the timer's delay elapses. The `delay` string accepts
31
31
  * the same units as actor timers (`"7d"`, `"30m"`, `"500ms"`, …).
32
32
  */
@@ -84,16 +84,15 @@ export type StateCallable = ((body: StateBody) => void) & {
84
84
  export type StateBody = () => Awaitable<Transition>;
85
85
  /**
86
86
  * Stateless workflow context — no `data`, no `states`, no `complete`.
87
- * `on()` returns are ignored (there's no state machine to transition).
87
+ * A `when` return is ignored (there's no state machine to transition).
88
88
  */
89
89
  export interface StatelessWorkflowContext extends WorkflowEffects {
90
- on<E extends EventDefinition>(event: E, handler: (payload: EventPayload<E>, ctx: WorkflowReactionContext) => Awaitable<Transition>, opts?: WorkflowOnOptions): void;
90
+ /** The one event verb react to an event. Returns are ignored when stateless. */
91
+ when<E extends EventDefinition>(event: E, handler: (payload: EventPayload<E>, ctx: WorkflowReactionContext) => Awaitable<Transition>, opts?: WorkflowOnOptions): void;
91
92
  /** Subscribe to the `complete` pseudo-event. Fires after each handler. */
92
- on(event: CompleteEvent, handler: () => Awaitable<void>, opts?: WorkflowOnOptions): void;
93
+ when(event: CompleteEvent, handler: () => Awaitable<void>, opts?: WorkflowOnOptions): void;
93
94
  /** Subscribe to a saga timer fire. */
94
- on(event: TimerDefinition, handler: (payload: unknown, ctx: WorkflowReactionContext) => Awaitable<Transition>, opts?: WorkflowOnOptions): void;
95
- /** Canonical event verb — alias of `on`. Prefer `when`; `on` is retained for now. */
96
- when: StatelessWorkflowContext["on"];
95
+ when(event: TimerDefinition, handler: (payload: unknown, ctx: WorkflowReactionContext) => Awaitable<Transition>, opts?: WorkflowOnOptions): void;
97
96
  }
98
97
  /**
99
98
  * Stateful workflow context — adds `data`, `assign`, `states`, and the
@@ -150,7 +149,7 @@ export interface WorkflowDefinition extends PublicMarker {
150
149
  readonly $kind: "workflow";
151
150
  readonly name: string;
152
151
  readonly description?: string;
153
- /** Event names this workflow declares an `on()` for. */
152
+ /** Event names this workflow declares a `when()` for. */
154
153
  readonly subscribedEvents: ReadonlySet<string>;
155
154
  /** Action names this workflow declares it dispatches. */
156
155
  readonly dispatchedActions: ReadonlySet<string>;
@@ -170,8 +169,6 @@ export interface WorkflowDefinition extends PublicMarker {
170
169
  readonly retry?: WorkflowRetryPolicy;
171
170
  /** Internal — invoked by the runtime per matching event. */
172
171
  readonly _fire: (event: EventMessage, fireCtx: FireContext) => Promise<void>;
173
- /** Where the workflow was declared. Studio uses this for IDE-open links. */
174
- readonly $source?: SourceLocation;
175
172
  }
176
173
  /**
177
174
  * Runtime hooks the workflow uses to load/save per-correlation state
@@ -16,7 +16,6 @@
16
16
  *
17
17
  * Example shapes appear in `docs/concepts/workflow.md`.
18
18
  */
19
- import { captureSourceLocation } from "@nwire/messages";
20
19
  import { markable } from "../helpers/public-marker.js";
21
20
  import { timerEventName } from "../stores/workflow-timer-store.js";
22
21
  /**
@@ -34,14 +33,26 @@ function isStateful(opts) {
34
33
  export function defineWorkflow(name,
35
34
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
35
  closure, options) {
37
- const $source = captureSourceLocation();
38
36
  const stateful = isStateful(options);
39
- const stateSpecs = stateful ? (options.states ?? {}) : {};
37
+ // Default lifecycle (nominal, not forced persistence): a stateful workflow —
38
+ // one that declares `data`/`states` — always has a closed lifecycle. With no
39
+ // states declared it gets `active` → `completed`; with states but none marked
40
+ // `final`, a `completed` terminal is injected so `complete` / cleanup always
41
+ // has meaning. The stateless fast path (no `data`, no `states`) is untouched —
42
+ // a pure reactor persists nothing and is nominally `active` forever.
43
+ let stateSpecs = stateful ? (options.states ?? {}) : {};
44
+ if (stateful && Object.keys(stateSpecs).length === 0) {
45
+ stateSpecs = { active: {}, completed: { final: true } };
46
+ }
40
47
  const finalStates = new Set();
41
48
  for (const [n, spec] of Object.entries(stateSpecs)) {
42
49
  if (spec.final)
43
50
  finalStates.add(n);
44
51
  }
52
+ if (stateful && finalStates.size === 0) {
53
+ stateSpecs = { ...stateSpecs, completed: { final: true } };
54
+ finalStates.add("completed");
55
+ }
45
56
  const initialState = stateful
46
57
  ? Object.keys(stateSpecs).find((n) => !finalStates.has(n))
47
58
  : undefined;
@@ -80,7 +91,6 @@ closure, options) {
80
91
  const probeStates = stateful ? buildProbeStateCallables(stateSpecs) : {};
81
92
  const probeCtx = stateful
82
93
  ? {
83
- on: probeOn,
84
94
  when: probeOn,
85
95
  send: async () => undefined,
86
96
  enqueue: async () => undefined,
@@ -93,7 +103,6 @@ closure, options) {
93
103
  schedule: async () => undefined,
94
104
  }
95
105
  : {
96
- on: probeOn,
97
106
  when: probeOn,
98
107
  send: async () => undefined,
99
108
  enqueue: async () => undefined,
@@ -159,7 +168,6 @@ closure, options) {
159
168
  dataSchema: stateful ? options.data : undefined,
160
169
  });
161
170
  },
162
- $source,
163
171
  };
164
172
  return markable(built);
165
173
  }
@@ -177,7 +185,6 @@ async function fireStateless({ closure, event, fireCtx, completeMarker, }) {
177
185
  }
178
186
  });
179
187
  const ctx = {
180
- on: register,
181
188
  when: register,
182
189
  send: fireCtx.send,
183
190
  enqueue: fireCtx.enqueue,
@@ -268,7 +275,6 @@ async function fireStateful(args) {
268
275
  }
269
276
  };
270
277
  const ctx = {
271
- on: onImpl,
272
278
  when: onImpl,
273
279
  async send(action, input) {
274
280
  if (scope.kind === "collect")