@reventlessdev/reventless-core 3.0.0-alpha.200 → 3.0.0-alpha.202

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,22 @@
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.202 (2026-08-02)
7
+
8
+ ### Features
9
+
10
+ * **core:** deploy-time offload hook seam for plugin payloads ([7f970eb](https://github.com/ReventlessDev/reventless-core/commit/7f970eb449765e781559c6596ee49dd74b9451bc))
11
+ * **spec:** make plugin structure/apiSchemaFragment offloadable payloads ([a0391bf](https://github.com/ReventlessDev/reventless-core/commit/a0391bf26f91035460cbf90f52d105a1003414c3))
12
+
13
+
14
+ # 3.0.0-alpha.201 (2026-08-02)
15
+
16
+ ### Features
17
+
18
+ * **aws,core,doc:** environment-tiered CloudWatch log retention and levels ([8032023](https://github.com/ReventlessDev/reventless-core/commit/803202305a7d352484b8df5e58df03c92ca58b5f))
19
+ * **aws,core,spec,seed-aws:** expire uploads nobody committed a reference to ([f63e84c](https://github.com/ReventlessDev/reventless-core/commit/f63e84c1a11cc350b799a6f69a2e7427cf1ea6e9))
20
+
21
+
6
22
  # 3.0.0-alpha.200 (2026-08-01)
7
23
 
8
24
  * feat(aws,core,spec)!: qualify store prefixes by plugin and refuse name collisions ([da39405](https://github.com/ReventlessDev/reventless-core/commit/da394059d9f8f981bf7adc79e2c1ce2b429e0267))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.200",
3
+ "version": "3.0.0-alpha.202",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -30,20 +30,20 @@
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
33
+ "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.0",
33
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
34
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
35
35
  "@reventlessdev/rescript-node": "2.0.0-alpha.0",
36
- "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.0",
37
- "@reventlessdev/rescript-ssh2": "2.0.0-alpha.0",
38
- "@reventlessdev/reventless-infra": "3.0.0-alpha.117",
36
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
39
37
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.92",
38
+ "@reventlessdev/rescript-ssh2": "2.0.0-alpha.0",
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.119",
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.94",
41
41
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
45
45
  "sury-ppx": "11.0.0-alpha.2",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.59"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.60"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "12.3.0"
@@ -200,6 +200,11 @@ type commandHandlerConfig = {
200
200
  sqsBatchSize?: int,
201
201
  ephemeralStorageMb?: int,
202
202
  logRetentionDays?: int,
203
+ // Minimum log level for this handler ("silent" | "error" | "warn" | "info" |
204
+ // "debug"), mapped onto the `LOG_LEVEL` env var the logger reads. Overrides the
205
+ // per-environment tier default. Transport-neutral: the in-memory platform reads
206
+ // it the same way via the env var.
207
+ logLevel?: string,
203
208
  envVars?: dict<string>,
204
209
  }
205
210
 
@@ -220,5 +225,8 @@ module CommandHandlerDefaults = {
220
225
  let timeout = 30
221
226
  let sqsBatchSize = 10
222
227
  let ephemeralStorageMb = 512
223
- let logRetentionDays = 7
228
+ // No `logRetentionDays` / `logLevel` default here: both are resolved per
229
+ // environment tier by the AWS platform (`Util_LogRetention`), not by a flat
230
+ // framework constant. An unset field means "follow the stack's tier", which a
231
+ // single number could not express.
224
232
  }
@@ -48,8 +48,7 @@ let CommandHandlerDefaults = {
48
48
  memorySize: 1024,
49
49
  timeout: 30,
50
50
  sqsBatchSize: 10,
51
- ephemeralStorageMb: 512,
52
- logRetentionDays: 7
51
+ ephemeralStorageMb: 512
53
52
  };
54
53
 
55
54
  export {
@@ -309,7 +309,7 @@ module Make = (
309
309
  })
310
310
  let eventLogEntries = Array.concat(eventLogEntriesFromAggregates, dcbResult.eventLogEntries)
311
311
  Config.hooks.subscriptionInfraHook->Option.forEach(hook =>
312
- hook({allQueryDbs, allEventTopics, eventLogEntries, opts})
312
+ hook({pluginName: name, allQueryDbs, allEventTopics, eventLogEntries, opts})
313
313
  )
314
314
 
315
315
  let queryEngine = QueryEngineAdapter.make(allQueryDbs)
@@ -150,6 +150,7 @@ function Make(RuntimeEnvironment) {
150
150
  }));
151
151
  let eventLogEntries = eventLogEntriesFromAggregates.concat(dcbResult.eventLogEntries);
152
152
  Stdlib_Option.forEach(Config.hooks.subscriptionInfraHook, hook => hook({
153
+ pluginName: Platform_Admin_Structure$ReventlessCore.pluginId,
153
154
  allQueryDbs: allQueryDbs,
154
155
  allEventTopics: aggregateEventTopics,
155
156
  eventLogEntries: eventLogEntries,
@@ -89,7 +89,7 @@ let pluginStatusSubscriptionSource: GraphQL_Stitcher.subscriptionSource = {
89
89
  // (`injectAwsAuthAll(~group="Admin")`), and uploads are a regular authenticated-user
90
90
  // operation. They are added instead to the **domain** API's platform-owned base
91
91
  // (`domainBaseFragment` in the AWS/local Platform), which takes the default
92
- // `AllowAuthenticated` auth. See [docs/plans/upload-release-path.md] § "Which API".
92
+ // `AllowAuthenticated` auth. See [docs/plans/done/upload-release-path.md] § "Which API".
93
93
  let uploadTypes = [
94
94
  `type Upload_Ticket {\n uploadUrl: String!\n storageRef: String!\n}`,
95
95
  `type Upload_ReleaseResult {\n released: Boolean!\n reason: String\n}`,
@@ -0,0 +1,124 @@
1
+ // Which fields of which committed events can hold a storage ref — read off the
2
+ // declarations rather than hand-listed.
3
+ //
4
+ // This is the deploy-time half of the claim mechanism (see
5
+ // [docs/plans/done/upload-pending-claim-and-expiry.md]). An uploaded object is minted
6
+ // carrying a "pending" tag and expires while it still has one, so something has
7
+ // to notice when an event finally references it. That something needs to know
8
+ // three things, and a `@storageRef` declaration already says all three:
9
+ //
10
+ // which events to observe only variants with at least one declared field
11
+ // where the refs sit the field name, and whether it holds one or many
12
+ // which store they're in the target, qualified to `{plugin}.{store}`
13
+ //
14
+ // Deriving it here rather than in the claimer means the runtime carries a flat
15
+ // lookup table and no schema machinery: the Lambda reads `{eventType → fields}`
16
+ // and never sees a sury schema. It also means an annotation added, moved or
17
+ // deleted changes what gets claimed on the next deploy, with nothing to keep in
18
+ // sync by hand.
19
+ //
20
+ // Read the same way `Plugin_Structure` reads the same declarations — top-level
21
+ // properties of each variant, via `StorageRef.getFieldStore`. A store nested
22
+ // inside an object is not provisioned there and so is not claimed here: one
23
+ // reading, consistently narrow, rather than two that could disagree about which
24
+ // fields exist.
25
+
26
+ /** One declared ref-bearing field of one event variant. */
27
+ type refField = {
28
+ /** The field's name on the event's payload (`data` on the wire). */
29
+ field: string,
30
+ /** Whether the field holds one ref or an array of them. */
31
+ arity: Reventless.StorageRef.arity,
32
+ /** The store the refs live in, qualified `{plugin}.{store}` — already
33
+ resolved against the declaring plugin, so a reader never has to know
34
+ whose plugin it came from. */
35
+ store: string,
36
+ }
37
+
38
+ /** The ref-bearing fields of one event variant. Only variants with at least
39
+ one are produced, so an empty result means "this event log carries no
40
+ storage refs" and the caller can skip it entirely. */
41
+ type eventRefFields = {
42
+ eventType: string,
43
+ fields: array<refField>,
44
+ }
45
+
46
+ // A variant is `{TAG: "<Name>", …fields}` (payload-less variants are a bare
47
+ // string const and can hold nothing, so they drop out here).
48
+ let fromVariant = (~plugin: string, variant: S.t<unknown>): option<eventRefFields> =>
49
+ switch variant {
50
+ | S.Object({properties}) =>
51
+ switch properties->Dict.get("TAG") {
52
+ | Some(String({const: ?Some(eventType)})) =>
53
+ let fields =
54
+ properties
55
+ ->Dict.toArray
56
+ ->Array.filterMap(((field, fieldSchema)) =>
57
+ Reventless.StorageRef.getFieldStore(fieldSchema)->Option.map(((target, arity)) => {
58
+ field,
59
+ arity,
60
+ // An unqualified store belongs to the declaring plugin — the same
61
+ // resolution `Plugin_Structure` makes, so the key the claimer looks
62
+ // up is the key the deploy provisioned.
63
+ store: target.plugin->Option.getOr(plugin) ++ "." ++ target.store,
64
+ })
65
+ )
66
+ ->Array.toSorted((a, b) => String.compare(a.field, b.field))
67
+ fields->Array.length == 0 ? None : Some({eventType, fields})
68
+ | _ => None
69
+ }
70
+ | _ => None
71
+ }
72
+
73
+ /**
74
+ The ref-bearing fields of every variant in an event log's event schema.
75
+
76
+ `~plugin` is the registered name of the plugin the event log belongs to, used
77
+ to qualify a store the annotation left unqualified.
78
+ */
79
+ let fromEventSchema = (~plugin: string, eventSchema: S.t<unknown>): array<eventRefFields> =>
80
+ switch eventSchema {
81
+ | Union({anyOf}) => anyOf->Array.filterMap(v => fromVariant(~plugin, v))
82
+ | other => fromVariant(~plugin, other)->Option.mapOr([], e => [e])
83
+ }
84
+
85
+ /**
86
+ The wire form the claimer reads: `{"<eventType>": [{"field", "arity", "store"}]}`.
87
+
88
+ A flat JSON object rather than a schema, because it crosses into a Lambda's
89
+ environment: the runtime side does a dictionary lookup and a field read, and
90
+ carries no sury dependency to do it.
91
+ */
92
+ let toJson = (entries: array<eventRefFields>): JSON.t =>
93
+ entries
94
+ ->Array.map(({eventType, fields}) => (
95
+ eventType,
96
+ fields
97
+ ->Array.map(({field, arity, store}) =>
98
+ Dict.fromArray([
99
+ ("field", JSON.Encode.string(field)),
100
+ (
101
+ "arity",
102
+ JSON.Encode.string(
103
+ switch arity {
104
+ | Single => "one"
105
+ | Multiple => "many"
106
+ },
107
+ ),
108
+ ),
109
+ ("store", JSON.Encode.string(store)),
110
+ ])->JSON.Encode.object
111
+ )
112
+ ->JSON.Encode.array,
113
+ ))
114
+ ->Dict.fromArray
115
+ ->JSON.Encode.object
116
+
117
+ /** The distinct stores an event log's declarations point at. What the claimer's
118
+ IAM has to cover for that log, and what a coverage check compares against
119
+ the set of stores the deploy actually provisioned. */
120
+ let storesOf = (entries: array<eventRefFields>): array<string> =>
121
+ entries
122
+ ->Array.flatMap(({fields}) => fields->Array.map(({store}) => store))
123
+ ->Belt.Set.String.fromArray
124
+ ->Belt.Set.String.toArray
@@ -0,0 +1,88 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
6
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
7
+ import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
8
+
9
+ function fromVariant(plugin, variant) {
10
+ if (variant.type !== "object") {
11
+ return;
12
+ }
13
+ let properties = variant.properties;
14
+ let match = properties["TAG"];
15
+ if (match === undefined) {
16
+ return;
17
+ }
18
+ if (match.type !== "string") {
19
+ return;
20
+ }
21
+ let eventType = match.const;
22
+ if (eventType === undefined) {
23
+ return;
24
+ }
25
+ let fields = Stdlib_Array.filterMap(Object.entries(properties), param => {
26
+ let field = param[0];
27
+ return Stdlib_Option.map(StorageRef$Reventless.getFieldStore(param[1]), param => {
28
+ let target = param[0];
29
+ return {
30
+ field: field,
31
+ arity: param[1],
32
+ store: Stdlib_Option.getOr(target.plugin, plugin) + "." + target.store
33
+ };
34
+ });
35
+ }).toSorted((a, b) => Primitive_string.compare(a.field, b.field));
36
+ if (fields.length === 0) {
37
+ return;
38
+ } else {
39
+ return {
40
+ eventType: eventType,
41
+ fields: fields
42
+ };
43
+ }
44
+ }
45
+
46
+ function fromEventSchema(plugin, eventSchema) {
47
+ if (eventSchema.type === "union") {
48
+ return Stdlib_Array.filterMap(eventSchema.anyOf, v => fromVariant(plugin, v));
49
+ } else {
50
+ return Stdlib_Option.mapOr(fromVariant(plugin, eventSchema), [], e => [e]);
51
+ }
52
+ }
53
+
54
+ function toJson(entries) {
55
+ return Object.fromEntries(entries.map(param => [
56
+ param.eventType,
57
+ param.fields.map(param => {
58
+ let tmp;
59
+ tmp = param.arity === "Single" ? "one" : "many";
60
+ return Object.fromEntries([
61
+ [
62
+ "field",
63
+ param.field
64
+ ],
65
+ [
66
+ "arity",
67
+ tmp
68
+ ],
69
+ [
70
+ "store",
71
+ param.store
72
+ ]
73
+ ]);
74
+ })
75
+ ]));
76
+ }
77
+
78
+ function storesOf(entries) {
79
+ return Belt_SetString.toArray(Belt_SetString.fromArray(entries.flatMap(param => param.fields.map(param => param.store))));
80
+ }
81
+
82
+ export {
83
+ fromVariant,
84
+ fromEventSchema,
85
+ toJson,
86
+ storesOf,
87
+ }
88
+ /* StorageRef-Reventless Not a pure module */
@@ -738,6 +738,31 @@ module Make = (
738
738
  // so reading it inside the async callback would always yield "Domain".
739
739
  let capturedDeployTarget = Spec.hooks.deployTarget.contents
740
740
 
741
+ // Offload the two large pluginDefinition fields at deploy time when a
742
+ // provider offers an object store (AWS writes a content-addressed object
743
+ // and returns a reference); unset locally, so they stay Inline. Both
744
+ // values are concrete here, so the hook — and the object write it does —
745
+ // runs at graph-construction time, before the Pulumi.Output.apply below
746
+ // (a resource cannot be created inside .apply). Unconditional: these
747
+ // fields are always large enough to be worth offloading.
748
+ let toPayload = (value, ~schema, ~store) =>
749
+ switch Plugin_Helpers.offloadHook.contents {
750
+ | Some(hook) =>
751
+ Reventless.Offload.Offloaded(
752
+ hook(~store, ~bytes=value->S.reverseConvertToJsonStringOrThrow(schema)),
753
+ )
754
+ | None => Reventless.Offload.Inline(value)
755
+ }
756
+ let apiSchemaFragmentPayload = toPayload(
757
+ apiSchemaFragment,
758
+ ~schema=Reventless.Plugin.apiSchemaFragmentSchema,
759
+ ~store="pluginApiFragments",
760
+ )
761
+ let structurePayload =
762
+ pluginStructure->Option.map(s =>
763
+ toPayload(s, ~schema=Reventless.Plugin.pluginStructureSchema, ~store="pluginStructures")
764
+ )
765
+
741
766
  // DCB EventLog definition — Some when this plugin has a DcbEventLog
742
767
  // component, None otherwise. Admin's manageSubscriptions reads this to
743
768
  // provision cross-plugin SNS subscriptions from peer plugins' DCB
@@ -769,9 +794,9 @@ module Make = (
769
794
  extensions: extensionsDefinitions,
770
795
  eventCollector: eventCollectorUrn,
771
796
  extensionProtocols: [],
772
- apiSchemaFragment: Some(apiSchemaFragment),
797
+ apiSchemaFragment: Some(apiSchemaFragmentPayload),
773
798
  apiTarget: Some(capturedDeployTarget),
774
- structure: pluginStructure,
799
+ structure: structurePayload,
775
800
  dcbEventLog: dcbEventLogDef,
776
801
  // Business role from the deploy-time metadata registry (set via
777
802
  // registerPluginMetadata). Unset → Domain. Rides the handshake to the
@@ -888,7 +913,7 @@ module Make = (
888
913
  // Gives the AWS platform access to allQueryDbs, allEventTopics, and eventLogEntries
889
914
  // without coupling reventless-core to reventless-aws.
890
915
  Spec.hooks.subscriptionInfraHook->Option.forEach(hook =>
891
- hook({allQueryDbs, allEventTopics, eventLogEntries, opts})
916
+ hook({pluginName: name, allQueryDbs, allEventTopics, eventLogEntries, opts})
892
917
  )
893
918
 
894
919
  module SpecificHeartbeat = Heartbeat_Builder.Make(HeartbeatRunner)
@@ -8,6 +8,7 @@ import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
8
8
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
9
9
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
10
10
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
11
+ import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
11
12
  import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
12
13
  import * as Plugin$ReventlessCore from "./Plugin.res.mjs";
13
14
  import * as StackReference$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/StackReference.res.mjs";
@@ -486,6 +487,22 @@ function Make(Spec) {
486
487
  let match$3 = EventCollectorHelper.make(childName, eventTopics, opts);
487
488
  let eventCollector = match$3[0];
488
489
  let capturedDeployTarget = Spec.hooks.deployTarget.contents;
490
+ let toPayload = (value, schema, store) => {
491
+ let hook = Plugin_Helpers$ReventlessCore.offloadHook.contents;
492
+ if (hook !== undefined) {
493
+ return {
494
+ TAG: "Offloaded",
495
+ _0: hook(store, S.reverseConvertToJsonStringOrThrow(value, schema, undefined))
496
+ };
497
+ } else {
498
+ return {
499
+ TAG: "Inline",
500
+ _0: value
501
+ };
502
+ }
503
+ };
504
+ let apiSchemaFragmentPayload = toPayload(apiSchemaFragment, Plugin$Reventless.apiSchemaFragmentSchema, "pluginApiFragments");
505
+ let structurePayload = Stdlib_Option.map(pluginStructure, s => toPayload(s, Plugin$Reventless.pluginStructureSchema, "pluginStructures"));
489
506
  let dcbOutputs$1 = dcbResult.dcbEventLogOutputs;
490
507
  let dcbEventLogDef;
491
508
  if (dcbOutputs$1 !== undefined) {
@@ -509,9 +526,9 @@ function Make(Spec) {
509
526
  extensions: extensionsDefinitions,
510
527
  eventCollector: param[1],
511
528
  extensionProtocols: [],
512
- apiSchemaFragment: apiSchemaFragment,
529
+ apiSchemaFragment: apiSchemaFragmentPayload,
513
530
  apiTarget: capturedDeployTarget,
514
- structure: pluginStructure,
531
+ structure: structurePayload,
515
532
  dcbEventLog: param[2],
516
533
  kind: Stdlib_Option.getOr(Stdlib_Option.flatMap(Plugin_Helpers$ReventlessCore.pluginMetadataRegistry.contents, m => m.kind), "Domain")
517
534
  }));
@@ -547,6 +564,7 @@ function Make(Spec) {
547
564
  let tasksOutputs = Plugin_Helpers$ReventlessCore.createTasks(tasks, aggregatesOutputs, scheduler, schedulerRoleUrn, publishToAggregates, queryEngine, Spec.resourceNaming, componentRuntime, opts);
548
565
  let resolvers = Plugin_Helpers$ReventlessCore.createResolvers(allQueryDbs);
549
566
  Stdlib_Option.forEach(Spec.hooks.subscriptionInfraHook, hook => hook({
567
+ pluginName: extra$1,
550
568
  allQueryDbs: allQueryDbs,
551
569
  allEventTopics: aggregateEventTopics,
552
570
  eventLogEntries: eventLogEntries,
@@ -785,6 +785,27 @@ let clearOnPluginDeployed = () => {
785
785
  onPluginDeployedHook.contents = None
786
786
  }
787
787
 
788
+ // ---------------------------------------------------------------------------
789
+ // Deploy-time offload hook — content-addresses a large pluginDefinition field
790
+ // to an object store and returns the reference. Set by a provider that can
791
+ // write objects (AWS creates a BucketObject); unset on the local platform, so
792
+ // the fields stay Inline. `bytes` is the already-serialized JSON of the value;
793
+ // the provider hashes it, stores it at `sha256/<hash>`, and returns the ref.
794
+ // Fired synchronously at graph-construction time (the value is concrete and a
795
+ // resource cannot be created inside a Pulumi.Output.apply).
796
+ // ---------------------------------------------------------------------------
797
+ let offloadHook: ref<option<(~store: string, ~bytes: string) => Reventless.Offload.offloadedRef>> = ref(
798
+ None,
799
+ )
800
+
801
+ let registerOffload = (hook: (~store: string, ~bytes: string) => Reventless.Offload.offloadedRef) => {
802
+ offloadHook.contents = Some(hook)
803
+ }
804
+
805
+ let clearOffload = () => {
806
+ offloadHook.contents = None
807
+ }
808
+
788
809
  // ---------------------------------------------------------------------------
789
810
  // Platform-deployed hook — fires after deployPlatform / makePlatform
790
811
  // completes, providing platform-level metadata with resolved values.
@@ -911,6 +932,11 @@ type eventQueryRegistrationParams = {
911
932
  // are assembled inside builderOutputs. Lets the AWS platform wire StateTopic and
912
933
  // EventLogSubscription Lambdas without touching reventless-core.
913
934
  type subscriptionInfraParams = {
935
+ /** The plugin's registered name. Carried because infrastructure wired from
936
+ here can be per plugin rather than per component — and because a
937
+ declaration that names a store without qualifying it means "this plugin's
938
+ own", which is unreadable without knowing whose plugin it is. */
939
+ pluginName: string,
914
940
  allQueryDbs: ReventlessInfra.QueryDb.allOutputs,
915
941
  allEventTopics: dict<ReventlessInfra.EventTopic.outputs>,
916
942
  eventLogEntries: array<ReventlessInfra.Api.eventLogSchemaEntry>,
@@ -418,6 +418,18 @@ function clearOnPluginDeployed() {
418
418
  onPluginDeployedHook.contents = undefined;
419
419
  }
420
420
 
421
+ let offloadHook = {
422
+ contents: undefined
423
+ };
424
+
425
+ function registerOffload(hook) {
426
+ offloadHook.contents = hook;
427
+ }
428
+
429
+ function clearOffload() {
430
+ offloadHook.contents = undefined;
431
+ }
432
+
421
433
  let onPlatformDeployedHook = {
422
434
  contents: undefined
423
435
  };
@@ -1015,6 +1027,9 @@ export {
1015
1027
  onPluginDeployedHook,
1016
1028
  registerOnPluginDeployed,
1017
1029
  clearOnPluginDeployed,
1030
+ offloadHook,
1031
+ registerOffload,
1032
+ clearOffload,
1018
1033
  onPlatformDeployedHook,
1019
1034
  lastPlatformDeployedInfo,
1020
1035
  registerOnPlatformDeployed,
@@ -503,7 +503,11 @@ let make = (
503
503
  properties
504
504
  ->Dict.toArray
505
505
  ->Array.filterMap(((field, fieldSchema)) =>
506
- Reventless.StorageRef.getStore(fieldSchema)->Option.map(target => {
506
+ // `getFieldStore`, not `getStore`: a `@storageRef("s") urls: array<string>`
507
+ // field carries the marker on its *element*, so reading the field schema
508
+ // directly answered `None` and the store went unprovisioned — the same
509
+ // silence as never having written the annotation.
510
+ Reventless.StorageRef.getFieldStore(fieldSchema)->Option.map(((target, _arity)) => {
507
511
  Reventless.Plugin.store: target.plugin->Option.getOr(name) ++ "." ++ target.store,
508
512
  component,
509
513
  field,
@@ -446,7 +446,8 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
446
446
  let properties = v.properties;
447
447
  return Stdlib_Array.filterMap(Object.entries(properties), param => {
448
448
  let field = param[0];
449
- return Stdlib_Option.map(StorageRef$Reventless.getStore(param[1]), target => {
449
+ return Stdlib_Option.map(StorageRef$Reventless.getFieldStore(param[1]), param => {
450
+ let target = param[0];
450
451
  let plugin = target.plugin;
451
452
  return {
452
453
  store: Stdlib_Option.getOr(target.plugin, name) + "." + target.store,
@@ -24,13 +24,15 @@ type state = {
24
24
  extensions: array<Reventless.Plugin.extensionDefinition>,
25
25
  status: status,
26
26
  statusChange: Message.statusChange,
27
- apiSchemaFragment: @s.matches(Reventless.Plugin.apiSchemaFragmentOptionSchema) option<Reventless.Plugin.apiSchemaFragment>,
27
+ apiSchemaFragment: @s.matches(Reventless.Plugin.apiSchemaFragmentOffloadSchema)
28
+ option<Reventless.Offload.payload<Reventless.Plugin.apiSchemaFragment>>,
28
29
  // API target for split-API schema routing. Absent/None means "Domain" (backward compat).
29
30
  // "Platform" → excluded from DomainApi runtime schema stitching in updateApiSchema.
30
31
  apiTarget?: string,
31
32
  // Plugin structure (component metadata) — surfaced via Platform_ComponentDefinitions.
32
33
  // None for older plugins whose protocol version did not carry the field.
33
- structure: @s.matches(Reventless.Plugin.pluginStructureOptionSchema) option<Reventless.Plugin.pluginStructure>,
34
+ structure: @s.matches(Reventless.Plugin.pluginStructureOffloadSchema)
35
+ option<Reventless.Offload.payload<Reventless.Plugin.pluginStructure>>,
34
36
  // DCB EventLog definition for plugins that bundle a DcbEventLog component.
35
37
  // Admin's manageSubscriptions uses this to wire cross-plugin SNS subscriptions
36
38
  // from this plugin's DCB topic → peer EventCollectors (and vice-versa). None
@@ -72,9 +74,9 @@ type queryResult = {
72
74
  extensionNames: array<string>,
73
75
  extensions: array<Reventless.Plugin.extensionDefinition>,
74
76
  status: status,
75
- apiSchemaFragment: option<Reventless.Plugin.apiSchemaFragment>,
77
+ apiSchemaFragment: option<Reventless.Offload.payload<Reventless.Plugin.apiSchemaFragment>>,
76
78
  apiTarget?: string,
77
- structure: option<Reventless.Plugin.pluginStructure>,
79
+ structure: option<Reventless.Offload.payload<Reventless.Plugin.pluginStructure>>,
78
80
  dcbEventLog: option<Reventless.Plugin.dcbEventLogDefinition>,
79
81
  kind: option<Reventless.Plugin.pluginKind>,
80
82
  }
@@ -23,9 +23,9 @@ let stateSchema = S.schema(s => ({
23
23
  extensions: s.m(S.array(Plugin$Reventless.extensionDefinitionSchema)),
24
24
  status: s.m(statusSchema),
25
25
  statusChange: s.m(Message$ReventlessCore.statusChangeSchema),
26
- apiSchemaFragment: s.m(Plugin$Reventless.apiSchemaFragmentOptionSchema),
26
+ apiSchemaFragment: s.m(Plugin$Reventless.apiSchemaFragmentOffloadSchema),
27
27
  apiTarget: s.m(S.option(S.string)),
28
- structure: s.m(Plugin$Reventless.pluginStructureOptionSchema),
28
+ structure: s.m(Plugin$Reventless.pluginStructureOffloadSchema),
29
29
  dcbEventLog: s.m(Plugin$Reventless.dcbEventLogOptionSchema),
30
30
  kind: s.m(S.option(Plugin$Reventless.pluginKindSchema)),
31
31
  otherConnectedVersions: s.m(S.array(Plugin$Reventless.versionSchema))
@@ -0,0 +1,148 @@
1
+ // What the claim component ever gets to see, decided here.
2
+ //
3
+ // `StorageRefFields.fromEventSchema` is the deploy-time reading of the
4
+ // `@storageRef` declarations that becomes the claimer's entire input: an event
5
+ // variant it does not produce is an event the claimer never reads a record for,
6
+ // and a field it does not list is a ref that never gets claimed — so the object
7
+ // keeps its pending tag and, on a store with expiry enabled, is eventually
8
+ // deleted while something still references it.
9
+ //
10
+ // Which makes the interesting assertions the negative ones: what is *absent*
11
+ // from this output is what silently rots.
12
+
13
+ open JestGlobals
14
+
15
+ module Fields = StorageRefFields
16
+
17
+ // An event union, shaped the way sury encodes a ReScript variant: one object per
18
+ // variant, discriminated by a `TAG` string constant.
19
+ let variant = (~tag: string, fields: array<(string, S.t<unknown>)>): S.t<unknown> =>
20
+ S.object(s => {
21
+ let _ = s.field("TAG", S.literal(tag)->S.castToUnknown)
22
+ fields->Array.forEach(((name, schema)) => {
23
+ let _ = s.field(name, schema)
24
+ })
25
+ ()
26
+ })->S.castToUnknown
27
+
28
+ let ref_ = (~plugin: option<string>=?, ~store: string) =>
29
+ Reventless.StorageRef.forStore(~plugin?, ~store)->S.castToUnknown
30
+
31
+ describe("StorageRefFields.fromEventSchema", () => {
32
+ testSync("finds a declared single-valued ref field and qualifies its store", () => {
33
+ let schema = S.union([variant(~tag="ProductImageChanged", [("imageUrl", ref_(~store="productImages"))])])->S.castToUnknown
34
+ expect(Fields.fromEventSchema(~plugin="Catalog", schema))->toEqual([
35
+ {
36
+ Fields.eventType: "ProductImageChanged",
37
+ fields: [
38
+ {Fields.field: "imageUrl", arity: Single, store: "Catalog.productImages"},
39
+ ],
40
+ },
41
+ ])
42
+ })
43
+
44
+ // The annotation's own qualification wins: a field pointing at another
45
+ // plugin's store must not be re-attributed to the plugin that wrote it, or the
46
+ // claimer resolves the wrong bucket and refuses the ref as out-of-store.
47
+ testSync("a qualified declaration keeps the store it names", () => {
48
+ let schema =
49
+ S.union([
50
+ variant(~tag="OrderPhotoAttached", [("photo", ref_(~plugin="Catalog", ~store="productImages"))]),
51
+ ])->S.castToUnknown
52
+ expect(
53
+ Fields.fromEventSchema(~plugin="Ordering", schema)
54
+ ->Array.flatMap(e => e.fields->Array.map(f => f.store)),
55
+ )->toEqual(["Catalog.productImages"])
56
+ })
57
+
58
+ // `@storageRef` on an `array<string>` attaches the marker to the ELEMENT, so
59
+ // reading the field schema directly answers None. The claimer needs the arity
60
+ // to know whether to read one value or iterate.
61
+ testSync("finds a multi-valued ref field and records its arity", () => {
62
+ let schema =
63
+ S.union([
64
+ variant(~tag="GalleryReplaced", [("imageUrls", S.array(ref_(~store="productImages"))->S.castToUnknown)]),
65
+ ])->S.castToUnknown
66
+ expect(
67
+ Fields.fromEventSchema(~plugin="Catalog", schema)->Array.flatMap(e => e.fields),
68
+ )->toEqual([{Fields.field: "imageUrls", arity: Multiple, store: "Catalog.productImages"}])
69
+ })
70
+
71
+ // Narrow input by construction: a platform whose plugins declare no store
72
+ // provisions no claimer at all, and an event log carrying no declaration is
73
+ // never subscribed to.
74
+ testSync("a variant with no declared field is omitted entirely", () => {
75
+ let schema =
76
+ S.union([
77
+ variant(~tag="ProductRenamed", [("name", S.string->S.castToUnknown)]),
78
+ variant(~tag="ProductImageChanged", [("imageUrl", ref_(~store="productImages"))]),
79
+ ])->S.castToUnknown
80
+ expect(Fields.fromEventSchema(~plugin="Catalog", schema)->Array.map(e => e.eventType))->toEqual([
81
+ "ProductImageChanged",
82
+ ])
83
+ })
84
+
85
+ testSync("an event schema with no declaration anywhere yields nothing", () => {
86
+ let schema = S.union([variant(~tag="ProductRenamed", [("name", S.string->S.castToUnknown)])])->S.castToUnknown
87
+ expect(Fields.fromEventSchema(~plugin="Catalog", schema))->toEqual([])
88
+ })
89
+
90
+ // A string field that merely looks like a ref is not one. Declaration
91
+ // outranks inference everywhere else in the framework, and this is the reading
92
+ // that has to agree with `Plugin_Structure`'s — the claimer can only reach a
93
+ // store the deploy actually provisioned.
94
+ testSync("an unannotated string field is not treated as a ref", () => {
95
+ let schema =
96
+ S.union([variant(~tag="ProductImported", [("imageUrl", S.string->S.castToUnknown)])])->S.castToUnknown
97
+ expect(Fields.fromEventSchema(~plugin="Catalog", schema))->toEqual([])
98
+ })
99
+
100
+ testSync("a single-variant (non-union) event schema is read the same way", () => {
101
+ let schema = variant(~tag="ProductImageChanged", [("imageUrl", ref_(~store="productImages"))])
102
+ expect(Fields.fromEventSchema(~plugin="Catalog", schema)->Array.map(e => e.eventType))->toEqual([
103
+ "ProductImageChanged",
104
+ ])
105
+ })
106
+ })
107
+
108
+ describe("StorageRefFields.toJson", () => {
109
+ // The wire form crosses into a Lambda's environment, where the handler decodes
110
+ // it with plain JSON reads. Both ends are pinned here because a drift is
111
+ // silent: an unreadable entry is indistinguishable from an absent one, and an
112
+ // absent one means "nothing to claim".
113
+ testSync("emits field, arity and store per event type", () => {
114
+ let entries: array<Fields.eventRefFields> = [
115
+ {
116
+ eventType: "ProductImageChanged",
117
+ fields: [{field: "imageUrl", arity: Single, store: "Catalog.productImages"}],
118
+ },
119
+ {
120
+ eventType: "GalleryReplaced",
121
+ fields: [{field: "imageUrls", arity: Multiple, store: "Catalog.productImages"}],
122
+ },
123
+ ]
124
+ expect(Fields.toJson(entries)->JSON.stringify)->toBe(
125
+ `{"ProductImageChanged":[{"field":"imageUrl","arity":"one","store":"Catalog.productImages"}],` ++
126
+ `"GalleryReplaced":[{"field":"imageUrls","arity":"many","store":"Catalog.productImages"}]}`,
127
+ )
128
+ })
129
+ })
130
+
131
+ describe("StorageRefFields.storesOf", () => {
132
+ testSync("collects the distinct stores an event log's declarations reach", () => {
133
+ let entries: array<Fields.eventRefFields> = [
134
+ {
135
+ eventType: "A",
136
+ fields: [{field: "a", arity: Single, store: "Catalog.productImages"}],
137
+ },
138
+ {
139
+ eventType: "B",
140
+ fields: [
141
+ {field: "b", arity: Single, store: "Catalog.productImages"},
142
+ {field: "c", arity: Multiple, store: "Ordering.receipts"},
143
+ ],
144
+ },
145
+ ]
146
+ expect(Fields.storesOf(entries))->toEqual(["Catalog.productImages", "Ordering.receipts"])
147
+ })
148
+ })
@@ -0,0 +1,152 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
5
+ import * as StorageRefFields$ReventlessCore from "../../src/components/Api/StorageRefFields.res.mjs";
6
+
7
+ function variant(tag, fields) {
8
+ return S.object(s => {
9
+ s.f("TAG", S.literal(tag));
10
+ fields.forEach(param => {
11
+ s.f(param[0], param[1]);
12
+ });
13
+ });
14
+ }
15
+
16
+ let ref_ = StorageRef$Reventless.forStore;
17
+
18
+ globalThis.describe("StorageRefFields.fromEventSchema", () => {
19
+ globalThis.test("finds a declared single-valued ref field and qualifies its store", () => {
20
+ let schema = S.union([variant("ProductImageChanged", [[
21
+ "imageUrl",
22
+ StorageRef$Reventless.forStore(undefined, "productImages")
23
+ ]])]);
24
+ globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Catalog", schema)).toEqual([{
25
+ eventType: "ProductImageChanged",
26
+ fields: [{
27
+ field: "imageUrl",
28
+ arity: "Single",
29
+ store: "Catalog.productImages"
30
+ }]
31
+ }]);
32
+ });
33
+ globalThis.test("a qualified declaration keeps the store it names", () => {
34
+ let schema = S.union([variant("OrderPhotoAttached", [[
35
+ "photo",
36
+ StorageRef$Reventless.forStore("Catalog", "productImages")
37
+ ]])]);
38
+ globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Ordering", schema).flatMap(e => e.fields.map(f => f.store))).toEqual(["Catalog.productImages"]);
39
+ });
40
+ globalThis.test("finds a multi-valued ref field and records its arity", () => {
41
+ let schema = S.union([variant("GalleryReplaced", [[
42
+ "imageUrls",
43
+ S.array(StorageRef$Reventless.forStore(undefined, "productImages"))
44
+ ]])]);
45
+ globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Catalog", schema).flatMap(e => e.fields)).toEqual([{
46
+ field: "imageUrls",
47
+ arity: "Multiple",
48
+ store: "Catalog.productImages"
49
+ }]);
50
+ });
51
+ globalThis.test("a variant with no declared field is omitted entirely", () => {
52
+ let schema = S.union([
53
+ variant("ProductRenamed", [[
54
+ "name",
55
+ S.string
56
+ ]]),
57
+ variant("ProductImageChanged", [[
58
+ "imageUrl",
59
+ StorageRef$Reventless.forStore(undefined, "productImages")
60
+ ]])
61
+ ]);
62
+ globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Catalog", schema).map(e => e.eventType)).toEqual(["ProductImageChanged"]);
63
+ });
64
+ globalThis.test("an event schema with no declaration anywhere yields nothing", () => {
65
+ let schema = S.union([variant("ProductRenamed", [[
66
+ "name",
67
+ S.string
68
+ ]])]);
69
+ globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Catalog", schema)).toEqual([]);
70
+ });
71
+ globalThis.test("an unannotated string field is not treated as a ref", () => {
72
+ let schema = S.union([variant("ProductImported", [[
73
+ "imageUrl",
74
+ S.string
75
+ ]])]);
76
+ globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Catalog", schema)).toEqual([]);
77
+ });
78
+ globalThis.test("a single-variant (non-union) event schema is read the same way", () => {
79
+ let schema = variant("ProductImageChanged", [[
80
+ "imageUrl",
81
+ StorageRef$Reventless.forStore(undefined, "productImages")
82
+ ]]);
83
+ globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Catalog", schema).map(e => e.eventType)).toEqual(["ProductImageChanged"]);
84
+ });
85
+ });
86
+
87
+ globalThis.describe("StorageRefFields.toJson", () => {
88
+ globalThis.test("emits field, arity and store per event type", () => {
89
+ let entries = [
90
+ {
91
+ eventType: "ProductImageChanged",
92
+ fields: [{
93
+ field: "imageUrl",
94
+ arity: "Single",
95
+ store: "Catalog.productImages"
96
+ }]
97
+ },
98
+ {
99
+ eventType: "GalleryReplaced",
100
+ fields: [{
101
+ field: "imageUrls",
102
+ arity: "Multiple",
103
+ store: "Catalog.productImages"
104
+ }]
105
+ }
106
+ ];
107
+ globalThis.expect(JSON.stringify(StorageRefFields$ReventlessCore.toJson(entries))).toBe(`{"ProductImageChanged":[{"field":"imageUrl","arity":"one","store":"Catalog.productImages"}],"GalleryReplaced":[{"field":"imageUrls","arity":"many","store":"Catalog.productImages"}]}`);
108
+ });
109
+ });
110
+
111
+ globalThis.describe("StorageRefFields.storesOf", () => {
112
+ globalThis.test("collects the distinct stores an event log's declarations reach", () => {
113
+ let entries = [
114
+ {
115
+ eventType: "A",
116
+ fields: [{
117
+ field: "a",
118
+ arity: "Single",
119
+ store: "Catalog.productImages"
120
+ }]
121
+ },
122
+ {
123
+ eventType: "B",
124
+ fields: [
125
+ {
126
+ field: "b",
127
+ arity: "Single",
128
+ store: "Catalog.productImages"
129
+ },
130
+ {
131
+ field: "c",
132
+ arity: "Multiple",
133
+ store: "Ordering.receipts"
134
+ }
135
+ ]
136
+ }
137
+ ];
138
+ globalThis.expect(StorageRefFields$ReventlessCore.storesOf(entries)).toEqual([
139
+ "Catalog.productImages",
140
+ "Ordering.receipts"
141
+ ]);
142
+ });
143
+ });
144
+
145
+ let Fields;
146
+
147
+ export {
148
+ Fields,
149
+ variant,
150
+ ref_,
151
+ }
152
+ /* Not a pure module */
@@ -109,7 +109,7 @@ describe("Message should", () => {
109
109
  extensionProtocols: [],
110
110
  apiSchemaFragment: None,
111
111
  apiTarget: None,
112
- structure: Some({
112
+ structure: Some(Reventless.Offload.Inline({
113
113
  readModels: [],
114
114
  stateViewSlices: [],
115
115
  stateChangeSlices: [],
@@ -132,7 +132,7 @@ describe("Message should", () => {
132
132
  extensionPoints: None,
133
133
  requiredStores: None,
134
134
  requiredStoreDeclarations: None,
135
- }),
135
+ })),
136
136
  dcbEventLog: None,
137
137
  kind: Domain,
138
138
  }
@@ -186,7 +186,8 @@ describe("Message should", () => {
186
186
  },
187
187
  )->toBe("Domain")
188
188
  expect(d.apiSchemaFragment->Option.isNone)->toBe(true)
189
- let structure: Reventless.Plugin.pluginStructure = d.structure->Option.getOrThrow
189
+ let structure: Reventless.Plugin.pluginStructure =
190
+ d.structure->Option.flatMap(Reventless.Offload.getInline)->Option.getOrThrow
190
191
  let agg = structure.aggregates->Array.getUnsafe(0)
191
192
  expect(agg.events->Array.length)->toBe(0)
192
193
  expect(agg.chapter->Option.isNone)->toBe(true)
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as S from "sury/src/S.res.mjs";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Offload$Reventless from "@reventlessdev/reventless-spec/src/semantic/Offload.res.mjs";
5
6
  import * as Message$ReventlessCore from "../../src/Message.res.mjs";
6
7
  import * as PluginSpec$ReventlessCore from "../../src/plugin/lifecycle/PluginSpec.res.mjs";
7
8
 
@@ -100,26 +101,29 @@ globalThis.describe("Message should", () => {
100
101
  apiSchemaFragment: undefined,
101
102
  apiTarget: undefined,
102
103
  structure: {
103
- readModels: [],
104
- stateViewSlices: [],
105
- stateChangeSlices: [],
106
- aggregates: [{
107
- name: "Order",
108
- commands: [],
109
- producedEventTypes: [],
110
- consumedEventTypes: [],
111
- linkedViews: [],
112
- consistencyRead: undefined,
113
- events: [],
114
- chapter: undefined
115
- }],
116
- automationSlices: [],
117
- outboundTranslationSlices: [],
118
- inboundTranslationSlices: [],
119
- extensions: [],
120
- extensionPoints: undefined,
121
- requiredStores: undefined,
122
- requiredStoreDeclarations: undefined
104
+ TAG: "Inline",
105
+ _0: {
106
+ readModels: [],
107
+ stateViewSlices: [],
108
+ stateChangeSlices: [],
109
+ aggregates: [{
110
+ name: "Order",
111
+ commands: [],
112
+ producedEventTypes: [],
113
+ consumedEventTypes: [],
114
+ linkedViews: [],
115
+ consistencyRead: undefined,
116
+ events: [],
117
+ chapter: undefined
118
+ }],
119
+ automationSlices: [],
120
+ outboundTranslationSlices: [],
121
+ inboundTranslationSlices: [],
122
+ extensions: [],
123
+ extensionPoints: undefined,
124
+ requiredStores: undefined,
125
+ requiredStoreDeclarations: undefined
126
+ }
123
127
  },
124
128
  dcbEventLog: undefined,
125
129
  kind: "Domain"
@@ -169,7 +173,7 @@ globalThis.describe("Message should", () => {
169
173
  tmp = match === "Domain" ? "Domain" : "other";
170
174
  globalThis.expect(tmp).toBe("Domain");
171
175
  globalThis.expect(Stdlib_Option.isNone(d$1.apiSchemaFragment)).toBe(true);
172
- let structure = Stdlib_Option.getOrThrow(d$1.structure, undefined);
176
+ let structure = Stdlib_Option.getOrThrow(Stdlib_Option.flatMap(d$1.structure, Offload$Reventless.getInline), undefined);
173
177
  let agg = structure.aggregates[0];
174
178
  globalThis.expect(agg.events.length).toBe(0);
175
179
  globalThis.expect(Stdlib_Option.isNone(agg.chapter)).toBe(true);