@reventlessdev/reventless-core 3.0.0-alpha.216 → 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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
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
+
6
16
  # 3.0.0-alpha.216 (2026-08-08)
7
17
 
8
18
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.216",
3
+ "version": "3.0.0-alpha.217",
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-hash-object": "1.2.0-alpha.14",
31
32
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.2",
32
- "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
33
33
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
34
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
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
- "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
38
36
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.2",
37
+ "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
38
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
39
39
  "@reventlessdev/reventless-infra": "3.0.0-alpha.128",
40
- "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.102"
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 */
@@ -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
  });
@@ -63,7 +63,10 @@ module Make = (
63
63
 
64
64
  let (commandTopic, eventTopic, outgoingJsonEventsHandler) =
65
65
  commandTopicResources
66
- ->Pulumi.Output.flatMap(commandTopicResources => {
66
+ // The extension point's runtime and its event topic are both built in
67
+ // here, once the command topic's resources resolve — i.e. after the
68
+ // plugin's construct has returned. Reinstate its attribution.
69
+ ->ResourceAttribution_Deploytime.flatMapAttributed(commandTopicResources => {
67
70
  module ExtensionPointCallback = ExtensionPoint_Callback.Make(
68
71
  {
69
72
  let publishToAggregates = publishToAggregates
@@ -15,6 +15,7 @@ import * as EventTopic_Builder$ReventlessCore from "../EventTopic/EventTopic_Bui
15
15
  import * as CommandTopic_Builder$ReventlessCore from "../CommandTopic/CommandTopic_Builder.res.mjs";
16
16
  import * as ExtensionPoint_Callback$ReventlessCore from "./ExtensionPoint_Callback.res.mjs";
17
17
  import * as ExtensionPoint_Operations$ReventlessCore from "./ExtensionPoint_Operations.res.mjs";
18
+ import * as ResourceAttribution_Deploytime$ReventlessCore from "../../adapter/ResourceAttribution_Deploytime.res.mjs";
18
19
 
19
20
  function Make(Spec) {
20
21
  return Mappings => (RuntimeEnvironment => (CommandTopicChannel => (EventTopicAdapter => (ExtensionPointRuntimeBuilder => (Defaults => {
@@ -42,7 +43,7 @@ function Make(Spec) {
42
43
  }, opts);
43
44
  let aggregateNames = Stdlib_Array.filterMap(Mappings.mappings, Mapping => Stdlib_Option.map(Mapping.mapOutgoingEvent, param => Mapping.delegateName));
44
45
  let commandTopicResources = Adapter$ReventlessCore.resourcesToResolvedOutput(commandTopic.channel.resources);
45
- let match = Output$Pulumi.unzip3(Output$Pulumi.flatMap(commandTopicResources, commandTopicResources => {
46
+ let match = Output$Pulumi.unzip3(ResourceAttribution_Deploytime$ReventlessCore.flatMapAttributed(commandTopicResources, commandTopicResources => {
46
47
  let ExtensionPointCallback = ExtensionPoint_Callback$ReventlessCore.Make({
47
48
  publishToAggregates: publishToAggregates,
48
49
  commandTopicResources: commandTopicResources,
@@ -648,7 +648,11 @@ module Make = (
648
648
  queryEngine,
649
649
  )
650
650
  ->Pulumi.Output.all6
651
- ->Pulumi.Output.apply(((
651
+ // `applyAttributed`, not `apply`: this callback provisions real resources —
652
+ // extension points, tasks, the heartbeat, resolvers — and it runs after
653
+ // this construct has returned, when the ambient attribution context is
654
+ // already empty. It reinstates the plugin's context for the duration.
655
+ ->ResourceAttribution_Deploytime.applyAttributed(((
652
656
  (interstackAdminExtensionPoints, localAdminResolvedEP, _),
653
657
  aggregateResources,
654
658
  publishToAggregates,
@@ -36,6 +36,7 @@ import * as ResourceAttribution$ReventlessCore from "../../ResourceAttribution.r
36
36
  import * as Plugin_EventQuerySchema$ReventlessCore from "./Plugin_EventQuerySchema.res.mjs";
37
37
  import * as PluginExtensionPointSpec$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/PluginExtensionPointSpec.res.mjs";
38
38
  import * as Plugin_SubscriptionSchema$ReventlessCore from "./Plugin_SubscriptionSchema.res.mjs";
39
+ import * as ResourceAttribution_Deploytime$ReventlessCore from "../../adapter/ResourceAttribution_Deploytime.res.mjs";
39
40
 
40
41
  function Make(Spec) {
41
42
  return ApiSpec => (FragmentProvider => (RuntimeEnvironment => (EventCollectorChannel => (QueryEngineAdapter => (PluginExtensionPointRemoteChannel => (HeartbeatRunner => (PluginRuntimeBuilder => (DcbEventLogStorage => (DcbEventTopicPublisher => (DcbCommandTopicChannel => (DcbCommandTopicChannelAsync => {
@@ -428,7 +429,7 @@ function Make(Spec) {
428
429
  let schemaPushed = pushSchema !== undefined ? pushSchema(extra$1, version, apiSchemaFragment) : Pulumi.output();
429
430
  let apiSchemaFragmentPayload = Plugin_Helpers$ReventlessCore.offloadPayload(apiSchemaFragment, Plugin$Reventless.apiSchemaFragmentSchema, "pluginApiFragments");
430
431
  let structurePayload = Stdlib_Option.map(pluginStructure, s => Plugin_Helpers$ReventlessCore.offloadPayload(s, Plugin$Reventless.pluginStructureSchema, "pluginStructures"));
431
- let builderOutputs = Pulumi.all([
432
+ let builderOutputs = ResourceAttribution_Deploytime$ReventlessCore.applyAttributed(Pulumi.all([
432
433
  Pulumi.all([
433
434
  interstackAdminExtensionPoints,
434
435
  localAdminResolvedEP,
@@ -439,7 +440,7 @@ function Make(Spec) {
439
440
  Pulumi.all(Plugin_Helpers$ReventlessCore.publishToReadModels),
440
441
  scheduler,
441
442
  queryEngine
442
- ]).apply(param => {
443
+ ]), param => {
443
444
  let queryEngine = param[5];
444
445
  let scheduler = param[4];
445
446
  let publishToReadModels = param[3];
@@ -383,7 +383,10 @@ let createConnectPluginExtension = (
383
383
  ~opts,
384
384
  ) =>
385
385
  pluginDefinition
386
- ->Pulumi.Output.apply(pluginDefinition => {
386
+ // Provisions the plugin's connect-extension and its event-collector runtime,
387
+ // so it needs the plugin's attribution reinstated — the apply runs after
388
+ // construct has emptied it.
389
+ ->ResourceAttribution_Deploytime.applyAttributed(pluginDefinition => {
387
390
  module ConnectPluginExtension = PluginConnectExtension_Builder.Make({
388
391
  let pluginDefinition = pluginDefinition
389
392
  let uiFragments = uiFragments
@@ -548,7 +551,9 @@ module MakeEventCollectorHelper = (
548
551
  resources,
549
552
  )
550
553
  ->Pulumi.Output.all5
551
- ->Pulumi.Output.apply(((
554
+ // Builds the plugin's own event-collector runtime, so it needs the plugin's
555
+ // attribution reinstated — this callback runs after construct emptied it.
556
+ ->ResourceAttribution_Deploytime.applyAttributed(((
552
557
  pluginDefinition,
553
558
  connectExtData,
554
559
  extensionsHandlers,
@@ -58,6 +58,7 @@ import * as OutboundTranslationSlice$ReventlessInterop from "@reventlessdev/reve
58
58
  import * as PluginExtensionPoint_Plugin$ReventlessCore from "../connect/PluginExtensionPoint_Plugin.res.mjs";
59
59
  import * as PluginConnectExtension_Builder$ReventlessCore from "../connect/PluginConnectExtension_Builder.res.mjs";
60
60
  import * as PluginConnectExtension_Mapping$ReventlessCore from "../connect/PluginConnectExtension_Mapping.res.mjs";
61
+ import * as ResourceAttribution_Deploytime$ReventlessCore from "../../adapter/ResourceAttribution_Deploytime.res.mjs";
61
62
 
62
63
  let log = Logger$ReventlessCore.fromEnv();
63
64
 
@@ -224,7 +225,7 @@ function extractExtensionDefinitions(extensionsOutputs) {
224
225
  }
225
226
 
226
227
  function createConnectPluginExtension(pluginDefinition, uiFragments, publishToPluginExtensionPoint, publishToAggregates, readModelNamesForSourceName, publishToReadModels, queryEngine, opts) {
227
- return Output$Pulumi.unzip(pluginDefinition.apply(pluginDefinition => {
228
+ return Output$Pulumi.unzip(ResourceAttribution_Deploytime$ReventlessCore.applyAttributed(pluginDefinition, pluginDefinition => {
228
229
  let ConnectPluginExtension = PluginConnectExtension_Builder$ReventlessCore.Make({
229
230
  pluginDefinition: pluginDefinition,
230
231
  uiFragments: uiFragments
@@ -292,13 +293,13 @@ function MakeEventCollectorHelper(RuntimeEnvironment) {
292
293
  param[0],
293
294
  param[1]
294
295
  ]) : Pulumi.output(undefined);
295
- return Pulumi.all([
296
+ return ResourceAttribution_Deploytime$ReventlessCore.applyAttributed(Pulumi.all([
296
297
  pluginDefinition,
297
298
  connectExtData,
298
299
  Pulumi.all(extensionsHandlers),
299
300
  Pulumi.all(extensionPointsHandlers),
300
301
  resources
301
- ]).apply(param => {
302
+ ]), param => {
302
303
  let extensionsHandlers = param[2];
303
304
  let connectExtData = param[1];
304
305
  let pluginDefinition = param[0];
@@ -68,3 +68,118 @@ describe("ResourceAttribution — ambient plugin context", () => {
68
68
  expect(Attribution.current.contents.plugin)->toEqual(None)
69
69
  })
70
70
  })
71
+
72
+ // The bracket only covers what runs synchronously inside a builder's construct.
73
+ // Builders defer part of their work into a `Pulumi.Output.apply`, which runs
74
+ // after `restore` has emptied the context — so a resource created there is
75
+ // attributed to nobody. These pin the mechanism that carries the context across
76
+ // that gap. Each empties the context first, which is exactly the state a
77
+ // deferred callback finds itself in.
78
+
79
+ describe("ResourceAttribution — carrying the context into deferred work", () => {
80
+ testSync("a deferred callback runs under the context it was wrapped in", () => {
81
+ let previous = Attribution.enter(~platform="online-shop", ~plugin="Catalog")
82
+ let observed = ref(None)
83
+ let finish = Attribution.deferred(() => observed := Attribution.current.contents.plugin)
84
+ Attribution.restore(previous)
85
+
86
+ // Construct has returned; nothing is attributed. This is when the apply fires.
87
+ expect(Attribution.current.contents.plugin)->toEqual(None)
88
+ finish()
89
+ expect(observed.contents)->toEqual(Some("Catalog"))
90
+ })
91
+
92
+ testSync("it puts the context back, so the next callback is not attributed to it", () => {
93
+ let previous = Attribution.enter(~platform="online-shop", ~plugin="Catalog")
94
+ let finish = Attribution.deferred(() => ())
95
+ Attribution.restore(previous)
96
+
97
+ finish()
98
+ expect(Attribution.current.contents.plugin)->toEqual(None)
99
+ })
100
+
101
+ // The registries these callbacks land in are module-level and shared, so one
102
+ // apply runs several plugins' finish functions back to back. Attributing them
103
+ // all to whichever ran first is the failure this guards.
104
+ testSync("two plugins deferred from one apply are each attributed to themselves", () => {
105
+ let outer = Attribution.enter(~platform="online-shop", ~plugin="Catalog")
106
+ let catalogSaw = ref(None)
107
+ let catalogFinish = Attribution.deferred(() =>
108
+ catalogSaw := Attribution.current.contents.plugin
109
+ )
110
+ Attribution.restore(outer)
111
+
112
+ let previous = Attribution.enter(~platform="online-shop", ~plugin="Ordering")
113
+ let orderingSaw = ref(None)
114
+ let orderingFinish = Attribution.deferred(() =>
115
+ orderingSaw := Attribution.current.contents.plugin
116
+ )
117
+ Attribution.restore(previous)
118
+
119
+ catalogFinish()
120
+ orderingFinish()
121
+ expect((catalogSaw.contents, orderingSaw.contents))->toEqual((
122
+ Some("Catalog"),
123
+ Some("Ordering"),
124
+ ))
125
+ })
126
+
127
+ testSync("a throwing callback still puts the context back", () => {
128
+ let previous = Attribution.enter(~platform="online-shop", ~plugin="Catalog")
129
+ let finish: unit => unit = Attribution.deferred(() =>
130
+ JsError.throwWithMessage("finish blew up")
131
+ )
132
+ Attribution.restore(previous)
133
+
134
+ let threw = try {
135
+ finish()
136
+ false
137
+ } catch {
138
+ | _ => true
139
+ }
140
+ expect(threw)->toBe(true)
141
+ expect(Attribution.current.contents.plugin)->toEqual(None)
142
+ })
143
+
144
+ testSync("within re-enters a captured context for a callback that takes arguments", () => {
145
+ let previous = Attribution.enter(~platform="online-shop", ~plugin="Catalog")
146
+ let captured = Attribution.current.contents
147
+ Attribution.restore(previous)
148
+
149
+ let observed = captured->Attribution.within(() => Attribution.current.contents.plugin)
150
+ expect(observed)->toEqual(Some("Catalog"))
151
+ expect(Attribution.current.contents.plugin)->toEqual(None)
152
+ })
153
+
154
+ testSync("enterCaptured can express the empty context, which enter cannot", () => {
155
+ let previous = Attribution.enter(~platform="online-shop", ~plugin="Catalog")
156
+ let restoredToEmpty = Attribution.enterCaptured({platform: None, plugin: None})
157
+ expect(Attribution.current.contents.plugin)->toEqual(None)
158
+ expect(restoredToEmpty.plugin)->toEqual(Some("Catalog"))
159
+ Attribution.restore(previous)
160
+ })
161
+ })
162
+
163
+ // The combinator being right is not the same as it being wired in. This one
164
+ // exercises the production choke point: a task side-effect handler registered
165
+ // during construct, then run the way `finishTasks` runs it — from an apply,
166
+ // with nothing attributed.
167
+ describe("Builder_Helpers.registerTaskSideEffectHandler — attribution survives the deferral", () => {
168
+ testSync("the registered finish sees the plugin that registered it", () => {
169
+ let before = Builder_Helpers.taskSideEffectFinishFns->Array.length
170
+ let observed = ref(None)
171
+
172
+ let previous = Attribution.enter(~platform="online-shop", ~plugin="Catalog")
173
+ Builder_Helpers.registerTaskSideEffectHandler(
174
+ ~gate=Pulumi.Output.make(),
175
+ ~finish=() => observed := Attribution.current.contents.plugin,
176
+ )
177
+ Attribution.restore(previous)
178
+
179
+ switch Builder_Helpers.taskSideEffectFinishFns->Array.get(before) {
180
+ | Some(finish) => finish()
181
+ | None => JsError.throwWithMessage("handler was not registered")
182
+ }
183
+ expect(observed.contents)->toEqual(Some("Catalog"))
184
+ })
185
+ })
@@ -1,6 +1,9 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Pulumi from "@pulumi/pulumi";
4
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
3
5
  import * as ComponentType$ReventlessCore from "../src/ComponentType.res.mjs";
6
+ import * as Builder_Helpers$ReventlessCore from "../src/components/Builder_Helpers.res.mjs";
4
7
  import * as ResourceAttribution$ReventlessCore from "../src/ResourceAttribution.res.mjs";
5
8
 
6
9
  globalThis.describe("ResourceAttribution.Scope — wire spellings", () => {
@@ -67,6 +70,109 @@ globalThis.describe("ResourceAttribution — ambient plugin context", () => {
67
70
  });
68
71
  });
69
72
 
73
+ globalThis.describe("ResourceAttribution — carrying the context into deferred work", () => {
74
+ globalThis.test("a deferred callback runs under the context it was wrapped in", () => {
75
+ let previous = ResourceAttribution$ReventlessCore.enter("online-shop", "Catalog");
76
+ let observed = {
77
+ contents: undefined
78
+ };
79
+ let finish = ResourceAttribution$ReventlessCore.deferred(() => {
80
+ observed.contents = ResourceAttribution$ReventlessCore.current.contents.plugin;
81
+ });
82
+ ResourceAttribution$ReventlessCore.restore(previous);
83
+ globalThis.expect(ResourceAttribution$ReventlessCore.current.contents.plugin).toEqual(undefined);
84
+ finish();
85
+ globalThis.expect(observed.contents).toEqual("Catalog");
86
+ });
87
+ globalThis.test("it puts the context back, so the next callback is not attributed to it", () => {
88
+ let previous = ResourceAttribution$ReventlessCore.enter("online-shop", "Catalog");
89
+ let finish = ResourceAttribution$ReventlessCore.deferred(() => {});
90
+ ResourceAttribution$ReventlessCore.restore(previous);
91
+ finish();
92
+ globalThis.expect(ResourceAttribution$ReventlessCore.current.contents.plugin).toEqual(undefined);
93
+ });
94
+ globalThis.test("two plugins deferred from one apply are each attributed to themselves", () => {
95
+ let outer = ResourceAttribution$ReventlessCore.enter("online-shop", "Catalog");
96
+ let catalogSaw = {
97
+ contents: undefined
98
+ };
99
+ let catalogFinish = ResourceAttribution$ReventlessCore.deferred(() => {
100
+ catalogSaw.contents = ResourceAttribution$ReventlessCore.current.contents.plugin;
101
+ });
102
+ ResourceAttribution$ReventlessCore.restore(outer);
103
+ let previous = ResourceAttribution$ReventlessCore.enter("online-shop", "Ordering");
104
+ let orderingSaw = {
105
+ contents: undefined
106
+ };
107
+ let orderingFinish = ResourceAttribution$ReventlessCore.deferred(() => {
108
+ orderingSaw.contents = ResourceAttribution$ReventlessCore.current.contents.plugin;
109
+ });
110
+ ResourceAttribution$ReventlessCore.restore(previous);
111
+ catalogFinish();
112
+ orderingFinish();
113
+ globalThis.expect([
114
+ catalogSaw.contents,
115
+ orderingSaw.contents
116
+ ]).toEqual([
117
+ "Catalog",
118
+ "Ordering"
119
+ ]);
120
+ });
121
+ globalThis.test("a throwing callback still puts the context back", () => {
122
+ let previous = ResourceAttribution$ReventlessCore.enter("online-shop", "Catalog");
123
+ let finish = ResourceAttribution$ReventlessCore.deferred(() => Stdlib_JsError.throwWithMessage("finish blew up"));
124
+ ResourceAttribution$ReventlessCore.restore(previous);
125
+ let threw;
126
+ try {
127
+ finish();
128
+ threw = false;
129
+ } catch (exn) {
130
+ threw = true;
131
+ }
132
+ globalThis.expect(threw).toBe(true);
133
+ globalThis.expect(ResourceAttribution$ReventlessCore.current.contents.plugin).toEqual(undefined);
134
+ });
135
+ globalThis.test("within re-enters a captured context for a callback that takes arguments", () => {
136
+ let previous = ResourceAttribution$ReventlessCore.enter("online-shop", "Catalog");
137
+ let captured = ResourceAttribution$ReventlessCore.current.contents;
138
+ ResourceAttribution$ReventlessCore.restore(previous);
139
+ let observed = ResourceAttribution$ReventlessCore.within(captured, () => ResourceAttribution$ReventlessCore.current.contents.plugin);
140
+ globalThis.expect(observed).toEqual("Catalog");
141
+ globalThis.expect(ResourceAttribution$ReventlessCore.current.contents.plugin).toEqual(undefined);
142
+ });
143
+ globalThis.test("enterCaptured can express the empty context, which enter cannot", () => {
144
+ let previous = ResourceAttribution$ReventlessCore.enter("online-shop", "Catalog");
145
+ let restoredToEmpty = ResourceAttribution$ReventlessCore.enterCaptured({
146
+ platform: undefined,
147
+ plugin: undefined
148
+ });
149
+ globalThis.expect(ResourceAttribution$ReventlessCore.current.contents.plugin).toEqual(undefined);
150
+ globalThis.expect(restoredToEmpty.plugin).toEqual("Catalog");
151
+ ResourceAttribution$ReventlessCore.restore(previous);
152
+ });
153
+ });
154
+
155
+ globalThis.describe("Builder_Helpers.registerTaskSideEffectHandler — attribution survives the deferral", () => {
156
+ globalThis.test("the registered finish sees the plugin that registered it", () => {
157
+ let before = Builder_Helpers$ReventlessCore.taskSideEffectFinishFns.length;
158
+ let observed = {
159
+ contents: undefined
160
+ };
161
+ let previous = ResourceAttribution$ReventlessCore.enter("online-shop", "Catalog");
162
+ Builder_Helpers$ReventlessCore.registerTaskSideEffectHandler(Pulumi.output(), () => {
163
+ observed.contents = ResourceAttribution$ReventlessCore.current.contents.plugin;
164
+ });
165
+ ResourceAttribution$ReventlessCore.restore(previous);
166
+ let finish = Builder_Helpers$ReventlessCore.taskSideEffectFinishFns[before];
167
+ if (finish !== undefined) {
168
+ finish();
169
+ } else {
170
+ Stdlib_JsError.throwWithMessage("handler was not registered");
171
+ }
172
+ globalThis.expect(observed.contents).toEqual("Catalog");
173
+ });
174
+ });
175
+
70
176
  let Attribution;
71
177
 
72
178
  export {
@@ -0,0 +1,55 @@
1
+ open JestGlobals
2
+
3
+ // The seam is a single process-global switch, so what is worth pinning is the
4
+ // default (off) and the fact that switching it on does not also register a
5
+ // runtime interceptor — the two are deliberately separate decisions, and
6
+ // conflating them would provision an interceptor path for a hook nobody set.
7
+
8
+ module Interception = QueryInterception
9
+
10
+ describe("QueryInterception", () => {
11
+ testSync("off by default — no registration, nothing provisioned", () => {
12
+ Interception.reset()
13
+ expect(Interception.isEnabled())->toBe(false)
14
+ })
15
+
16
+ testSync("use switches it on for the deployment", () => {
17
+ Interception.reset()
18
+ Interception.use()
19
+ expect(Interception.isEnabled())->toBe(true)
20
+ Interception.reset()
21
+ })
22
+
23
+ testSync("use is idempotent — an extension may call it more than once", () => {
24
+ Interception.reset()
25
+ Interception.use()
26
+ Interception.use()
27
+ expect(Interception.isEnabled())->toBe(true)
28
+ Interception.reset()
29
+ })
30
+
31
+ testSync("reset puts it back, so one test cannot leak into the next", () => {
32
+ Interception.use()
33
+ Interception.reset()
34
+ expect(Interception.isEnabled())->toBe(false)
35
+ })
36
+
37
+ // Provisioning the path and registering the hook are separate on purpose: the
38
+ // framework provisions, the extension decides what runs. Asking for
39
+ // interception must not fabricate an interceptor.
40
+ testSync("switching provisioning on does not register a runtime interceptor", () => {
41
+ QueryDb_Callback.clearQueryInterceptor()
42
+ Interception.use()
43
+ expect(QueryDb_Callback.queryInterceptorHook.contents->Option.isNone)->toBe(true)
44
+ Interception.reset()
45
+ })
46
+
47
+ testSync("and registering a runtime interceptor does not switch provisioning on", () => {
48
+ Interception.reset()
49
+ QueryDb_Callback.registerQueryInterceptor((~identity as _, ~readModelName as _, ~args as _) =>
50
+ Promise.resolve(QueryDb_Callback.Allow)
51
+ )
52
+ expect(Interception.isEnabled())->toBe(false)
53
+ QueryDb_Callback.clearQueryInterceptor()
54
+ })
55
+ })
@@ -0,0 +1,49 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as QueryDb_Callback$ReventlessCore from "../../src/components/QueryDb/QueryDb_Callback.res.mjs";
5
+ import * as QueryInterception$ReventlessCore from "../../src/adapter/QueryInterception/QueryInterception.res.mjs";
6
+
7
+ globalThis.describe("QueryInterception", () => {
8
+ globalThis.test("off by default — no registration, nothing provisioned", () => {
9
+ QueryInterception$ReventlessCore.reset();
10
+ globalThis.expect(QueryInterception$ReventlessCore.isEnabled()).toBe(false);
11
+ });
12
+ globalThis.test("use switches it on for the deployment", () => {
13
+ QueryInterception$ReventlessCore.reset();
14
+ QueryInterception$ReventlessCore.use();
15
+ globalThis.expect(QueryInterception$ReventlessCore.isEnabled()).toBe(true);
16
+ QueryInterception$ReventlessCore.reset();
17
+ });
18
+ globalThis.test("use is idempotent — an extension may call it more than once", () => {
19
+ QueryInterception$ReventlessCore.reset();
20
+ QueryInterception$ReventlessCore.use();
21
+ QueryInterception$ReventlessCore.use();
22
+ globalThis.expect(QueryInterception$ReventlessCore.isEnabled()).toBe(true);
23
+ QueryInterception$ReventlessCore.reset();
24
+ });
25
+ globalThis.test("reset puts it back, so one test cannot leak into the next", () => {
26
+ QueryInterception$ReventlessCore.use();
27
+ QueryInterception$ReventlessCore.reset();
28
+ globalThis.expect(QueryInterception$ReventlessCore.isEnabled()).toBe(false);
29
+ });
30
+ globalThis.test("switching provisioning on does not register a runtime interceptor", () => {
31
+ QueryDb_Callback$ReventlessCore.clearQueryInterceptor();
32
+ QueryInterception$ReventlessCore.use();
33
+ globalThis.expect(Stdlib_Option.isNone(QueryDb_Callback$ReventlessCore.queryInterceptorHook.contents)).toBe(true);
34
+ QueryInterception$ReventlessCore.reset();
35
+ });
36
+ globalThis.test("and registering a runtime interceptor does not switch provisioning on", () => {
37
+ QueryInterception$ReventlessCore.reset();
38
+ QueryDb_Callback$ReventlessCore.registerQueryInterceptor((param, param$1, param$2) => Promise.resolve("Allow"));
39
+ globalThis.expect(QueryInterception$ReventlessCore.isEnabled()).toBe(false);
40
+ QueryDb_Callback$ReventlessCore.clearQueryInterceptor();
41
+ });
42
+ });
43
+
44
+ let Interception;
45
+
46
+ export {
47
+ Interception,
48
+ }
49
+ /* Not a pure module */