@reventlessdev/reventless-core 3.0.0-alpha.214 → 3.0.0-alpha.216

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 (36) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +7 -7
  3. package/src/adapter/RuntimeExtension/RuntimeExtension.res +194 -0
  4. package/src/adapter/RuntimeExtension/RuntimeExtension.res.mjs +60 -0
  5. package/src/admin/Platform_Admin_Structure.res +4 -0
  6. package/src/admin/Platform_Admin_Structure.res.mjs +5 -0
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +16 -1
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +24 -1
  9. package/src/components/Aggregate/Aggregate_Callback.res +14 -2
  10. package/src/components/Aggregate/Aggregate_Callback.res.mjs +3 -3
  11. package/src/components/CommandTopic/CommandTopic.res.mjs +12 -0
  12. package/src/components/CommandTopic/CommandTopic_Helpers.res +88 -2
  13. package/src/components/CommandTopic/CommandTopic_Helpers.res.mjs +48 -2
  14. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +13 -1
  15. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +4 -4
  16. package/src/plugin/component/Plugin_Builder.res +1 -0
  17. package/src/plugin/component/Plugin_Builder.res.mjs +1 -0
  18. package/src/plugin/component/Plugin_Structure.res +66 -51
  19. package/src/plugin/component/Plugin_Structure.res.mjs +61 -46
  20. package/tests/adapter/RuntimeExtensionHookReachTest.res +84 -0
  21. package/tests/adapter/RuntimeExtensionHookReachTest.res.mjs +95 -0
  22. package/tests/adapter/RuntimeExtensionTest.res +127 -0
  23. package/tests/adapter/RuntimeExtensionTest.res.mjs +111 -0
  24. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +21 -0
  25. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +21 -0
  26. package/tests/commandtopic/CommandOutcomeHookTest.res +258 -0
  27. package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +372 -0
  28. package/tests/commandtopic/CommandTopicHelpersRejectionTest.res +9 -1
  29. package/tests/commandtopic/CommandTopicHelpersRejectionTest.res.mjs +3 -3
  30. package/tests/message/MessageTest.res +1 -0
  31. package/tests/message/MessageTest.res.mjs +1 -0
  32. package/tests/plugin/PluginStructureTest.res +27 -0
  33. package/tests/plugin/PluginStructureTest.res.mjs +27 -0
  34. package/tests/plugin/StateChangeSlice/PsShipOrder.res +3 -1
  35. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -1
  36. package/tests/plugin/pluginDefinitionRequiredScalars.txt +8 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.216 (2026-08-08)
7
+
8
+ ### Features
9
+
10
+ * **core,spec:** surface a write-side component's declared error types ([c9c2505](https://github.com/ReventlessDev/reventless-core/commit/c9c25057c70595fe27d73447c9aef9b451f86168))
11
+ * **core:** add a runtime hook for command outcomes ([f2092a8](https://github.com/ReventlessDev/reventless-core/commit/f2092a8f9a8d8d7feee4f3de19bea7297a250cbd))
12
+
13
+
14
+ # 3.0.0-alpha.215 (2026-08-08)
15
+
16
+ ### Features
17
+
18
+ * **core,aws,local:** add the RuntimeExtension cold-start seam ([143e30e](https://github.com/ReventlessDev/reventless-core/commit/143e30eeded6232ce7f0fcc18328939b2917bb31))
19
+
20
+
6
21
  # 3.0.0-alpha.214 (2026-08-07)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.214",
3
+ "version": "3.0.0-alpha.216",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -28,17 +28,17 @@
28
28
  "dependencies": {
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
- "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
33
31
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.2",
32
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
34
33
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
34
+ "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
35
+ "@reventlessdev/rescript-node": "2.0.0-alpha.2",
35
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
36
- "@reventlessdev/rescript-ssh2": "2.0.0-alpha.2",
37
37
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
38
- "@reventlessdev/rescript-node": "2.0.0-alpha.2",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.127",
38
+ "@reventlessdev/rescript-ssh2": "2.0.0-alpha.2",
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.128",
40
40
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.101"
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.102"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
@@ -0,0 +1,194 @@
1
+ /**
2
+ Runtime hook: every provisioned runtime announces its cold start, so an extension
3
+ can register the runtime callback hooks the framework already ships — before the
4
+ runtime handles its first request.
5
+
6
+ No-op by default. Zero behavioral change unless an extension registers one via
7
+ `use` before the platform/plugin build: nothing is bundled, no env var is set, and
8
+ no code runs.
9
+
10
+ `Monitoring` and `EventLogProvisioning` are the deploy-time half of this shape —
11
+ they let an extension attach behaviour to *what gets provisioned*. This is the
12
+ runtime half: *what runs inside it*. Without it,
13
+ `CommandGenerator_Callback.registerCommandInterceptor`,
14
+ `QueryDb_Callback.registerQueryInterceptor` and the two
15
+ `EventPublish_Callback` publish hooks are unreachable in a deployed runtime —
16
+ each is a module-level `ref` consulted on the hot path, so it is useless unless
17
+ something calls the registrar in the same process first, and the framework-owned
18
+ entry shells import only framework and domain modules. Per-command authorisation,
19
+ request tracing, rate limiting and request accounting are all built from those
20
+ hooks, and each one is otherwise a change to the framework itself.
21
+
22
+ What an extension does at cold start is deliberately NOT a framework concern.
23
+ This only exposes the choke point through which the framework says "a runtime of
24
+ this kind just started, here is who it is" and leaves the reaction to the
25
+ listener.
26
+
27
+ See `docs/plans/done/runtime-extension-seam.md`.
28
+ */
29
+
30
+ /**
31
+ What every registered extension is handed at cold start. Kept to the runtime's
32
+ IDENTITY on purpose: an extension registering a command interceptor needs to know
33
+ which runtime it is inside, not that runtime's storage ops or specs. Widening this
34
+ to internals would make it a second, informal handler API — and the four hooks it
35
+ exists to reach already receive their own per-request payloads
36
+ (`commandInterceptor` gets `~componentName` / `~tag` / `~args` on every call).
37
+
38
+ `~runtimeKind` is the modelling kind of the component the runtime runs — the same
39
+ `ComponentType.t` every AWS runtime builder already passes to
40
+ `RuntimeEnvironment_Lambda.makeFromCodeAsset`, so there is no third taxonomy to
41
+ keep in step with `Monitoring.unitKind` and `ResourceAttribution.Role`.
42
+
43
+ `~component` is the runtime's STATIC logical name — the shared Lambda's name where
44
+ several components of one kind share a runtime (`AllAggregatesCmdHandler`), the
45
+ component's own name where it does not. It identifies the runtime, not a single
46
+ model element; use the per-request `~componentName` on the interception hooks to
47
+ tell hosted components apart.
48
+
49
+ `~plugin` / `~platform` are the owning plugin and platform, taken at provisioning
50
+ time from the ambient `ResourceAttribution` context (the same source `AWS_Tags`,
51
+ `Monitoring.notify` and `EventLogProvisioning.notify` use) and carried into the
52
+ runtime as configuration — the runtime has no ambient deploy context of its own.
53
+ Both are `None` for a runtime provisioned outside any plugin construct.
54
+ */
55
+ type coldStartHook = (
56
+ ~runtimeKind: ComponentType.t,
57
+ ~component: string,
58
+ ~plugin: option<string>,
59
+ ~platform: option<string>,
60
+ ) => unit
61
+
62
+ /**
63
+ A runtime extension, registered by an extension (a deploy program) before the
64
+ platform builds. `onColdStart` is called once per runtime process, before that
65
+ runtime handles its first request.
66
+
67
+ `moduleUrl` is the extension module's own `import.meta.url`. It is what makes the
68
+ seam reachable in a *deployed* runtime: registering a first-class module only
69
+ populates the deploy program's process, and the Lambda is a different one, so the
70
+ framework bundles the module's package into the code archive and names the module
71
+ in the runtime's config for the entry shell to import. Spec and behavior modules
72
+ are carried into runtimes the same way. The convention matches
73
+ `@@reventless.spec`'s injected `let moduleUrl`, so a ReScript extension can lift
74
+ it from there.
75
+
76
+ **Synchronous.** An async hook would have to be awaited before the first request
77
+ and would put extension latency on every cold path. An extension needing I/O can
78
+ start it here and not block on it.
79
+
80
+ **Fired once, in registration order.** Unlike `EventLogProvisioning` there is no
81
+ scarce resource here (no stream-reader budget), so several extensions compose —
82
+ tracing and accounting are independent concerns. They run in the order they were
83
+ registered; an extension that depends on another having run first is relying on
84
+ its host's statement order, which is the only ordering the framework can promise.
85
+
86
+ **A throwing extension does not take the runtime down.** Each hook is isolated:
87
+ the failure is logged at ERROR and the remaining extensions still run. Failing the
88
+ runtime instead would turn a broken extension into an outage, which is a worse
89
+ trade than a loud, skipped hook.
90
+ */
91
+ module type Extension = {
92
+ let moduleUrl: string
93
+ let onColdStart: coldStartHook
94
+ }
95
+
96
+ let extensions: ref<array<module(Extension)>> = ref([])
97
+
98
+ /**
99
+ Register a runtime extension. Must run before the platform/plugin build in the
100
+ deploy program (plain statement order — the registry is read when each runtime's
101
+ code archive and config are built). Additive: call once per extension.
102
+
103
+ There is no `Noop` default to register. The neutral state is the empty registry,
104
+ which is also the state that bundles nothing and sets no env var; a registered
105
+ do-nothing extension would still cost a package in every runtime's archive.
106
+ */
107
+ let use = (e: module(Extension)) => extensions := extensions.contents->Array.concat([e])
108
+
109
+ /**
110
+ Drop every registration. Test-support only — deploy programs never call this.
111
+ */
112
+ let reset = () => extensions := []
113
+
114
+ /** True when no extension is registered — the default. Deploy-time backends use
115
+ this to skip bundling and config emission entirely, so a deployment that
116
+ registers nothing produces a byte-identical archive. */
117
+ let isEmpty = () => extensions.contents->Array.length == 0
118
+
119
+ /**
120
+ The `import.meta.url` of every registered extension, in registration order. Read
121
+ by the provider's code-archive builder to bundle each extension's package and to
122
+ name the modules a deployed runtime imports at cold start.
123
+ */
124
+ let moduleUrls = (): array<string> =>
125
+ extensions.contents->Array.map(e => {
126
+ module E = unpack(e)
127
+ E.moduleUrl
128
+ })
129
+
130
+ let log = Logger.fromEnv()
131
+
132
+ /**
133
+ Run one hook, isolated. A throwing extension is logged at ERROR and skipped; the
134
+ caller keeps going. See the failure-policy note on `Extension`.
135
+ */
136
+ let runHook = (
137
+ hook: coldStartHook,
138
+ ~index: int,
139
+ ~runtimeKind: ComponentType.t,
140
+ ~component: string,
141
+ ~plugin: option<string>,
142
+ ~platform: option<string>,
143
+ ) =>
144
+ try hook(~runtimeKind, ~component, ~plugin, ~platform) catch {
145
+ | exn =>
146
+ let message =
147
+ exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown error")
148
+ log.error(
149
+ ~comp="RuntimeExtension",
150
+ `extension ${index->Int.toString} threw at cold start of ${runtimeKind->ComponentType.toString}(${component}); skipped: ${message}`,
151
+ )
152
+ }
153
+
154
+ /**
155
+ Fire the given cold-start hooks, in order, each isolated from the others. Used by
156
+ runtimes that resolve their extensions themselves — a deployed entry point loads
157
+ its extension modules dynamically, so it holds hooks rather than first-class
158
+ modules.
159
+ */
160
+ let notifyColdStartHooks = (
161
+ ~hooks: array<coldStartHook>,
162
+ ~runtimeKind: ComponentType.t,
163
+ ~component: string,
164
+ ~plugin: option<string>,
165
+ ~platform: option<string>,
166
+ ) =>
167
+ hooks->Array.forEachWithIndex((hook, index) =>
168
+ hook->runHook(~index, ~runtimeKind, ~component, ~plugin, ~platform)
169
+ )
170
+
171
+ /**
172
+ Announce a runtime's cold start to every extension registered in THIS process.
173
+ Called by in-process runtimes (the local platform), where the deploy program and
174
+ the runtime share a process so the registry is already populated. A deployed
175
+ runtime cannot use this — its registry is empty until it imports the extension
176
+ modules named in its config — and calls `notifyColdStartHooks` instead. No-op
177
+ until an extension registers.
178
+ */
179
+ let notifyColdStart = (
180
+ ~runtimeKind: ComponentType.t,
181
+ ~component: string,
182
+ ~plugin: option<string>,
183
+ ~platform: option<string>,
184
+ ) =>
185
+ notifyColdStartHooks(
186
+ ~hooks=extensions.contents->Array.map(e => {
187
+ module E = unpack(e)
188
+ E.onColdStart
189
+ }),
190
+ ~runtimeKind,
191
+ ~component,
192
+ ~plugin,
193
+ ~platform,
194
+ )
@@ -0,0 +1,60 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
6
+ import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
7
+ import * as ComponentType$ReventlessCore from "../../ComponentType.res.mjs";
8
+
9
+ let extensions = {
10
+ contents: []
11
+ };
12
+
13
+ function use(e) {
14
+ extensions.contents = extensions.contents.concat([e]);
15
+ }
16
+
17
+ function reset() {
18
+ extensions.contents = [];
19
+ }
20
+
21
+ function isEmpty() {
22
+ return extensions.contents.length === 0;
23
+ }
24
+
25
+ function moduleUrls() {
26
+ return extensions.contents.map(e => e.moduleUrl);
27
+ }
28
+
29
+ let log = Logger$ReventlessCore.fromEnv();
30
+
31
+ function runHook(hook, index, runtimeKind, component, plugin, platform) {
32
+ try {
33
+ return hook(runtimeKind, component, plugin, platform);
34
+ } catch (raw_exn) {
35
+ let exn = Primitive_exceptions.internalToException(raw_exn);
36
+ let message = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown error");
37
+ return log.error("RuntimeExtension", undefined, `extension ` + index.toString() + ` threw at cold start of ` + ComponentType$ReventlessCore.toString(runtimeKind) + `(` + component + `); skipped: ` + message);
38
+ }
39
+ }
40
+
41
+ function notifyColdStartHooks(hooks, runtimeKind, component, plugin, platform) {
42
+ hooks.forEach((hook, index) => runHook(hook, index, runtimeKind, component, plugin, platform));
43
+ }
44
+
45
+ function notifyColdStart(runtimeKind, component, plugin, platform) {
46
+ notifyColdStartHooks(extensions.contents.map(e => e.onColdStart), runtimeKind, component, plugin, platform);
47
+ }
48
+
49
+ export {
50
+ extensions,
51
+ use,
52
+ reset,
53
+ isEmpty,
54
+ moduleUrls,
55
+ log,
56
+ runHook,
57
+ notifyColdStartHooks,
58
+ notifyColdStart,
59
+ }
60
+ /* log Not a pure module */
@@ -98,6 +98,10 @@ let pluginAggregate: writableDef = {
98
98
  linkedViews: ["Plugins"],
99
99
  consistencyRead: None,
100
100
  events: [],
101
+ // Derived rather than left empty like `events` above: the two admin commands can
102
+ // be refused, and `[]` here would read as "this aggregate never rejects". Uses
103
+ // the same walk `Plugin_Structure` applies to every other write side.
104
+ errors: Plugin_Structure.extractErrorDefs(PluginSpec.errorSchema->S.castToUnknown),
101
105
  chapter: None,
102
106
  }
103
107
 
@@ -7,6 +7,8 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
7
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
8
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
9
9
  import * as Api_Naming$ReventlessCore from "../components/Api/Api_Naming.res.mjs";
10
+ import * as PluginSpec$ReventlessCore from "../plugin/lifecycle/PluginSpec.res.mjs";
11
+ import * as Plugin_Structure$ReventlessCore from "../plugin/component/Plugin_Structure.res.mjs";
10
12
  import * as SuryToJsonSchema$ReventlessCore from "../components/Api/SuryToJsonSchema.res.mjs";
11
13
  import * as PluginBaseFragment$ReventlessCore from "../plugin/api/PluginBaseFragment.res.mjs";
12
14
  import * as PluginsReadModelSpec$ReventlessCore from "../plugin/lifecycle/PluginsReadModelSpec.res.mjs";
@@ -105,6 +107,8 @@ let pluginAggregate_linkedViews = ["Plugins"];
105
107
 
106
108
  let pluginAggregate_events = [];
107
109
 
110
+ let pluginAggregate_errors = Plugin_Structure$ReventlessCore.extractErrorDefs(PluginSpec$ReventlessCore.errorSchema);
111
+
108
112
  let pluginAggregate = {
109
113
  name: "Plugin",
110
114
  commands: pluginAggregate_commands,
@@ -113,6 +117,7 @@ let pluginAggregate = {
113
117
  linkedViews: pluginAggregate_linkedViews,
114
118
  consistencyRead: undefined,
115
119
  events: pluginAggregate_events,
120
+ errors: pluginAggregate_errors,
116
121
  chapter: undefined
117
122
  };
118
123
 
@@ -20,7 +20,11 @@ let sdlTypes: array<string> = [
20
20
  `type Platform_FieldReference {\n fieldName: String!\n entity: String!\n plugin: String\n}`,
21
21
  `type Platform_CommandDef {\n name: String!\n schema: String!\n level: String!\n aggregateIdField: String\n mutationField: String!\n references: [Platform_FieldReference!]!\n allowedStates: [String!]\n targetState: String\n apiExposed: Boolean\n}`,
22
22
  `type Platform_EventDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
23
- `type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n chapter: String\n}`,
23
+ // Same fields as Platform_EventDef, kept a distinct type because a refusal is not
24
+ // a fact: a caller selecting `errors` is asking what a command can be rejected
25
+ // with, and the two lists must stay independently evolvable.
26
+ `type Platform_ErrorDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
27
+ `type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n errors: [Platform_ErrorDef!]!\n chapter: String\n}`,
24
28
  `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n statusField: String\n visibility: String\n chapter: String\n}`,
25
29
  `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
26
30
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
@@ -102,6 +106,13 @@ let encodeEventDef = (e: eventDef): JSON.t =>
102
106
  ("references", e.references->Array.map(encodeFieldReference)->JSON.Encode.array),
103
107
  ])->JSON.Encode.object
104
108
 
109
+ let encodeErrorDef = (e: errorDef): JSON.t =>
110
+ Dict.fromArray([
111
+ ("name", JSON.Encode.string(e.name)),
112
+ ("schema", JSON.Encode.string(e.schema)),
113
+ ("references", e.references->Array.map(encodeFieldReference)->JSON.Encode.array),
114
+ ])->JSON.Encode.object
115
+
105
116
  let encodeWritableDef = (w: writableDef): JSON.t =>
106
117
  Dict.fromArray([
107
118
  ("name", JSON.Encode.string(w.name)),
@@ -115,6 +126,10 @@ let encodeWritableDef = (w: writableDef): JSON.t =>
115
126
  ("consumedEventTypes", encodeStrings(w.consumedEventTypes)),
116
127
  // Phase 6.3: emitted-event field schemas (None → [] on the wire).
117
128
  ("events", w.events->Array.map(encodeEventDef)->JSON.Encode.array),
129
+ // Declared errors — the refusals a caller has to handle. `[]` is the honest
130
+ // answer for a component that declares none; the structure is re-derived on
131
+ // every build, so it never stands in for "cannot say".
132
+ ("errors", w.errors->Array.map(encodeErrorDef)->JSON.Encode.array),
118
133
  ("chapter", w.chapter->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
119
134
  ])->JSON.Encode.object
120
135
 
@@ -8,7 +8,8 @@ let sdlTypes = [
8
8
  `type Platform_FieldReference {\n fieldName: String!\n entity: String!\n plugin: String\n}`,
9
9
  `type Platform_CommandDef {\n name: String!\n schema: String!\n level: String!\n aggregateIdField: String\n mutationField: String!\n references: [Platform_FieldReference!]!\n allowedStates: [String!]\n targetState: String\n apiExposed: Boolean\n}`,
10
10
  `type Platform_EventDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
11
- `type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n chapter: String\n}`,
11
+ `type Platform_ErrorDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
12
+ `type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n errors: [Platform_ErrorDef!]!\n chapter: String\n}`,
12
13
  `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n statusField: String\n visibility: String\n chapter: String\n}`,
13
14
  `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
14
15
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
@@ -152,6 +153,23 @@ function encodeEventDef(e) {
152
153
  ]);
153
154
  }
154
155
 
156
+ function encodeErrorDef(e) {
157
+ return Object.fromEntries([
158
+ [
159
+ "name",
160
+ e.name
161
+ ],
162
+ [
163
+ "schema",
164
+ e.schema
165
+ ],
166
+ [
167
+ "references",
168
+ e.references.map(encodeFieldReference)
169
+ ]
170
+ ]);
171
+ }
172
+
155
173
  function encodeWritableDef(w) {
156
174
  return Object.fromEntries([
157
175
  [
@@ -182,6 +200,10 @@ function encodeWritableDef(w) {
182
200
  "events",
183
201
  w.events.map(encodeEventDef)
184
202
  ],
203
+ [
204
+ "errors",
205
+ w.errors.map(encodeErrorDef)
206
+ ],
185
207
  [
186
208
  "chapter",
187
209
  Stdlib_Option.mapOr(w.chapter, null, prim => prim)
@@ -341,6 +363,7 @@ export {
341
363
  isPublicQueryable,
342
364
  encodeQueryableDef,
343
365
  encodeEventDef,
366
+ encodeErrorDef,
344
367
  encodeWritableDef,
345
368
  encodeAutomationSliceDef,
346
369
  encodeOutboundTranslationSliceDef,
@@ -284,13 +284,25 @@ module Make = (
284
284
  outcomes->Array.map(((reference, outcome, _meta)) =>
285
285
  switch outcome {
286
286
  | CmdRejected({errorCode, errorDetail}) =>
287
- CommandTopic_Helpers.reportRejected(reference, {errorCode, errorDetail})
287
+ CommandTopic_Helpers.reportRejected(
288
+ ~component=Spec.name,
289
+ ~cause=DomainRejection,
290
+ reference,
291
+ {errorCode, errorDetail},
292
+ )
288
293
  Ok(reference)
289
294
  | CmdOk(_) if appendSucceeded =>
290
- CommandTopic_Helpers.reportAccepted(reference, {entityId, eventCount: appendedEventCount})
295
+ CommandTopic_Helpers.reportAccepted(
296
+ ~component=Spec.name,
297
+ reference,
298
+ {entityId, eventCount: appendedEventCount},
299
+ )
291
300
  Ok(reference)
292
301
  | CmdOk(_) =>
302
+ // The decision succeeded and the append did not — infrastructure, not the model.
293
303
  CommandTopic_Helpers.reportRejected(
304
+ ~component=Spec.name,
305
+ ~cause=InfrastructureFailure,
294
306
  reference,
295
307
  {errorCode: "AppendFailed", errorDetail: appendErrorDetail},
296
308
  )
@@ -200,7 +200,7 @@ function Make(Spec) {
200
200
  let reference = param[0];
201
201
  if (outcome.TAG === "CmdOk") {
202
202
  if (appendSucceeded) {
203
- CommandTopic_Helpers$ReventlessCore.reportAccepted(reference, {
203
+ CommandTopic_Helpers$ReventlessCore.reportAccepted(Spec.name, reference, {
204
204
  entityId: entityId,
205
205
  eventCount: appendedEventCount
206
206
  });
@@ -209,7 +209,7 @@ function Make(Spec) {
209
209
  _0: reference
210
210
  };
211
211
  } else {
212
- CommandTopic_Helpers$ReventlessCore.reportRejected(reference, {
212
+ CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "InfrastructureFailure", reference, {
213
213
  errorCode: "AppendFailed",
214
214
  errorDetail: appendErrorDetail
215
215
  });
@@ -219,7 +219,7 @@ function Make(Spec) {
219
219
  };
220
220
  }
221
221
  }
222
- CommandTopic_Helpers$ReventlessCore.reportRejected(reference, {
222
+ CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "DomainRejection", reference, {
223
223
  errorCode: outcome.errorCode,
224
224
  errorDetail: outcome.errorDetail
225
225
  });
@@ -23,6 +23,14 @@ function filter(allCommandTopics, names) {
23
23
 
24
24
  let componentType = "CommandTopic";
25
25
 
26
+ let commandOutcomeHook = CommandTopic_Helpers$ReventlessCore.commandOutcomeHook;
27
+
28
+ let registerCommandOutcome = CommandTopic_Helpers$ReventlessCore.registerCommandOutcome;
29
+
30
+ let clearCommandOutcome = CommandTopic_Helpers$ReventlessCore.clearCommandOutcome;
31
+
32
+ let fireCommandOutcome = CommandTopic_Helpers$ReventlessCore.fireCommandOutcome;
33
+
26
34
  let acceptedResultChannel = CommandTopic_Helpers$ReventlessCore.acceptedResultChannel;
27
35
 
28
36
  let rejectedResultChannel = CommandTopic_Helpers$ReventlessCore.rejectedResultChannel;
@@ -50,6 +58,10 @@ let getHandlers = CommandTopic_Helpers$ReventlessCore.getHandlers;
50
58
  export {
51
59
  componentType,
52
60
  NotPublishedToChannel,
61
+ commandOutcomeHook,
62
+ registerCommandOutcome,
63
+ clearCommandOutcome,
64
+ fireCommandOutcome,
53
65
  acceptedResultChannel,
54
66
  rejectedResultChannel,
55
67
  reportAccepted,
@@ -18,6 +18,71 @@ is the JSON-stringified error payload (or empty string for payload-less variants
18
18
  */
19
19
  type rejectedResult = {errorCode: string, errorDetail: string}
20
20
 
21
+ /**
22
+ Why a command was refused.
23
+
24
+ `DomainRejection` is `Behavior.decide` returning `Error` — the model declining the command,
25
+ which is the model working. `InfrastructureFailure` is the decision never landing: an event-log
26
+ append that failed outright or exhausted its conflict retries. Both arrive on `reportRejected`,
27
+ so without this a consumer would read a broken event log as a business rejection.
28
+ */
29
+ type refusalCause = DomainRejection | InfrastructureFailure
30
+
31
+ /**
32
+ What became of one command. Constructors are prefixed to stay clear of `commandOutcome`'s
33
+ `Accepted`/`Rejected` below — that type is the producer-facing result shape (and the GraphQL
34
+ `CommandResult` union); this one is the observation, and carries the refusal cause it doesn't.
35
+ */
36
+ type observedOutcome =
37
+ | OutcomeAccepted({entityId: option<string>, eventCount: int})
38
+ | OutcomeRejected({errorCode: string, errorDetail: string, cause: refusalCause})
39
+
40
+ /** `reference` is the command's `meta.msgId` on every current transport. */
41
+ type commandOutcomeReport = {
42
+ component: string,
43
+ reference: string,
44
+ outcome: observedOutcome,
45
+ }
46
+
47
+ type commandOutcomeHook = commandOutcomeReport => unit
48
+
49
+ /**
50
+ Module-level outcome hook, in the shape the `RuntimeExtension` cold-start seam already reaches
51
+ — so an extension registers it from `onColdStart` alongside the interception and publish hooks,
52
+ and no new registration path is needed. None = no-op (default).
53
+ */
54
+ let commandOutcomeHook: ref<option<commandOutcomeHook>> = ref(None)
55
+
56
+ let registerCommandOutcome = (hook: commandOutcomeHook) => {
57
+ commandOutcomeHook.contents = Some(hook)
58
+ }
59
+
60
+ let clearCommandOutcome = () => {
61
+ commandOutcomeHook.contents = None
62
+ }
63
+
64
+ // Fired from reportAccepted/reportRejected below, which both write-side kinds call
65
+ // unconditionally — so this sees fire-and-forget dispatches too, not just the ones a
66
+ // producer awaited on the side-channels.
67
+ //
68
+ // A throwing hook is logged and swallowed: the outcome it observes has already happened,
69
+ // and letting an observer fail a completed command would make an extension an outage
70
+ // (the same failure policy the cold-start seam settled on).
71
+ let fireCommandOutcome = (~component: string, ~reference: string, outcome: observedOutcome) =>
72
+ commandOutcomeHook.contents->Option.forEach(hook =>
73
+ switch hook({component, reference, outcome}) {
74
+ | () => ()
75
+ | exception err =>
76
+ EffectLogger.logError(
77
+ ~comp=`CommandTopic(${component})`,
78
+ `command-outcome hook threw (ignored): ${err
79
+ ->JsExn.fromException
80
+ ->Option.flatMap(JsExn.message)
81
+ ->Option.getOr("unknown")}`,
82
+ )->Effect.runSync
83
+ }
84
+ )
85
+
21
86
  // Side-channel for publishJsonsAndWait result propagation.
22
87
  // Set by runInlineAndCollect before invoking the handler; cleared after.
23
88
  // Callback implementations call reportAccepted during inline dispatch.
@@ -28,11 +93,32 @@ let acceptedResultChannel: ref<option<(string, acceptedResult) => unit>> = ref(N
28
93
  // `Rejected` outcomes that carry the real error code and detail.
29
94
  let rejectedResultChannel: ref<option<(string, rejectedResult) => unit>> = ref(None)
30
95
 
31
- let reportAccepted = (reference: string, result: acceptedResult) =>
96
+ // `~component` is the owning component's `Spec.name`: an outcome isn't interpretable without
97
+ // knowing which component produced it, and the side-channel (keyed per inline dispatch) never
98
+ // had to say. `~cause` is required rather than defaulted so a new rejection site has to state
99
+ // which kind it is instead of silently reporting infrastructure as a domain decision.
100
+ let reportAccepted = (~component: string, reference: string, result: acceptedResult) => {
32
101
  acceptedResultChannel.contents->Option.forEach(cb => cb(reference, result))
102
+ fireCommandOutcome(
103
+ ~component,
104
+ ~reference,
105
+ OutcomeAccepted({entityId: result.entityId, eventCount: result.eventCount}),
106
+ )
107
+ }
33
108
 
34
- let reportRejected = (reference: string, result: rejectedResult) =>
109
+ let reportRejected = (
110
+ ~component: string,
111
+ ~cause: refusalCause,
112
+ reference: string,
113
+ result: rejectedResult,
114
+ ) => {
35
115
  rejectedResultChannel.contents->Option.forEach(cb => cb(reference, result))
116
+ fireCommandOutcome(
117
+ ~component,
118
+ ~reference,
119
+ OutcomeRejected({errorCode: result.errorCode, errorDetail: result.errorDetail, cause}),
120
+ )
121
+ }
36
122
 
37
123
  // Placed here (rather than CommandTopic.res) so runInlineAndCollect can use commandOutcome
38
124
  // without importing Adapter.res → @pulumi/pulumi. Re-exported by CommandTopic via include.