@nwire/forge 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -83
- package/dist/framework-events.d.ts +8 -37
- package/dist/framework-events.js +7 -3
- package/dist/helpers/cli-runner.js +21 -10
- package/dist/index.d.ts +8 -7
- package/dist/index.js +7 -6
- package/dist/plugins/actions-chain.d.ts +39 -22
- package/dist/plugins/actions-chain.js +117 -78
- package/dist/plugins/actions-plugin.d.ts +26 -23
- package/dist/plugins/actions-plugin.js +122 -44
- package/dist/plugins/actors-chain.d.ts +9 -2
- package/dist/plugins/actors-chain.js +62 -2
- package/dist/plugins/actors-plugin.d.ts +1 -1
- package/dist/plugins/actors-plugin.js +24 -14
- package/dist/plugins/external-calls-plugin.d.ts +28 -0
- package/dist/plugins/external-calls-plugin.js +136 -0
- package/dist/plugins/idempotency-plugin.d.ts +15 -1
- package/dist/plugins/idempotency-plugin.js +56 -11
- package/dist/plugins/projections-chain.d.ts +2 -2
- package/dist/plugins/projections-chain.js +2 -2
- package/dist/plugins/projections-plugin.d.ts +1 -1
- package/dist/plugins/projections-plugin.js +4 -13
- package/dist/plugins/queries-chain.d.ts +4 -3
- package/dist/plugins/queries-chain.js +8 -5
- package/dist/plugins/queries-plugin.d.ts +15 -29
- package/dist/plugins/queries-plugin.js +36 -49
- package/dist/plugins/workflows-chain.d.ts +9 -2
- package/dist/plugins/workflows-chain.js +19 -1
- package/dist/plugins/workflows-plugin.d.ts +1 -1
- package/dist/plugins/workflows-plugin.js +12 -20
- package/dist/primitives/define-action.d.ts +80 -115
- package/dist/primitives/define-action.js +111 -56
- package/dist/primitives/define-actor.d.ts +103 -214
- package/dist/primitives/define-actor.js +157 -216
- package/dist/primitives/define-handler.d.ts +42 -112
- package/dist/primitives/define-handler.js +14 -45
- package/dist/primitives/define-projection.d.ts +23 -28
- package/dist/primitives/define-projection.js +29 -32
- package/dist/primitives/define-query.d.ts +52 -42
- package/dist/primitives/define-query.js +65 -28
- package/dist/primitives/define-workflow.d.ts +8 -11
- package/dist/primitives/define-workflow.js +14 -8
- package/dist/runtime/forge-dispatcher.d.ts +30 -12
- package/dist/runtime/forge-dispatcher.js +199 -237
- package/dist/runtime/forge-plugin.d.ts +8 -0
- package/dist/runtime/forge-plugin.js +113 -31
- package/dist/runtime/forge-plugins.d.ts +55 -0
- package/dist/runtime/forge-plugins.js +57 -0
- package/dist/runtime/forge-types.d.ts +8 -2
- package/dist/runtime/with-forge.d.ts +8 -11
- package/dist/runtime/with-forge.js +9 -11
- package/dist/stores/idempotency-store.d.ts +1 -1
- package/package.json +12 -12
|
@@ -1,49 +1,18 @@
|
|
|
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
|
-
|
|
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(
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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(
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
37
|
+
listens,
|
|
40
38
|
initial: options.initial,
|
|
41
|
-
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
|
|
3
|
-
*
|
|
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:
|
|
8
|
-
*
|
|
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
|
|
20
|
+
* // Handler form — no projection; reach into the container yourself.
|
|
14
21
|
* export const usersById = defineQuery({
|
|
15
|
-
* name:
|
|
16
|
-
*
|
|
17
|
-
* handler: async ({ id },
|
|
18
|
-
* const db = resolve<DrizzleDb>(
|
|
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
|
-
*
|
|
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
|
|
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
|
|
59
|
-
readonly
|
|
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,
|
|
69
|
-
* projection, no state —
|
|
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
|
|
76
|
-
readonly
|
|
77
|
-
|
|
78
|
-
readonly handler: (
|
|
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
|
-
|
|
96
|
+
/** Input schema (zod). */
|
|
97
|
+
readonly input: TSchema;
|
|
87
98
|
/**
|
|
88
|
-
* Backing projection. `undefined` for handler-form queries —
|
|
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
|
-
|
|
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
|
-
/**
|
|
106
|
-
readonly
|
|
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
|
|
3
|
-
*
|
|
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:
|
|
8
|
-
*
|
|
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
|
|
20
|
+
* // Handler form — no projection; reach into the container yourself.
|
|
14
21
|
* export const usersById = defineQuery({
|
|
15
|
-
* name:
|
|
16
|
-
*
|
|
17
|
-
* handler: async ({ id },
|
|
18
|
-
* const db = resolve<DrizzleDb>(
|
|
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
|
-
*
|
|
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 {
|
|
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.
|
|
40
|
-
return
|
|
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.
|
|
54
|
-
return
|
|
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
|
|
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 — `
|
|
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
|
-
* `
|
|
87
|
+
* A `when` return is ignored (there's no state machine to transition).
|
|
88
88
|
*/
|
|
89
89
|
export interface StatelessWorkflowContext extends WorkflowEffects {
|
|
90
|
-
|
|
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
|
-
|
|
93
|
+
when(event: CompleteEvent, handler: () => Awaitable<void>, opts?: WorkflowOnOptions): void;
|
|
93
94
|
/** Subscribe to a saga timer fire. */
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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")
|