@reventlessdev/reventless-aws 3.0.0-alpha.280 → 3.0.0-alpha.282

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,20 @@
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.282 (2026-08-10)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** load query-interceptor extensions in init, and let it log ([c93612b](https://github.com/ReventlessDev/reventless-core/commit/c93612b3d2208cdc3f1c4b4dbef615b974c5e74a))
11
+
12
+
13
+ # 3.0.0-alpha.281 (2026-08-10)
14
+
15
+ ### Features
16
+
17
+ * **admin:** carry Internal queryables on their own manifest field ([f5aa03d](https://github.com/ReventlessDev/reventless-core/commit/f5aa03d60b0b2554d116fe9b3784d30f2c4ef356))
18
+
19
+
6
20
  # 3.0.0-alpha.280 (2026-08-10)
7
21
 
8
22
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.280",
3
+ "version": "3.0.0-alpha.282",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -19,11 +19,11 @@
19
19
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.69",
20
20
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
21
21
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
- "@reventlessdev/reventless-core": "3.0.0-alpha.222",
23
- "@reventlessdev/reventless-infra": "3.0.0-alpha.132",
22
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.133",
23
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.87",
24
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.107",
24
25
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
25
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.86",
26
- "@reventlessdev/reventless-spec": "3.0.0-alpha.106"
26
+ "@reventlessdev/reventless-core": "3.0.0-alpha.223"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
@@ -114,19 +114,33 @@ let isPublicQueryable = (q: JSON.t): bool =>
114
114
  | _ => true
115
115
  }
116
116
 
117
- // Drop Internal ReadModels / StateViewSlices from a persisted structure. A
118
- // missing/non-array field defaults to `[]` (mirrors the JS `?? []`).
117
+ // Drop Internal ReadModels / StateViewSlices from a persisted structure, and
118
+ // carry what was dropped on `internalQueryables` the complement, so a client
119
+ // can resolve a reference to an Internal view without any enumeration site
120
+ // being able to see it. A missing/non-array field defaults to `[]` (mirrors the
121
+ // JS `?? []`), which also covers a structure persisted before this field
122
+ // existed: the complement is recomputed here, never read off the structure.
123
+ //
124
+ // The `~filter=false` path (Platform_PluginStructures) deliberately does not go
125
+ // through here — it serves Internal components inline and must not grow a
126
+ // duplicate list.
119
127
  let filterStructure = (structure: dict<JSON.t>): dict<JSON.t> => {
120
128
  let out = Dict.fromArray(structure->Dict.toArray)
121
- let filterArr = key =>
122
- out
123
- ->Dict.get(key)
124
- ->Option.flatMap(JSON.Decode.array)
125
- ->Option.getOr([])
126
- ->Array.filter(isPublicQueryable)
127
- ->JSON.Encode.array
128
- out->Dict.set("readModels", filterArr("readModels"))
129
- out->Dict.set("stateViewSlices", filterArr("stateViewSlices"))
129
+ let arrAt = key =>
130
+ out->Dict.get(key)->Option.flatMap(JSON.Decode.array)->Option.getOr([])
131
+ let readModels = arrAt("readModels")
132
+ let stateViewSlices = arrAt("stateViewSlices")
133
+ out->Dict.set("readModels", readModels->Array.filter(isPublicQueryable)->JSON.Encode.array)
134
+ out->Dict.set(
135
+ "stateViewSlices",
136
+ stateViewSlices->Array.filter(isPublicQueryable)->JSON.Encode.array,
137
+ )
138
+ out->Dict.set(
139
+ "internalQueryables",
140
+ Array.concat(readModels, stateViewSlices)
141
+ ->Array.filter(q => !isPublicQueryable(q))
142
+ ->JSON.Encode.array,
143
+ )
130
144
  out
131
145
  }
132
146
 
@@ -166,9 +166,12 @@ function isPublicQueryable(q) {
166
166
 
167
167
  function filterStructure(structure) {
168
168
  let out = Object.fromEntries(Object.entries(structure));
169
- let filterArr = key => Stdlib_Option.getOr(Stdlib_Option.flatMap(out[key], Stdlib_JSON.Decode.array), []).filter(isPublicQueryable);
170
- out["readModels"] = filterArr("readModels");
171
- out["stateViewSlices"] = filterArr("stateViewSlices");
169
+ let arrAt = key => Stdlib_Option.getOr(Stdlib_Option.flatMap(out[key], Stdlib_JSON.Decode.array), []);
170
+ let readModels = arrAt("readModels");
171
+ let stateViewSlices = arrAt("stateViewSlices");
172
+ out["readModels"] = readModels.filter(isPublicQueryable);
173
+ out["stateViewSlices"] = stateViewSlices.filter(isPublicQueryable);
174
+ out["internalQueryables"] = readModels.concat(stateViewSlices).filter(q => !isPublicQueryable(q));
172
175
  return out;
173
176
  }
174
177
 
@@ -2,18 +2,24 @@
2
2
  // Query resolver — one pipeline step whose only job is to consult the hook.
3
3
  //
4
4
  // The hook is a module-level `ref`, and in a deployed runtime the only thing that
5
- // ever fills it is a `RuntimeExtension`'s `onColdStart`. Every other runtime
6
- // reaches those through a compiled entry shell that awaits `runtimeExtensionsReady`
7
- // before it serves anything; this handler is not built by a shell, so it awaits
8
- // the same promise itself. Without it the extensions the archive already carries
9
- // are never imported, the hook stays `None`, and interception degrades to a
10
- // passthrough that costs an invocation on every read and observes nothing — the
11
- // one failure mode this path cannot afford, because it is silent and its whole
12
- // price has already been paid.
5
+ // ever fills it is a `RuntimeExtension`'s `onColdStart`. So the handler awaits
6
+ // `runtimeExtensionsReady` before reading it. Without that the extensions the
7
+ // archive already carries are never imported, the hook stays `None`, and
8
+ // interception degrades to a passthrough that costs an invocation on every read
9
+ // and observes nothing the one failure mode this path cannot afford, because it
10
+ // is silent and its whole price has already been paid.
11
+ //
12
+ // The await is kept here even though `QueryInterceptorEntryPoint.mjs` now awaits
13
+ // the same promise at top level, because the two are answering different
14
+ // questions. The shell's await decides WHEN the seam fires — init rather than
15
+ // invoke, which is what keeps the load off the read path. This one is the
16
+ // module's own contract: it does not consult a hook it has not waited for,
17
+ // however it was entered. On the path they share, the second await is a settled
18
+ // promise and costs a microtask.
13
19
  //
14
20
  // Awaited from the small module rather than from `HandlerFactoryHelpers`, which
15
21
  // re-exports the same binding behind the Effect runtime and the DynamoDB clients:
16
- // this runtime is sized for a decision, not for work.
22
+ // there is no reason to pull that graph in to reach one promise.
17
23
 
18
24
  type payload = {
19
25
  readModelName: string,
@@ -64,23 +64,56 @@ let buildInterceptor = (~api: Types.AppSync.api, ~opts) => {
64
64
  ),
65
65
  ])
66
66
  let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
67
- ~entryPointModule="@reventlessdev/reventless-aws/src/adapter/QueryDb/QueryInterceptor_Lambda.res.mjs",
67
+ ~entryPointModule="@reventlessdev/reventless-aws/src/adapter/Runtime/QueryInterceptorEntryPoint.mjs",
68
68
  ~packageDirs,
69
69
  )
70
70
 
71
- // Sits in front of every read, so it is sized for a decision rather than for
72
- // work: small memory, short timeout. A hook that needs longer than this is
73
- // doing something a read path cannot afford anyway.
71
+ // Framework default sizing, deliberately, and it took an outage to get here.
72
+ //
73
+ // What this runtime DECIDES is trivial — consult a hook, return a verdict — and
74
+ // it was once sized for exactly that: 256MB and a 10-second timeout, on the
75
+ // reasoning that a read path cannot afford anything more. But a runtime is not
76
+ // sized for what it decides, it is sized for what it LOADS, and this one loads
77
+ // whatever module graph the registered extensions drag behind them. An
78
+ // accounting extension reaching a DynamoDB client is enough to put the whole
79
+ // cloud SDK in that graph. Lambda CPU scales with memory, so at 256MB the load
80
+ // ran at a fraction of a core and did not finish inside ten seconds — and
81
+ // because an unfinished import neither completes nor throws, the failure was a
82
+ // bare timeout on EVERY read, with an empty log group to explain it.
83
+ //
84
+ // The defaults are also close to cost-neutral for the thing being paid for
85
+ // here. Lambda bills memory×duration, so a decision that fits in single-digit
86
+ // milliseconds costs about the same at either size; what changes is whether the
87
+ // cold start fits. The entry shell spends it in init (see its own docstring),
88
+ // where the timeout is headroom for the retry Lambda performs when init
89
+ // overruns its own budget, rather than a ceiling on the read.
74
90
  let runtime = RuntimeEnvironment_Lambda.makeFromCodeAsset(
75
91
  ~name,
76
92
  ~unitKind=ReventlessCore.Monitoring.Other("QueryInterceptor"),
77
93
  ~componentKind=ReventlessCore.ComponentType.Plugin,
78
94
  ~code,
79
95
  ~sourceCodeHash,
80
- ~memorySize=256,
81
- ~timeout=10,
82
96
  ~opts=componentOpts,
83
97
  )
98
+
99
+ // The execution role's logging grant, which `makeFromCodeAsset` does NOT give
100
+ // it: every runtime builder in the framework attaches this at its own call
101
+ // site, and this one — hand-rolled rather than grown from a builder — was the
102
+ // one that forgot. The result was a component sitting in front of every read on
103
+ // the platform whose log group never had a single stream, so the timeout above
104
+ // could only be found by invoking the function by hand and reading the tail of
105
+ // a response. Silence on the read path is not a small defect; it is the thing
106
+ // that decides how long the next outage lasts.
107
+ let _logging = IAM.RolePolicy.make(
108
+ ~name=name ++ "Logging",
109
+ ~args={
110
+ IAM.RolePolicy.policy: PulumiAws.Lambda.defaultLoggingPolicyDocument
111
+ ->PolicyDocument.toJsonString
112
+ ->Pulumi.Input.make,
113
+ role: runtime.parts.lambdaRole.id->Pulumi.Output.asInput,
114
+ },
115
+ ~opts,
116
+ )
84
117
  let lambdaArn = runtime.parts.lambda->Pulumi.Output.flatMap(lambda => lambda.arn)
85
118
 
86
119
  let dataSourceRole = IAM.Role.makeWithDefaultPolicy(
@@ -5,6 +5,7 @@ import * as IAM$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/IAM.r
5
5
  import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
6
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
7
  import * as Pulumi from "@pulumi/pulumi";
8
+ import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
8
9
  import * as AWS$ReventlessAws from "../AWS.res.mjs";
9
10
  import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
10
11
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
@@ -38,11 +39,15 @@ function buildInterceptor(api, opts) {
38
39
  "@reventlessdev/reventless-aws",
39
40
  Util_Bundle$ReventlessAws.resolvePackageRoot(undefined, "@reventlessdev/reventless-aws")
40
41
  ]]);
41
- let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/QueryDb/QueryInterceptor_Lambda.res.mjs", packageDirs, undefined, undefined);
42
+ let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Runtime/QueryInterceptorEntryPoint.mjs", packageDirs, undefined, undefined);
42
43
  let runtime = RuntimeEnvironment_Lambda$ReventlessAws.makeFromCodeAsset(name, {
43
44
  TAG: "Other",
44
45
  _0: "QueryInterceptor"
45
- }, "Plugin", match.code, match.sourceCodeHash, undefined, 256, 10, undefined, undefined, undefined, undefined, undefined, componentOpts);
46
+ }, "Plugin", match.code, match.sourceCodeHash, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, componentOpts);
47
+ new (Aws.iam.RolePolicy)(name + "Logging", {
48
+ policy: PolicyDocument$PulumiAws.toJsonString(Lambda$PulumiAws.defaultLoggingPolicyDocument),
49
+ role: runtime.parts.lambdaRole.id
50
+ }, opts);
46
51
  let lambdaArn = Output$Pulumi.flatMap(runtime.parts.lambda, lambda => lambda.arn);
47
52
  let dataSourceRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name + "DataSource", Pulumi.output(AWS$ReventlessAws.AppSync.principal), AWS_Tags$ReventlessAws.make(name + "DataSource", "Plugin", "Identity", "Plugin", undefined, undefined, undefined, undefined), opts);
48
53
  Pulumi.all([
@@ -0,0 +1,29 @@
1
+ // Query-interceptor Lambda entry point.
2
+ //
3
+ // One job, and it is a scheduling one: pull the runtime-extension cold start
4
+ // into the INIT phase.
5
+ //
6
+ // `runtimeExtensionsReady` is an async IIFE started at module load and awaited
7
+ // by nobody, so evaluating a module that merely imports it does not wait for it.
8
+ // Awaiting it from the handler instead — which is what this runtime did before
9
+ // there was a shell — leaves the whole extension load in the INVOKE phase, where
10
+ // it runs without the init CPU boost and against the function timeout. An
11
+ // extension whose module graph reaches a cloud SDK does not finish inside a
12
+ // budget sized for a decision, and the way that fails is the worst shape
13
+ // available: the load neither completes nor throws, so every read times out
14
+ // having logged nothing at all.
15
+ //
16
+ // A top-level await is what moves it. It propagates to the generated `index.mjs`
17
+ // re-export, so init is not finished until the seam has fired — which is also
18
+ // the only way the handler's own await can be a formality rather than the place
19
+ // the work happens.
20
+ //
21
+ // It cannot reject: the seam catches and reports each extension's load failure
22
+ // individually, and resolves having skipped it. So a broken extension still
23
+ // costs interception nothing more than the counting it was going to do.
24
+
25
+ import { runtimeExtensionsReady } from "./RuntimeExtensionsReady.mjs";
26
+
27
+ await runtimeExtensionsReady;
28
+
29
+ export { handler } from "../QueryDb/QueryInterceptor_Lambda.res.mjs";
@@ -112,3 +112,67 @@ describe("toEntryWith heals a structure persisted before a required list existed
112
112
  expect(names)->toEqual(["Products"])
113
113
  })
114
114
  })
115
+
116
+ // The persisted structure is pre-filter, so this handler is a twin of the
117
+ // in-memory `encodePluginStructureEntry` rather than a consequence of it. A shell
118
+ // reading `internalQueryables` against a platform that emits it on only one
119
+ // transport gets working pickers locally and silent text inputs when deployed —
120
+ // which is why the split is asserted here as well as in core.
121
+ describe("toEntryWith carries Internal queryables on the filtered field", () => {
122
+ let mixed = Dict.fromArray([
123
+ (
124
+ "readModels",
125
+ JSON.Encode.array([
126
+ JSON.parseOrThrow(`{"name": "AvailableProducts", "visibility": "Internal"}`),
127
+ JSON.parseOrThrow(`{"name": "Products"}`),
128
+ ]),
129
+ ),
130
+ (
131
+ "stateViewSlices",
132
+ JSON.Encode.array([
133
+ JSON.parseOrThrow(`{"name": "PendingShipments", "visibility": "Internal"}`),
134
+ ]),
135
+ ),
136
+ ])->JSON.Encode.object
137
+
138
+ let namesAt = (~filter, collection) =>
139
+ entry(~filter, mixed)
140
+ ->Option.map(members(_, collection))
141
+ ->Option.getOr([])
142
+ ->Array.filterMap(m => m->Dict.get("name")->Option.flatMap(JSON.Decode.string))
143
+
144
+ testSync("emits the complement of the filter, fed by both source arrays", () =>
145
+ expect(namesAt(~filter=true, "internalQueryables"))->toEqual([
146
+ "AvailableProducts",
147
+ "PendingShipments",
148
+ ])
149
+ )
150
+
151
+ testSync("keeps the surface lists public-only", () => {
152
+ expect(namesAt(~filter=true, "readModels"))->toEqual(["Products"])
153
+ expect(namesAt(~filter=true, "stateViewSlices"))->toEqual([])
154
+ })
155
+
156
+ // Platform_PluginStructures serves Internal components inline; a duplicate list
157
+ // there would be a second answer to a question that already has one.
158
+ testSync("adds nothing to the complete field", () =>
159
+ expect(
160
+ entry(~filter=false, mixed)->Option.flatMap(o => o->Dict.get("internalQueryables")),
161
+ )->toEqual(None)
162
+ )
163
+
164
+ // A structure persisted before the field existed has no key for it, and must not
165
+ // grow one from its own contents — the complement is always recomputed.
166
+ testSync("computes the complement rather than reading it off the structure", () => {
167
+ let stale = Dict.fromArray([
168
+ ("internalQueryables", JSON.parseOrThrow(`[{"name": "Stale"}]`)),
169
+ ("readModels", JSON.parseOrThrow(`[{"name": "Products"}]`)),
170
+ ])->JSON.Encode.object
171
+ let names =
172
+ entry(~filter=true, stale)
173
+ ->Option.map(members(_, "internalQueryables"))
174
+ ->Option.getOr([])
175
+ ->Array.filterMap(m => m->Dict.get("name")->Option.flatMap(JSON.Decode.string))
176
+ expect(names)->toEqual([])
177
+ })
178
+ })
@@ -64,6 +64,50 @@ globalThis.describe("toEntryWith heals a structure persisted before a required l
64
64
  });
65
65
  });
66
66
 
67
+ globalThis.describe("toEntryWith carries Internal queryables on the filtered field", () => {
68
+ let mixed = Object.fromEntries([
69
+ [
70
+ "readModels",
71
+ [
72
+ JSON.parse(`{"name": "AvailableProducts", "visibility": "Internal"}`),
73
+ JSON.parse(`{"name": "Products"}`)
74
+ ]
75
+ ],
76
+ [
77
+ "stateViewSlices",
78
+ [JSON.parse(`{"name": "PendingShipments", "visibility": "Internal"}`)]
79
+ ]
80
+ ]);
81
+ let namesAt = (filter, collection) => Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.map(entry(filter, mixed), __x => members(__x, collection)), []), m => Stdlib_Option.flatMap(m["name"], Stdlib_JSON.Decode.string));
82
+ globalThis.test("emits the complement of the filter, fed by both source arrays", () => {
83
+ globalThis.expect(namesAt(true, "internalQueryables")).toEqual([
84
+ "AvailableProducts",
85
+ "PendingShipments"
86
+ ]);
87
+ });
88
+ globalThis.test("keeps the surface lists public-only", () => {
89
+ globalThis.expect(namesAt(true, "readModels")).toEqual(["Products"]);
90
+ globalThis.expect(namesAt(true, "stateViewSlices")).toEqual([]);
91
+ });
92
+ globalThis.test("adds nothing to the complete field", () => {
93
+ globalThis.expect(Stdlib_Option.flatMap(entry(false, mixed), o => o["internalQueryables"])).toEqual(undefined);
94
+ });
95
+ globalThis.test("computes the complement rather than reading it off the structure", () => {
96
+ let stale = Object.fromEntries([
97
+ [
98
+ "internalQueryables",
99
+ JSON.parse(`[{"name": "Stale"}]`)
100
+ ],
101
+ [
102
+ "readModels",
103
+ JSON.parse(`[{"name": "Products"}]`)
104
+ ]
105
+ ]);
106
+ let names = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.map(entry(true, stale), __x => members(__x, "internalQueryables")), []), m => Stdlib_Option.flatMap(m["name"], Stdlib_JSON.Decode.string));
107
+ globalThis.expect(names).toEqual([]);
108
+ });
109
+ });
110
+
67
111
  export {
68
112
  entry,
69
113
  members,