@reventlessdev/reventless-local 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,13 @@
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-12)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **command:** drop null arguments, and let a caller read why a payload was refused ([bfee0dc](https://github.com/ReventlessDev/reventless-core/commit/bfee0dc37755596ea8bcce1fe2cccdace7759f10))
11
+
12
+
6
13
  # 3.0.0-alpha.216 (2026-08-12)
7
14
 
8
15
  **Note:** Version bump only for package @reventlessdev/reventless-local
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.216",
3
+ "version": "3.0.0-alpha.217",
4
4
  "description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -35,18 +35,18 @@
35
35
  "sury": "11.0.0-alpha.4",
36
36
  "ws": "^8.18.0",
37
37
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
38
+ "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
38
39
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
39
40
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
40
- "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
41
- "@reventlessdev/rescript-node": "2.0.0-alpha.4",
42
41
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
43
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.76",
44
- "@reventlessdev/reventless-core": "3.0.0-alpha.228",
45
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.92",
46
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.175",
42
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.77",
47
43
  "@reventlessdev/reventless-infra": "3.0.0-alpha.137",
44
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.176",
45
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.93",
48
46
  "@reventlessdev/reventless-seed": "1.0.0-alpha.11",
49
- "@reventlessdev/reventless-spec": "3.0.0-alpha.111"
47
+ "@reventlessdev/rescript-node": "2.0.0-alpha.4",
48
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.111",
49
+ "@reventlessdev/reventless-core": "3.0.0-alpha.229"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.0",
@@ -54,12 +54,10 @@ let extractIdentity = (ctx: JSON.t): Reventless.Identity.t =>
54
54
  | _ => Reventless.Identity.anonymous
55
55
  }
56
56
 
57
- // Throw a `GraphQLError` from the `graphql` package yoga's `maskedErrors`
58
- // option preserves these (only opaque thrown values get masked as
59
- // "Unexpected error / INTERNAL_SERVER_ERROR"). Mirrors the directive-level
60
- // `@aws_auth` rejection that AppSync surfaces in production.
61
- @new @module("graphql")
62
- external makeGraphqlError: (string, {"extensions": {"code": string}}) => exn = "GraphQLError"
57
+ // An unauthorized caller reads the reason: `GraphQL_CallerError` explains why a
58
+ // resolver has to construct the error rather than throw a bare one. Mirrors the
59
+ // directive-level `@aws_auth` rejection that AppSync surfaces in production.
60
+ let makeGraphqlError = GraphQL_CallerError.make
63
61
 
64
62
  let unauthorizedError = (~group: string): exn =>
65
63
  makeGraphqlError(
@@ -44,6 +44,10 @@ function extractIdentity(ctx) {
44
44
  }
45
45
  }
46
46
 
47
+ function makeGraphqlError(prim0, prim1) {
48
+ return new Graphql.GraphQLError(prim0, prim1);
49
+ }
50
+
47
51
  function unauthorizedError(group) {
48
52
  return new Graphql.GraphQLError(`Unauthorized: requires group "` + group + `"`, {
49
53
  extensions: {
@@ -70,6 +74,7 @@ export {
70
74
  identityFromAuthResult,
71
75
  buildAuthContext,
72
76
  extractIdentity,
77
+ makeGraphqlError,
73
78
  unauthorizedError,
74
79
  requireGroup,
75
80
  }
@@ -159,6 +159,27 @@ let deriveSdlField = (~fieldName, variantSchema: S.t<unknown>) =>
159
159
 
160
160
  let handlerRefs: dict<ref<option<CommandGenerator.commandGenerator>>> = Dict.make()
161
161
 
162
+ // Run a command through its generator, letting a failure that describes the
163
+ // caller's own request reach them.
164
+ //
165
+ // Without this every such failure arrives as "Unexpected error /
166
+ // INTERNAL_SERVER_ERROR": yoga masks anything that is not a `GraphQLError`, so
167
+ // a payload that does not decode and a database outage read identically from
168
+ // outside. Only errors core marked as the caller's are unwrapped — everything
169
+ // else propagates untouched and stays masked, which is what masking is for.
170
+ let runCommand = async (
171
+ generateCommand: CommandGenerator.commandGenerator,
172
+ payload: CommandGenerator.payload,
173
+ ) =>
174
+ try await generateCommand(payload)->Effect.runPromise catch {
175
+ | e if ReventlessCore.Plugin_ResolverError.isCallerFault(e) =>
176
+ throw(
177
+ GraphQL_CallerError.badUserInput(
178
+ e->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("invalid command"),
179
+ ),
180
+ )
181
+ }
182
+
162
183
  // -- register (Phase 1 — synchronous, Aggregates) ----------------------------
163
184
  // Called by Plugin_Builder via aggregateMutationResolverHook before any
164
185
  // Output.apply chains fire. Registers SDL + resolver stubs in GraphQL_Server.
@@ -210,7 +231,7 @@ let register = (
210
231
  meta: {ip: [], user: identity.userId, info: `Mutation.${field}`},
211
232
  identity,
212
233
  }
213
- let outcome = await generateCommand(payload)->Effect.runPromise
234
+ let outcome = await runCommand(generateCommand, payload)
214
235
  outcome->commandOutcomeToJson
215
236
  }
216
237
  }
@@ -273,7 +294,7 @@ let registerDcb = (
273
294
  meta: {ip: [], user: identity.userId, info: `Mutation.${fieldName}`},
274
295
  identity,
275
296
  }
276
- let outcome = await generateCommand(payload)->Effect.runPromise
297
+ let outcome = await runCommand(generateCommand, payload)
277
298
  outcome->commandOutcomeToJson
278
299
  }
279
300
  }
@@ -1,14 +1,18 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
3
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
5
  import * as Effect from "effect/Effect";
5
6
  import * as Pulumi from "@pulumi/pulumi";
6
7
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
7
8
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
8
9
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
10
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
9
11
  import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
10
12
  import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
11
13
  import * as CommandTopic$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic.res.mjs";
14
+ import * as GraphQL_CallerError$ReventlessLocal from "../GraphQL_CallerError.res.mjs";
15
+ import * as Plugin_ResolverError$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_ResolverError.res.mjs";
12
16
  import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
13
17
 
14
18
  function extractIdentity(ctx) {
@@ -140,6 +144,18 @@ function deriveSdlField(fieldName, variantSchema) {
140
144
 
141
145
  let handlerRefs = {};
142
146
 
147
+ async function runCommand(generateCommand, payload) {
148
+ try {
149
+ return await Effect.runPromise(generateCommand(payload));
150
+ } catch (raw_e) {
151
+ let e = Primitive_exceptions.internalToException(raw_e);
152
+ if (Plugin_ResolverError$ReventlessCore.isCallerFault(e)) {
153
+ throw GraphQL_CallerError$ReventlessLocal.badUserInput(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(e), Stdlib_JsExn.message), "invalid command"));
154
+ }
155
+ throw e;
156
+ }
157
+ }
158
+
143
159
  function register(fields, commandSchema, commandAuthorization, server) {
144
160
  server.registerTypes([commandResultSdl]);
145
161
  let sdlFields = fields.map(field => {
@@ -187,7 +203,7 @@ function register(fields, commandSchema, commandAuthorization, server) {
187
203
  meta: payload_meta,
188
204
  identity: identity
189
205
  };
190
- return CommandTopic$ReventlessCore.commandOutcomeToJson(await Effect.runPromise(generateCommand(payload)));
206
+ return CommandTopic$ReventlessCore.commandOutcomeToJson(await runCommand(generateCommand, payload));
191
207
  };
192
208
  resolvers[field] = resolver;
193
209
  });
@@ -231,7 +247,7 @@ function registerDcb(fieldName, commandSchema, commandAuthorization, server) {
231
247
  meta: payload_meta,
232
248
  identity: identity
233
249
  };
234
- return CommandTopic$ReventlessCore.commandOutcomeToJson(await Effect.runPromise(generateCommand(payload)));
250
+ return CommandTopic$ReventlessCore.commandOutcomeToJson(await runCommand(generateCommand, payload));
235
251
  };
236
252
  let resolvers = {};
237
253
  resolvers[fieldName] = resolver;
@@ -290,6 +306,7 @@ export {
290
306
  variantIndexForField,
291
307
  deriveSdlField,
292
308
  handlerRefs,
309
+ runCommand,
293
310
  register,
294
311
  registerDcb,
295
312
  bindHandler,
@@ -0,0 +1,19 @@
1
+ // A failure the caller is allowed to read.
2
+ //
3
+ // graphql-yoga's `maskedErrors` replaces any thrown value that is not a
4
+ // `GraphQLError` with "Unexpected error / INTERNAL_SERVER_ERROR". That is the
5
+ // right default — an internal failure's message is not the caller's business —
6
+ // but it also hides the failures that describe the caller's own request, which
7
+ // are the ones they can do something about. Constructing the error through this
8
+ // module is how a resolver says "this one is theirs to read".
9
+ //
10
+ // Mirrors what AppSync surfaces in production, where a resolver's thrown message
11
+ // is returned as the field error.
12
+
13
+ @new @module("graphql")
14
+ external make: (string, {"extensions": {"code": string}}) => exn = "GraphQLError"
15
+
16
+ /** A request the server understood and refused — bad input, in GraphQL's own
17
+ `BAD_USER_INPUT` sense. */
18
+ let badUserInput = (message: string): exn =>
19
+ make(message, {"extensions": {"code": "BAD_USER_INPUT"}})
@@ -0,0 +1,16 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Graphql from "graphql";
4
+
5
+ function badUserInput(message) {
6
+ return new Graphql.GraphQLError(message, {
7
+ extensions: {
8
+ code: "BAD_USER_INPUT"
9
+ }
10
+ });
11
+ }
12
+
13
+ export {
14
+ badUserInput,
15
+ }
16
+ /* graphql Not a pure module */
@@ -198,3 +198,89 @@ describe("CommandGeneratorResolvers_GraphQL — per-constructor authorization",
198
198
  },
199
199
  )
200
200
  })
201
+
202
+ // ── Caller-fault errors reach the caller ─────────────────────────────────────
203
+ //
204
+ // graphql-yoga masks every thrown value that is not a `GraphQLError` as
205
+ // "Unexpected error / INTERNAL_SERVER_ERROR". That is right for an internal
206
+ // failure and wrong for the failures that describe the caller's own request —
207
+ // a payload that does not decode, a caller who cannot be identified. Core marks
208
+ // those; this is the half that acts on the mark.
209
+
210
+ @get external errorExtensions: JsExn.t => option<{"code": string}> = "extensions"
211
+
212
+ let failingFixture = (~namespace: string, ~failWith: unit => unit) => {
213
+ let server = DomainGraphQL_Server.asInterface
214
+ let field = `${namespace}_Add`
215
+ CommandGeneratorResolvers_GraphQL.register(
216
+ ~fields=[field],
217
+ ~commandSchema=commandSchema->S.castToUnknown,
218
+ ~commandAuthorization,
219
+ ~server,
220
+ )
221
+ let generateCommand: ReventlessCore.CommandGenerator.commandGenerator = _payload =>
222
+ Effect.promise(() => {
223
+ failWith()
224
+ Promise.resolve(ReventlessCore.CommandTopic.Accepted({msgId: "unreachable", eventCount: 0}))
225
+ })
226
+ CommandGeneratorResolvers_GraphQL.bindHandler(~field, ~generateCommand)
227
+ switch server.getMutationResolver(field) {
228
+ | Some(r) => r
229
+ | None => JsError.throwWithMessage("resolver not registered: " ++ field)
230
+ }
231
+ }
232
+
233
+ let invoke = async resolver =>
234
+ switch await resolver(
235
+ JSON.Encode.null,
236
+ JSON.Encode.object(Dict.fromArray([("name", JSON.Encode.string("Books"))])),
237
+ ctxFor(adminIdentity),
238
+ ) {
239
+ | _ => None
240
+ | exception e => e->JsExn.fromException
241
+ }
242
+
243
+ describe("CommandGeneratorResolvers_GraphQL — a failure the caller may read", () => {
244
+ beforeEach(() => {
245
+ DomainGraphQL_Server.asInterface.reset()
246
+ })
247
+
248
+ testPromise("a caller-fault failure is rethrown as an unmasked GraphQL error", async () => {
249
+ let resolver = failingFixture(~namespace="Fault1", ~failWith=() =>
250
+ ReventlessCore.Plugin_ResolverError.throwCallerFault(
251
+ `Error: Couldn't decode: Expected string | undefined, received null`,
252
+ )
253
+ )
254
+ let caught = await invoke(resolver)
255
+ expect((
256
+ caught->Option.flatMap(JsExn.name),
257
+ caught->Option.flatMap(errorExtensions)->Option.map(e => e["code"]),
258
+ ))->toEqual((Some("GraphQLError"), Some("BAD_USER_INPUT")))
259
+ })
260
+
261
+ // The reason is the point: masked, the caller learns only that something
262
+ // went wrong on a request that is theirs to fix.
263
+ testPromise("and carries the reason it was given", async () => {
264
+ let resolver = failingFixture(~namespace="Fault2", ~failWith=() =>
265
+ ReventlessCore.Plugin_ResolverError.throwCallerFault(
266
+ "Expected string | undefined, received null",
267
+ )
268
+ )
269
+ let caught = await invoke(resolver)
270
+ expect(
271
+ caught
272
+ ->Option.flatMap(JsExn.message)
273
+ ->Option.mapOr(false, m => m->String.includes("received null")),
274
+ )->toBe(true)
275
+ })
276
+
277
+ // The control, and the reason the mark exists: an internal failure is not the
278
+ // caller's business and must keep being masked.
279
+ testPromise("an unmarked failure is left to be masked", async () => {
280
+ let resolver = failingFixture(~namespace="Fault3", ~failWith=() =>
281
+ JsError.throwWithMessage("connection to the event store was reset")
282
+ )
283
+ let caught = await invoke(resolver)
284
+ expect(caught->Option.flatMap(JsExn.name))->not_->toEqual(Some("GraphQLError"))
285
+ })
286
+ })
@@ -2,11 +2,14 @@
2
2
 
3
3
  import * as S from "sury/src/S.res.mjs";
4
4
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
5
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
7
  import * as Effect from "effect/Effect";
7
8
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
8
9
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
10
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
9
11
  import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
12
+ import * as Plugin_ResolverError$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_ResolverError.res.mjs";
10
13
  import * as DomainGraphQL_Server$ReventlessLocal from "../../src/adapter/DomainGraphQL_Server.res.mjs";
11
14
  import * as CommandGeneratorResolvers_GraphQL$ReventlessLocal from "../../src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs";
12
15
 
@@ -176,6 +179,63 @@ globalThis.describe("CommandGeneratorResolvers_GraphQL — per-constructor autho
176
179
  });
177
180
  });
178
181
 
182
+ function failingFixture(namespace, failWith) {
183
+ let field = namespace + `_Add`;
184
+ CommandGeneratorResolvers_GraphQL$ReventlessLocal.register([field], commandSchema, commandAuthorization, DomainGraphQL_Server$ReventlessLocal.asInterface);
185
+ let generateCommand = _payload => Effect.promise(() => {
186
+ failWith();
187
+ return Promise.resolve({
188
+ TAG: "Accepted",
189
+ msgId: "unreachable",
190
+ eventCount: 0
191
+ });
192
+ });
193
+ CommandGeneratorResolvers_GraphQL$ReventlessLocal.bindHandler(field, generateCommand);
194
+ let r = DomainGraphQL_Server$ReventlessLocal.asInterface.getMutationResolver(field);
195
+ if (r !== undefined) {
196
+ return r;
197
+ } else {
198
+ return Stdlib_JsError.throwWithMessage("resolver not registered: " + field);
199
+ }
200
+ }
201
+
202
+ async function invoke(resolver) {
203
+ try {
204
+ await resolver(null, Object.fromEntries([[
205
+ "name",
206
+ "Books"
207
+ ]]), ctxFor(adminIdentity));
208
+ return;
209
+ } catch (raw_e) {
210
+ return Stdlib_JsExn.fromException(Primitive_exceptions.internalToException(raw_e));
211
+ }
212
+ }
213
+
214
+ globalThis.describe("CommandGeneratorResolvers_GraphQL — a failure the caller may read", () => {
215
+ globalThis.beforeEach(() => DomainGraphQL_Server$ReventlessLocal.asInterface.reset());
216
+ globalThis.test("a caller-fault failure is rethrown as an unmasked GraphQL error", async () => {
217
+ let resolver = failingFixture("Fault1", () => Plugin_ResolverError$ReventlessCore.throwCallerFault(`Error: Couldn't decode: Expected string | undefined, received null`));
218
+ let caught = await invoke(resolver);
219
+ globalThis.expect([
220
+ Stdlib_Option.flatMap(caught, Stdlib_JsExn.name),
221
+ Stdlib_Option.map(Stdlib_Option.flatMap(caught, prim => prim.extensions), e => e.code)
222
+ ]).toEqual([
223
+ "GraphQLError",
224
+ "BAD_USER_INPUT"
225
+ ]);
226
+ });
227
+ globalThis.test("and carries the reason it was given", async () => {
228
+ let resolver = failingFixture("Fault2", () => Plugin_ResolverError$ReventlessCore.throwCallerFault("Expected string | undefined, received null"));
229
+ let caught = await invoke(resolver);
230
+ globalThis.expect(Stdlib_Option.mapOr(Stdlib_Option.flatMap(caught, Stdlib_JsExn.message), false, m => m.includes("received null"))).toBe(true);
231
+ });
232
+ globalThis.test("an unmarked failure is left to be masked", async () => {
233
+ let resolver = failingFixture("Fault3", () => Stdlib_JsError.throwWithMessage("connection to the event store was reset"));
234
+ let caught = await invoke(resolver);
235
+ globalThis.expect(Stdlib_Option.flatMap(caught, Stdlib_JsExn.name)).not.toEqual("GraphQLError");
236
+ });
237
+ });
238
+
179
239
  export {
180
240
  commandSchema,
181
241
  commandAuthorization,
@@ -186,5 +246,7 @@ export {
186
246
  getTypename,
187
247
  getErrorCode,
188
248
  buildFixture,
249
+ failingFixture,
250
+ invoke,
189
251
  }
190
252
  /* Not a pure module */