@nwire/forge 0.10.0 → 0.11.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 (41) hide show
  1. package/dist/framework-events.d.ts +44 -2
  2. package/dist/framework-events.js +10 -1
  3. package/dist/helpers/cli-runner.d.ts +1 -1
  4. package/dist/helpers/cli-runner.js +5 -4
  5. package/dist/index.d.ts +25 -13
  6. package/dist/index.js +25 -13
  7. package/dist/plugins/actions-chain.d.ts +48 -0
  8. package/dist/plugins/actions-chain.js +251 -0
  9. package/dist/plugins/actions-plugin.d.ts +40 -0
  10. package/dist/plugins/actions-plugin.js +76 -0
  11. package/dist/plugins/actors-chain.d.ts +52 -0
  12. package/dist/plugins/actors-chain.js +204 -0
  13. package/dist/plugins/actors-plugin.d.ts +36 -0
  14. package/dist/plugins/actors-plugin.js +62 -0
  15. package/dist/plugins/dlq-plugin.d.ts +29 -0
  16. package/dist/plugins/dlq-plugin.js +37 -0
  17. package/dist/plugins/idempotency-plugin.d.ts +28 -0
  18. package/dist/plugins/idempotency-plugin.js +57 -0
  19. package/dist/plugins/projections-chain.d.ts +34 -0
  20. package/dist/plugins/projections-chain.js +86 -0
  21. package/dist/plugins/projections-plugin.d.ts +36 -0
  22. package/dist/plugins/projections-plugin.js +63 -0
  23. package/dist/plugins/queries-chain.d.ts +33 -0
  24. package/dist/plugins/queries-chain.js +77 -0
  25. package/dist/plugins/queries-plugin.d.ts +41 -0
  26. package/dist/plugins/queries-plugin.js +74 -0
  27. package/dist/plugins/workflows-chain.d.ts +51 -0
  28. package/dist/plugins/workflows-chain.js +203 -0
  29. package/dist/plugins/workflows-plugin.d.ts +47 -0
  30. package/dist/plugins/workflows-plugin.js +81 -0
  31. package/dist/runtime/create-forge-app.d.ts +11 -11
  32. package/dist/runtime/create-forge-app.js +28 -32
  33. package/dist/runtime/forge-dispatcher.d.ts +24 -0
  34. package/dist/runtime/forge-dispatcher.js +76 -22
  35. package/dist/runtime/forge-plugin.d.ts +57 -42
  36. package/dist/runtime/forge-plugin.js +72 -59
  37. package/dist/runtime/with-forge.d.ts +26 -0
  38. package/dist/runtime/with-forge.js +30 -0
  39. package/dist/stores/idempotency-store.d.ts +15 -0
  40. package/dist/stores/idempotency-store.js +7 -0
  41. package/package.json +11 -11
@@ -1,55 +1,52 @@
1
1
  /**
2
- * `forgePlugin` — the foundation-form delivery of forge's CQRS engine.
2
+ * Forge plugin installs the dispatcher, materialises forge's framework
3
+ * hooks, pins the action handler step on the dispatch chain, and registers
4
+ * any domain primitives passed in options.
3
5
  *
4
- * Used two ways:
6
+ * import { createApp } from "@nwire/app";
7
+ * import { createForgePlugin, FORGE_DISPATCHER_BINDING } from "@nwire/forge";
5
8
  *
6
- * // Default stores (InMemory*):
7
- * createApp({ plugins: [forgePlugin, ...] })
9
+ * const app = createApp({
10
+ * appName: "orders",
11
+ * plugins: [createForgePlugin({
12
+ * actors: [Order],
13
+ * projections: [OrdersDashboard],
14
+ * handlers: [placeOrder, sendReceipt],
15
+ * })],
16
+ * });
8
17
  *
9
- * // Custom stores / cross-service bus / persistent timers:
10
- * createApp({ plugins: [createForgePlugin({ actorStore, bus }), ...] })
18
+ * await app.start();
19
+ * const dispatcher = app.container.resolve(FORGE_DISPATCHER_BINDING);
11
20
  *
12
- * Setup work:
13
- * 1. Construct a `ForgeDispatcher(runtime, opts)`.
14
- * 2. Bind it on the container as `"forge.dispatcher"`; consumer code
15
- * (and forge.createApp's app-handle delegators) resolves it from
16
- * there to call `dispatch / publish / fireDueTimers / …`.
17
- * 3. Materialise the Action* / Event* framework-hook slots forge's
18
- * module augmentation declares — kernel pre-instantiates only
19
- * App* / Plugin* / Wire* slots; forge's domain slots come in via
20
- * the plugin so a non-forge app's runtime stays uncluttered.
21
- * 4. Pin the action handler chain step on `runtime.dispatchHook$` at
22
- * priority `-Infinity` so user-registered `runtime.use()` middleware
23
- * stays strictly OUTSIDE the handler invocation.
21
+ * Handlers and direct registrations land on the dispatcher during the
22
+ * `AppBooting` chain — after every plugin's setup ran (so the dispatcher
23
+ * is bound) but before plugin boot queues fire (so downstream plugins'
24
+ * boot work can reach domain routes already wired).
24
25
  *
25
- * Dispose work:
26
- * - Best-effort cleanup of the in-memory workflow timer scheduler if
27
- * the dispatcher started one. Persistent stores release through
28
- * their own `bind({ dispose })` registrations.
26
+ * `forgePlugin` is the default-options shortcut for tests, demos, and
27
+ * single-process apps. For custom stores or a cross-service bus, use
28
+ * `createForgePlugin(opts)`.
29
29
  */
30
30
  import { ForgeDispatcher } from "./forge-dispatcher.js";
31
- /** Canonical container binding name for the forge dispatcher. */
31
+ /** Container binding the forge dispatcher is registered under. */
32
32
  export const FORGE_DISPATCHER_BINDING = "forge.dispatcher";
33
- /** Capability bindings handler / resolver ctx resolves these by name. */
33
+ /** Capability bindings consumed by handler / resolver ctx via `ctx.resolve(...)`. */
34
34
  export const FORGE_EXECUTE_BINDING = "execute";
35
35
  export const FORGE_SEND_BINDING = "send";
36
36
  export const FORGE_USE_PROJECTION_BINDING = "useProjection";
37
37
  /**
38
- * Default forge plugin uses in-memory stores for everything.
39
- * Suitable for tests, demos, and single-process apps. For custom
40
- * stores or a cross-service bus, use `createForgePlugin(opts)`.
38
+ * Construct a forge plugin with custom stores, bus, and/or domain
39
+ * primitives. The plugin's setup is synchronous; domain registration
40
+ * happens during the App's `AppBooting` chain.
41
41
  */
42
- export const forgePlugin = createForgePlugin();
43
42
  export function createForgePlugin(options = {}) {
44
43
  return {
45
44
  name: "forge",
46
45
  setup({ runtime, bind, dispose, hooks }) {
47
46
  const dispatcher = new ForgeDispatcher(runtime, options);
48
- // 1. Bind the dispatcher on the container so the app handle's
49
- // `dispatch / publish / request / …` delegators can resolve it.
50
47
  bind(FORGE_DISPATCHER_BINDING, dispatcher);
51
- // 1b. Bind capability factories what handlers / HTTP resolvers
52
- // reach for via `ctx.resolve("execute" | "send" | "useProjection")`.
48
+ // Capability factories for handler / HTTP resolver ctx
49
+ // (`ctx.resolve("execute" | "send" | "useProjection")`).
53
50
  const execute = (action, input, envelope) => dispatcher.dispatch(action, input, envelope);
54
51
  const send = (action, input, envelope) => {
55
52
  void dispatcher.dispatch(action, input, envelope);
@@ -60,62 +57,78 @@ export function createForgePlugin(options = {}) {
60
57
  bind(FORGE_EXECUTE_BINDING, () => execute);
61
58
  bind(FORGE_SEND_BINDING, () => send);
62
59
  bind(FORGE_USE_PROJECTION_BINDING, () => useProjection);
63
- // 2. Materialise forge's Action* / Event* framework-hook slots.
64
- // The kernel pre-instantiates App* / Plugin* / Wire*; forge's
65
- // augmentation gets installed here so a runtime without forge
66
- // doesn't carry empty Action*/Event* hooks.
67
- for (const slot of ["ActionDispatching", "ActionCompleted", "ActionFailed", "EventRecording", "EventRecorded"]) {
60
+ // Materialise forge's Action* / Event* hook slots and the
61
+ // EventPublishing chain. Apps without forge don't carry these.
62
+ for (const slot of [
63
+ "ActionDispatching",
64
+ "ActionCompleted",
65
+ "ActionFailed",
66
+ "EventRecording",
67
+ "EventRecorded",
68
+ "EventPublishing",
69
+ ]) {
68
70
  runtime.defineHook(slot);
69
71
  }
70
- // 3. Pin the dispatch chain step. priority -Infinity keeps every
71
- // user `runtime.use()` middleware strictly outside it; the
72
- // pinned step calls the dispatcher's prepared `coreFn` and
73
- // writes the result back onto `hctx.result`.
72
+ // Attach forge's atomic publish chain to the EventPublishing hook.
73
+ // Each step does its work for one event and calls next() to descend
74
+ // to the next priority slot. The idempotency step short-circuits
75
+ // (skips next) when a duplicate is seen.
76
+ dispatcher.attachPublishChain();
77
+ // Pin the dispatch chain handler step at priority -Infinity so any
78
+ // user `runtime.use()` middleware stays strictly outside the
79
+ // handler invocation.
74
80
  runtime.dispatchHook$.use(async (hctx, next) => {
75
81
  hctx.result = await hctx.coreFn();
76
82
  await next();
77
83
  }, { name: "handler", priority: -Infinity });
78
- // 3a. Forward runtime-registered forge handlers to the dispatcher
79
- // at AppBooting time. createApp's `handlers` option lands them on
80
- // runtime.handlers; the dispatcher needs its own registration to
81
- // route by action name. We pick them up by checking for `$kind
82
- // === "handler"` (forge HandlerDefinition shape) — anything else
83
- // stays on the runtime side.
84
+ // Domain registration runs at AppBooting after every plugin's
85
+ // setup ran (so the dispatcher is bound) but before plugin boot
86
+ // queues fire (so downstream plugins' boot work can reach domain
87
+ // routes already wired).
84
88
  hooks.AppBooting.use(async (_, next) => {
85
- // Forward runtime-registered forge handlers to the dispatcher.
89
+ // Forward any runtime-registered handlers to the dispatcher.
86
90
  for (const name of runtime.listHandlers()) {
87
91
  const def = runtime.getHandler(name);
88
92
  if (def?.$kind === "handler") {
89
93
  dispatcher.registerActionHandler(def);
90
94
  }
91
95
  }
92
- // Register plugin-option domain primitives.
96
+ // Register option-declared primitives.
97
+ for (const handler of options.handlers ?? []) {
98
+ dispatcher.registerActionHandler(handler);
99
+ }
93
100
  for (const actor of options.actors ?? []) {
94
101
  dispatcher.registerActor(actor);
95
102
  }
96
- for (const workflow of options.workflows ?? []) {
97
- dispatcher.registerWorkflow(workflow);
98
- }
99
103
  for (const projection of options.projections ?? []) {
100
104
  dispatcher.registerProjection(projection);
101
105
  }
102
106
  for (const query of options.queries ?? []) {
103
107
  dispatcher.registerQuery(query);
104
108
  }
109
+ for (const workflow of options.workflows ?? []) {
110
+ dispatcher.registerWorkflow(workflow);
111
+ }
105
112
  for (const call of options.externalCalls ?? []) {
106
113
  dispatcher.registerExternalCall(call);
107
114
  }
108
115
  await next();
109
- }, { name: "forge.forward-handlers", priority: 1_000_000 });
110
- // 4. Graceful shutdown close any internally-managed schedulers.
111
- // The in-memory timer store has no thread to stop; durable
112
- // adapters that DO own threads pass themselves in via opts and
113
- // handle their own dispose via `bind(name, store, { dispose })`.
116
+ }, { name: "forge.register-domain", priority: 1_000_000 });
117
+ // Cleanup hookdurable adopters with their own dispose schedules
118
+ // register via `bind(name, store, { dispose })`.
114
119
  dispose(async () => {
115
- // No-op for in-memory; reserved for future timer-scheduler
116
- // ownership semantics.
117
120
  void dispatcher;
118
121
  });
119
122
  },
120
123
  };
121
124
  }
125
+ /** Default forge plugin — in-memory stores, no bus, no domain options. */
126
+ export const forgePlugin = createForgePlugin();
127
+ /**
128
+ * Resolve the forge dispatcher from an app's container. Convenience for
129
+ * test code and tooling that needs direct dispatcher access; handler code
130
+ * uses `ctx.execute` / `ctx.send` / `ctx.publish` instead.
131
+ */
132
+ export function forgeDispatcher(app) {
133
+ return app.container.resolve(FORGE_DISPATCHER_BINDING);
134
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `withForge(opts)` — App-transformer that installs forge.
3
+ *
4
+ * A plain function `App → App` that calls `app.with(createForgePlugin(opts))`
5
+ * under the hood. Use it as the canonical install path for forge:
6
+ *
7
+ * const app = withForge({
8
+ * actors: [Order],
9
+ * projections: [OrdersDashboard],
10
+ * actions: [placeOrder, sendReceipt],
11
+ * })(createApp({ appName: "orders" }));
12
+ *
13
+ * Equivalent to writing the chain by hand:
14
+ *
15
+ * const app = createApp({ appName: "orders" })
16
+ * .with(createForgePlugin({ actors: [Order], ... }));
17
+ *
18
+ * Use whichever reads better at the call site.
19
+ */
20
+ import type { App } from "@nwire/app";
21
+ import { type ForgePluginOptions } from "./forge-plugin.js";
22
+ /**
23
+ * Returns an App transformer that installs forge into any App it's
24
+ * applied to. The transformer preserves the existing `<TCaps>`.
25
+ */
26
+ export declare function withForge(opts?: ForgePluginOptions): <TCaps>(app: App<TCaps>) => App<TCaps>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `withForge(opts)` — App-transformer that installs forge.
3
+ *
4
+ * A plain function `App → App` that calls `app.with(createForgePlugin(opts))`
5
+ * under the hood. Use it as the canonical install path for forge:
6
+ *
7
+ * const app = withForge({
8
+ * actors: [Order],
9
+ * projections: [OrdersDashboard],
10
+ * actions: [placeOrder, sendReceipt],
11
+ * })(createApp({ appName: "orders" }));
12
+ *
13
+ * Equivalent to writing the chain by hand:
14
+ *
15
+ * const app = createApp({ appName: "orders" })
16
+ * .with(createForgePlugin({ actors: [Order], ... }));
17
+ *
18
+ * Use whichever reads better at the call site.
19
+ */
20
+ import { createForgePlugin } from "./forge-plugin.js";
21
+ /**
22
+ * Returns an App transformer that installs forge into any App it's
23
+ * applied to. The transformer preserves the existing `<TCaps>`.
24
+ */
25
+ export function withForge(opts = {}) {
26
+ return (app) => {
27
+ app.with(createForgePlugin(opts));
28
+ return app;
29
+ };
30
+ }
@@ -22,11 +22,26 @@ export interface IdempotencyStore {
22
22
  record(messageId: string, opts?: {
23
23
  ttlMs?: number;
24
24
  }): void | Promise<void>;
25
+ /**
26
+ * Atomic check-and-record. Returns true on the first call for a given
27
+ * key and false on subsequent calls. Used by the EventPublishing
28
+ * idempotency step to dedup correctly under concurrent publishes.
29
+ *
30
+ * Adapters backed by Redis / Postgres / etc. should implement this as
31
+ * a single round-trip (SETNX, INSERT … ON CONFLICT, etc.). The
32
+ * in-memory default does it synchronously on the underlying Set so
33
+ * concurrent JS callers can't race.
34
+ */
35
+ recordIfNew(messageId: string, opts?: {
36
+ ttlMs?: number;
37
+ }): boolean | Promise<boolean>;
25
38
  }
26
39
  export declare class InMemoryIdempotencyStore implements IdempotencyStore {
27
40
  private readonly entries;
28
41
  seen(messageId: string): boolean;
29
42
  record(messageId: string): void;
43
+ /** Atomic on a single-threaded event loop: check + add in one synchronous step. */
44
+ recordIfNew(messageId: string): boolean;
30
45
  /** Test seam — count of recorded ids. */
31
46
  size(): number;
32
47
  /** Test seam — drop all entries. */
@@ -20,6 +20,13 @@ export class InMemoryIdempotencyStore {
20
20
  record(messageId) {
21
21
  this.entries.add(messageId);
22
22
  }
23
+ /** Atomic on a single-threaded event loop: check + add in one synchronous step. */
24
+ recordIfNew(messageId) {
25
+ if (this.entries.has(messageId))
26
+ return false;
27
+ this.entries.add(messageId);
28
+ return true;
29
+ }
23
30
  /** Test seam — count of recorded ids. */
24
31
  size() {
25
32
  return this.entries.size;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwire/forge",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Nwire — the framework's core primitives. defineAction, defineEvent, defineHandler, defineActor, defineProjection, defineQuery, defineWorkflow, defineModule, defineApp, definePlugin, createApp. MessageEnvelope with correlation/causation. The runtime is the bus.",
5
5
  "keywords": [
6
6
  "actions",
@@ -34,16 +34,16 @@
34
34
  "emittery": "1.0.1",
35
35
  "koa": "^2.16.4",
36
36
  "zod": "^4.0.0",
37
- "@nwire/app": "0.10.0",
38
- "@nwire/bus": "0.10.0",
39
- "@nwire/dead-letter": "0.10.0",
40
- "@nwire/hooks": "0.10.0",
41
- "@nwire/container": "0.10.0",
42
- "@nwire/handler": "0.10.0",
43
- "@nwire/logger": "0.10.0",
44
- "@nwire/wires": "0.10.0",
45
- "@nwire/messages": "0.10.0",
46
- "@nwire/envelope": "0.10.0"
37
+ "@nwire/app": "0.11.0",
38
+ "@nwire/bus": "0.11.0",
39
+ "@nwire/dead-letter": "0.11.0",
40
+ "@nwire/handler": "0.11.0",
41
+ "@nwire/container": "0.11.0",
42
+ "@nwire/messages": "0.11.0",
43
+ "@nwire/wires": "0.11.0",
44
+ "@nwire/logger": "0.11.0",
45
+ "@nwire/envelope": "0.11.0",
46
+ "@nwire/hooks": "0.11.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/koa": "^2.15.0",