@reventlessdev/reventless-core 3.0.0-alpha.215 → 3.0.0-alpha.217

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 (46) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +6 -6
  3. package/src/ResourceAttribution.res +60 -4
  4. package/src/ResourceAttribution.res.mjs +26 -0
  5. package/src/adapter/QueryInterception/QueryInterception.res +74 -0
  6. package/src/adapter/QueryInterception/QueryInterception.res.mjs +26 -0
  7. package/src/adapter/ResourceAttribution_Deploytime.res +43 -0
  8. package/src/adapter/ResourceAttribution_Deploytime.res.mjs +20 -0
  9. package/src/admin/Platform_Admin_Structure.res +4 -0
  10. package/src/admin/Platform_Admin_Structure.res.mjs +5 -0
  11. package/src/admin/Platform_ComponentDefinitionsApi.res +16 -1
  12. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +24 -1
  13. package/src/components/Aggregate/Aggregate_Callback.res +14 -2
  14. package/src/components/Aggregate/Aggregate_Callback.res.mjs +3 -3
  15. package/src/components/Builder_Helpers.res +21 -3
  16. package/src/components/Builder_Helpers.res.mjs +4 -3
  17. package/src/components/CommandTopic/CommandTopic.res.mjs +12 -0
  18. package/src/components/CommandTopic/CommandTopic_Helpers.res +88 -2
  19. package/src/components/CommandTopic/CommandTopic_Helpers.res.mjs +48 -2
  20. package/src/components/ExtensionPoint/ExtensionPoint_Builder.res +4 -1
  21. package/src/components/ExtensionPoint/ExtensionPoint_Builder.res.mjs +2 -1
  22. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +13 -1
  23. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +4 -4
  24. package/src/plugin/component/Plugin_Builder.res +6 -1
  25. package/src/plugin/component/Plugin_Builder.res.mjs +4 -2
  26. package/src/plugin/component/Plugin_Helpers.res +7 -2
  27. package/src/plugin/component/Plugin_Helpers.res.mjs +4 -3
  28. package/src/plugin/component/Plugin_Structure.res +66 -51
  29. package/src/plugin/component/Plugin_Structure.res.mjs +61 -46
  30. package/tests/ResourceAttributionTest.res +115 -0
  31. package/tests/ResourceAttributionTest.res.mjs +106 -0
  32. package/tests/adapter/QueryInterceptionTest.res +55 -0
  33. package/tests/adapter/QueryInterceptionTest.res.mjs +49 -0
  34. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +21 -0
  35. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +21 -0
  36. package/tests/commandtopic/CommandOutcomeHookTest.res +258 -0
  37. package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +372 -0
  38. package/tests/commandtopic/CommandTopicHelpersRejectionTest.res +9 -1
  39. package/tests/commandtopic/CommandTopicHelpersRejectionTest.res.mjs +3 -3
  40. package/tests/message/MessageTest.res +1 -0
  41. package/tests/message/MessageTest.res.mjs +1 -0
  42. package/tests/plugin/PluginStructureTest.res +27 -0
  43. package/tests/plugin/PluginStructureTest.res.mjs +27 -0
  44. package/tests/plugin/StateChangeSlice/PsShipOrder.res +3 -1
  45. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -1
  46. package/tests/plugin/pluginDefinitionRequiredScalars.txt +8 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
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.217 (2026-08-08)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core:** attribute resources built after a builder's construct returns ([67d6d9c](https://github.com/ReventlessDev/reventless-core/commit/67d6d9c0ff3ff1d38cd9e8ad43a4b86121abf827))
11
+ ### Features
12
+
13
+ * **core:** let an extension switch query interception on, and provision it ([4523b10](https://github.com/ReventlessDev/reventless-core/commit/4523b10597f1a028eedccc42391b250b25a6ddd3))
14
+
15
+
16
+ # 3.0.0-alpha.216 (2026-08-08)
17
+
18
+ ### Features
19
+
20
+ * **core,spec:** surface a write-side component's declared error types ([c9c2505](https://github.com/ReventlessDev/reventless-core/commit/c9c25057c70595fe27d73447c9aef9b451f86168))
21
+ * **core:** add a runtime hook for command outcomes ([f2092a8](https://github.com/ReventlessDev/reventless-core/commit/f2092a8f9a8d8d7feee4f3de19bea7297a250cbd))
22
+
23
+
6
24
  # 3.0.0-alpha.215 (2026-08-08)
7
25
 
8
26
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.215",
3
+ "version": "3.0.0-alpha.217",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,16 +29,16 @@
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
32
- "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
33
32
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.2",
34
33
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
34
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
35
35
  "@reventlessdev/rescript-node": "2.0.0-alpha.2",
36
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
37
36
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.2",
38
- "@reventlessdev/reventless-infra": "3.0.0-alpha.127",
39
- "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
40
37
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.101"
38
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.128",
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.102",
41
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.30"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
@@ -112,10 +112,28 @@ large, purely mechanical churn — so the plugin builder publishes them here for
112
112
  the duration of its construct, exactly as it already does for
113
113
  `Logger.currentPluginName`.
114
114
 
115
- Safe because deploy-time resource construction is synchronous (the framework
116
- forbids creating resources inside `Pulumi.Output.apply`), so everything built
117
- between `enter` and `restore` genuinely belongs to that plugin. Outside any
118
- plugin construct both are `None` — which is the correct answer for
115
+ The `enter`/`restore` bracket covers what runs **synchronously** inside the
116
+ builder's construct, and everything built there genuinely belongs to that plugin.
117
+
118
+ It does not cover all of construct. Builders defer part of their work until every
119
+ handler has registered, and they defer it by waiting on outputs — so that work
120
+ runs from a `Pulumi.Output.apply` callback, after construct has returned and
121
+ `restore` has already emptied the context. A resource created there would be
122
+ attributed to nobody, and `None` is a *meaningful* value here: it means
123
+ platform-scope substrate. So the miss does not read as a gap, it reads as a
124
+ positive answer, and a consumer cannot tell the two apart.
125
+
126
+ Deferred work therefore carries its own attribution. Capture the context while it
127
+ is still published and reinstate it around the callback: `deferred` for a
128
+ callback registered during construct and invoked later, and
129
+ `ResourceAttribution_Deploytime.applyAttributed` / `flatMapAttributed` for a
130
+ builder's own apply. Both restore afterwards, so several plugins' deferred work
131
+ can run from one apply without leaking into each other.
132
+
133
+ Everything Pulumi-shaped lives in that separate module, so this one stays
134
+ importable from code that ends up in a Lambda bundle.
135
+
136
+ Outside any plugin construct both are `None` — the correct answer for
119
137
  platform-scope substrate.
120
138
  */
121
139
  type context = {platform: option<string>, plugin: option<string>}
@@ -131,3 +149,41 @@ let enter = (~platform: string, ~plugin: string) => {
131
149
 
132
150
  /** Restore the context captured by `enter`. */
133
151
  let restore = (previous: context) => current := previous
152
+
153
+ /** Re-enter a whole captured context — including an empty one, which `enter`
154
+ cannot express since it takes two required strings. Returns the previous
155
+ context, exactly as `enter` does. */
156
+ let enterCaptured = (captured: context) => {
157
+ let previous = current.contents
158
+ current := captured
159
+ previous
160
+ }
161
+
162
+ /** Run `fn` under `captured`, then put the previous context back — including
163
+ when `fn` throws, so a failing deferred callback cannot strand the context
164
+ and mis-attribute everything built after it. */
165
+ let within = (captured: context, fn: unit => 'a): 'a => {
166
+ let previous = enterCaptured(captured)
167
+ try {
168
+ let result = fn()
169
+ restore(previous)
170
+ result
171
+ } catch {
172
+ | e =>
173
+ restore(previous)
174
+ throw(e)
175
+ }
176
+ }
177
+
178
+ /** Capture the context now and wrap a callback so it runs under that context
179
+ whenever it is finally called. For work registered during construct but
180
+ executed later from an apply — a builder's deferred `finish`, whose whole
181
+ purpose is to run after every handler has registered.
182
+
183
+ Wrap at **registration**, not at the call site: the registries these
184
+ callbacks land in are module-level and shared by every plugin, so by the time
185
+ they run there is no single context that would be right for all of them. */
186
+ let deferred = (fn: unit => 'a): (unit => 'a) => {
187
+ let captured = current.contents
188
+ () => within(captured, fn)
189
+ }
@@ -86,11 +86,37 @@ function restore(previous) {
86
86
  current.contents = previous;
87
87
  }
88
88
 
89
+ function enterCaptured(captured) {
90
+ let previous = current.contents;
91
+ current.contents = captured;
92
+ return previous;
93
+ }
94
+
95
+ function within(captured, fn) {
96
+ let previous = enterCaptured(captured);
97
+ try {
98
+ let result = fn();
99
+ current.contents = previous;
100
+ return result;
101
+ } catch (e) {
102
+ current.contents = previous;
103
+ throw e;
104
+ }
105
+ }
106
+
107
+ function deferred(fn) {
108
+ let captured = current.contents;
109
+ return () => within(captured, fn);
110
+ }
111
+
89
112
  export {
90
113
  Scope,
91
114
  Role,
92
115
  current,
93
116
  enter,
94
117
  restore,
118
+ enterCaptured,
119
+ within,
120
+ deferred,
95
121
  }
96
122
  /* No side effect */
@@ -0,0 +1,74 @@
1
+ /**
2
+ Deploy-time switch: an extension asks for query interception, and the framework
3
+ provisions whatever that costs.
4
+
5
+ `QueryDb_Callback.registerQueryInterceptor` is a runtime hook, and `RuntimeExtension`
6
+ made it reachable inside a deployed runtime. On a DynamoDB-backed read model there
7
+ was still nothing to reach: a top-level Query resolver talks to DynamoDB directly,
8
+ so no code of ours runs on a read at all. Something has to put a runtime in front
9
+ of the read, and that is a provisioning decision the extension cannot make for
10
+ itself — an AppSync Lambda data source needs the api handle and a service role,
11
+ both of which exist only inside the plugin build.
12
+
13
+ So the split follows `Monitoring` and `EventLogProvisioning`: **the extension
14
+ decides, the framework provisions.** An extension says only *that* interception
15
+ should happen; it names no data source, no api and no role, and writes nothing
16
+ provider-shaped. The framework already ships the handler
17
+ (`QueryInterceptor_Lambda` on AWS), so there is nothing left for a caller to
18
+ supply.
19
+
20
+ **Off by default, and silent when off.** No registration means no interceptor
21
+ runtime, no data source, unit resolvers exactly as before, and a byte-identical
22
+ code archive — the same guarantee the cold-start seam makes.
23
+
24
+ **It composes with `RuntimeExtension` without either knowing about the other.**
25
+ The interceptor runtime is built through the standard runtime builder, so
26
+ `Util_Bundle` bundles every registered extension's package into its archive and
27
+ `makeFromCodeAsset` writes `RUNTIME_EXTENSIONS`. An extension that registers its
28
+ interceptor in `onColdStart` is therefore carried into the interceptor runtime
29
+ with no second registration.
30
+
31
+ ## What it costs, which is why it is opt-in
32
+
33
+ Interception puts a Lambda invocation in front of **every read** on a
34
+ DynamoDB-backed read model. Reads outrun writes by orders of magnitude, so this
35
+ is the most expensive thing the framework can be asked to switch on. Two
36
+ properties bound it:
37
+
38
+ - **It is a property of the read-model backend, not of the cloud.** A
39
+ Postgres-backed read model already routes through a resolver Lambda, which
40
+ consults the same hook, so interception there costs nothing extra. Only the
41
+ DynamoDB direct-resolver path pays.
42
+ - **It is the only place a read can be refused.** Anything cheaper — a log
43
+ subscription, a stream-fed counter — observes a read after the fact but cannot
44
+ deny it. Where the requirement is enforcement rather than observation, this
45
+ cost is the requirement's price rather than overhead.
46
+
47
+ Granularity is per-deployment, deliberately: an operator asking "what does
48
+ interception cost me" wants one answer, not one per component. Narrowing later is
49
+ additive (a predicate on the registration); widening a per-component switch is
50
+ not.
51
+
52
+ See `docs/plans/done/query-interception-provisioning.md`.
53
+ */
54
+
55
+ let enabled = ref(false)
56
+
57
+ /**
58
+ Switch query interception on for this deployment. Must run before the
59
+ platform/plugin build in the deploy program (plain statement order — the switch is
60
+ read lazily at each provisioning site), exactly like `Monitoring.use`.
61
+
62
+ Registering the runtime interceptor itself stays separate and is the extension's
63
+ job, through `QueryDb_Callback.registerQueryInterceptor` — typically from a
64
+ `RuntimeExtension` cold-start hook. This call only provisions the path that lets
65
+ that hook be consulted on a read.
66
+ */
67
+ let use = () => enabled := true
68
+
69
+ /** Whether interception was asked for. Consulted by the provisioning sites. */
70
+ let isEnabled = () => enabled.contents
71
+
72
+ /** Tests only — the switch is process-global, so a test that flips it must put it
73
+ back. */
74
+ let reset = () => enabled := false
@@ -0,0 +1,26 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ let enabled = {
5
+ contents: false
6
+ };
7
+
8
+ function use() {
9
+ enabled.contents = true;
10
+ }
11
+
12
+ function isEnabled() {
13
+ return enabled.contents;
14
+ }
15
+
16
+ function reset() {
17
+ enabled.contents = false;
18
+ }
19
+
20
+ export {
21
+ enabled,
22
+ use,
23
+ isEnabled,
24
+ reset,
25
+ }
26
+ /* No side effect */
@@ -0,0 +1,43 @@
1
+ /***
2
+ Carrying `ResourceAttribution` across a `Pulumi.Output` boundary.
3
+
4
+ Separate from `ResourceAttribution` itself on purpose: that module is read by
5
+ code that ends up in a Lambda bundle, and importing the deploy-time engine there
6
+ is a cold-start failure. Everything Pulumi-shaped lives here, where only
7
+ deploy-time builders reach it — the same reason `AdapterDeploytime` sits apart
8
+ from `Adapter`.
9
+
10
+ Both helpers wrap `apply` / `flatMap` rather than the callback alone. Wrapping
11
+ the callback would read more neatly, but it severs the type flow from the Output
12
+ to the callback's parameters, and inference then fails on records the Output was
13
+ the only thing constraining.
14
+ */
15
+
16
+ /** `Pulumi.Output.apply` for a callback that CREATES RESOURCES.
17
+
18
+ An apply callback runs after the enclosing builder's construct has returned,
19
+ so the ambient attribution context — published for the duration of that
20
+ construct and unpublished at the end of it — is already empty by then, and
21
+ anything the callback provisions is attributed to nobody. That is not a gap
22
+ but a wrong answer: an empty plugin means platform-scope substrate.
23
+
24
+ This captures the context where the apply is *registered*, which is
25
+ synchronous inside construct, and reinstates it around the callback. Reach
26
+ for it wherever an apply body reaches a resource; plain `Pulumi.Output.apply`
27
+ stays right for a callback that only shapes data. */
28
+ let applyAttributed = (output, callback) => {
29
+ let captured = ResourceAttribution.current.contents
30
+ output->Pulumi.Output.apply(resolved =>
31
+ captured->ResourceAttribution.within(() => callback(resolved))
32
+ )
33
+ }
34
+
35
+ /** `Pulumi.Output.flatMap` counterpart, for the same reason — a builder that
36
+ resolves one resource's outputs in order to build the next one defers just as
37
+ far. */
38
+ let flatMapAttributed = (output, callback) => {
39
+ let captured = ResourceAttribution.current.contents
40
+ output->Pulumi.Output.flatMap(resolved =>
41
+ captured->ResourceAttribution.within(() => callback(resolved))
42
+ )
43
+ }
@@ -0,0 +1,20 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
4
+ import * as ResourceAttribution$ReventlessCore from "../ResourceAttribution.res.mjs";
5
+
6
+ function applyAttributed(output, callback) {
7
+ let captured = ResourceAttribution$ReventlessCore.current.contents;
8
+ return output.apply(resolved => ResourceAttribution$ReventlessCore.within(captured, () => callback(resolved)));
9
+ }
10
+
11
+ function flatMapAttributed(output, callback) {
12
+ let captured = ResourceAttribution$ReventlessCore.current.contents;
13
+ return Output$Pulumi.flatMap(output, resolved => ResourceAttribution$ReventlessCore.within(captured, () => callback(resolved)));
14
+ }
15
+
16
+ export {
17
+ applyAttributed,
18
+ flatMapAttributed,
19
+ }
20
+ /* Output-Pulumi 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
  });
@@ -42,7 +42,13 @@ let createAggregatesWithoutEventMappers = (
42
42
  let publishJsons =
43
43
  SpecificAggregate.operations(aggregate)->Pulumi.Output.apply(({publishJsons}) => publishJsons)
44
44
  publishToAggregates->Dict.set(SpecificAggregate.Spec.name, publishJsons)
45
- aggregateFinishFns->Dict.set(SpecificAggregate.Spec.name, SpecificAggregate.finish)
45
+ // `finish` runs from an apply in `finishAggregates`, long after this
46
+ // construct returns — wrap it now, while the plugin is still the ambient
47
+ // one, so whatever it provisions is attributed to that plugin.
48
+ aggregateFinishFns->Dict.set(
49
+ SpecificAggregate.Spec.name,
50
+ ResourceAttribution.deferred(SpecificAggregate.finish),
51
+ )
46
52
  aggOutputs
47
53
  })
48
54
  ->Array.map(aggregate => {(aggregate.name, aggregate)})
@@ -106,7 +112,10 @@ let taskSideEffectFinishFns: array<unit => unit> = []
106
112
 
107
113
  let registerTaskSideEffectHandler = (~gate, ~finish) => {
108
114
  let _ = taskSideEffectGates->Array.push(gate)
109
- let _ = taskSideEffectFinishFns->Array.push(finish)
115
+ // These arrays are module-level and shared by every plugin, so the context has
116
+ // to travel with each entry: by the time `finishTasks` runs them there is no
117
+ // one plugin that would be right for all of them.
118
+ let _ = taskSideEffectFinishFns->Array.push(ResourceAttribution.deferred(finish))
110
119
  }
111
120
 
112
121
  // Provision side-effect handler runtimes once every task's handler is ready. Same
@@ -179,7 +188,16 @@ let createReadModels = (
179
188
  rmOperations->Pulumi.Output.apply(({enqueueEvent}) => enqueueEvent),
180
189
  )
181
190
 
182
- (SpecificReadModel.Spec.name, {outputs: rmOutputs, operations: rmOperations, finish: SpecificReadModel.finish})
191
+ // Deferred to `finishReadModels`, which runs it from an apply — wrap it here
192
+ // so the read model's runtime is attributed to the plugin building it.
193
+ (
194
+ SpecificReadModel.Spec.name,
195
+ {
196
+ outputs: rmOutputs,
197
+ operations: rmOperations,
198
+ finish: ResourceAttribution.deferred(SpecificReadModel.finish),
199
+ },
200
+ )
183
201
  })
184
202
  readModels->finishReadModels
185
203
  readModels->extractReadModelsOutputs
@@ -4,6 +4,7 @@ import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
4
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Pulumi from "@pulumi/pulumi";
6
6
  import * as QueryDb$ReventlessCore from "./QueryDb/QueryDb.res.mjs";
7
+ import * as ResourceAttribution$ReventlessCore from "../ResourceAttribution.res.mjs";
7
8
 
8
9
  let addEventMapperFns = {};
9
10
 
@@ -22,7 +23,7 @@ function createAggregatesWithoutEventMappers(aggregates, api, componentRuntime,
22
23
  aggregateResources[SpecificAggregate.Spec.name] = resources;
23
24
  let publishJsons = SpecificAggregate.operations(aggregate).apply(param => param.publishJsons);
24
25
  publishToAggregates[SpecificAggregate.Spec.name] = publishJsons;
25
- aggregateFinishFns[SpecificAggregate.Spec.name] = SpecificAggregate.finish;
26
+ aggregateFinishFns[SpecificAggregate.Spec.name] = ResourceAttribution$ReventlessCore.deferred(SpecificAggregate.finish);
26
27
  return aggOutputs;
27
28
  }).map(aggregate => [
28
29
  aggregate.name,
@@ -54,7 +55,7 @@ let taskSideEffectFinishFns = [];
54
55
 
55
56
  function registerTaskSideEffectHandler(gate, finish) {
56
57
  taskSideEffectGates.push(gate);
57
- taskSideEffectFinishFns.push(finish);
58
+ taskSideEffectFinishFns.push(ResourceAttribution$ReventlessCore.deferred(finish));
58
59
  }
59
60
 
60
61
  function finishTasks() {
@@ -102,7 +103,7 @@ function createReadModels(readModels, api, apiRole, componentRuntime, allEventTo
102
103
  {
103
104
  outputs: rmOutputs,
104
105
  operations: rmOperations,
105
- finish: SpecificReadModel.finish
106
+ finish: ResourceAttribution$ReventlessCore.deferred(SpecificReadModel.finish)
106
107
  }
107
108
  ];
108
109
  });
@@ -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,