@reventlessdev/reventless-core 3.0.0-alpha.227 → 3.0.0-alpha.228

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.228 (2026-08-12)
7
+
8
+ ### Features
9
+
10
+ * **api:** publish each mutation argument's GraphQL type on the command schema ([9ea1a5b](https://github.com/ReventlessDev/reventless-core/commit/9ea1a5bdf1957a726f6452e122c8fcda73ae824f))
11
+
12
+
6
13
  # 3.0.0-alpha.227 (2026-08-12)
7
14
 
8
15
  **Note:** Version bump only for package @reventlessdev/reventless-core
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.227",
3
+ "version": "3.0.0-alpha.228",
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-effect": "0.1.0-alpha.32",
32
31
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.4",
33
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
34
33
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
35
34
  "@reventlessdev/rescript-node": "2.0.0-alpha.4",
35
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
37
37
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.4",
38
+ "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
38
39
  "@reventlessdev/reventless-infra": "3.0.0-alpha.137",
39
- "@reventlessdev/reventless-spec": "3.0.0-alpha.111",
40
40
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
41
- "@reventlessdev/rescript-uuid": "2.0.0-alpha.0"
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.111"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
@@ -475,6 +475,37 @@ let deriveMutationFieldFromObject = (
475
475
  | None => None
476
476
  }
477
477
 
478
+ // The rendered GraphQL type of every argument one mutation field declares,
479
+ // keyed by argument name — `"Ordering_PlaceOrderShippingMethod!"`,
480
+ // `"DateRangeInput"`, `"[Ordering_PlaceOrderLineItems!]!"`.
481
+ //
482
+ // A client that assembles its own mutation document must declare a variable per
483
+ // argument, and it cannot derive these from JSON Schema: an enum's type name is
484
+ // composed from the mutation field and the property, an object's comes from a
485
+ // semantic or the field path, and the nullability comes from whether the sury
486
+ // schema wrapped it in an option. None of that survives the JSON-Schema
487
+ // projection, so the name is published rather than left to be re-derived from a
488
+ // convention that would then live in two repos shipping on different cycles.
489
+ //
490
+ // Deliberately the same `fromSchemaType` call `deriveMutationFieldFromObject`
491
+ // makes: one producer, so the published string and the SDL cannot disagree.
492
+ // The collected type *definitions* are discarded — a caller wants the reference
493
+ // each argument renders as, and the definitions are already in the schema this
494
+ // generator emits.
495
+ let mutationArgTypes = (~fieldName: string, variantSchema: S.t<unknown>): option<dict<string>> =>
496
+ SchemaType.fromSuryObject(~typeName=fieldName, variantSchema)->Option.map(fields => {
497
+ let out = Dict.make()
498
+ fields
499
+ ->Dict.toArray
500
+ ->Array.forEach(((argName, argType)) =>
501
+ out->Dict.set(
502
+ argName,
503
+ fromSchemaType(~required=true, ~asInput=true, argType, [], Set.make()),
504
+ )
505
+ )
506
+ out
507
+ })
508
+
478
509
  // ── Main generate function ─────────────────────────────────────────────────
479
510
 
480
511
  let generate = (
@@ -390,6 +390,16 @@ function deriveMutationFieldFromObject(fieldName, collectedTypes, seenTypes, var
390
390
  return ` ` + fieldName + argsPart + `: CommandResult!`;
391
391
  }
392
392
 
393
+ function mutationArgTypes(fieldName, variantSchema) {
394
+ return Stdlib_Option.map(SchemaType$ReventlessCore.fromSuryObject(fieldName, variantSchema), fields => {
395
+ let out = {};
396
+ Object.entries(fields).forEach(param => {
397
+ out[param[0]] = fromSchemaType(true, true, param[1], [], new Set());
398
+ });
399
+ return out;
400
+ });
401
+ }
402
+
393
403
  function generate(mutationEntries, queryEntries) {
394
404
  let types = [];
395
405
  let mutations = [];
@@ -571,6 +581,7 @@ export {
571
581
  deriveByIdsQueryField,
572
582
  deriveConnectionQueryField,
573
583
  deriveMutationFieldFromObject,
584
+ mutationArgTypes,
574
585
  generate,
575
586
  }
576
587
  /* Api_Naming-ReventlessCore Not a pure module */
@@ -318,6 +318,32 @@ let make = (
318
318
  | AllowGroups(_) | AllowAuthenticated | AllowAnonymous | DenyAll => None
319
319
  }
320
320
 
321
+ // Write each mutation argument's rendered GraphQL type onto the property it
322
+ // belongs to, so a consumer assembling its own mutation document declares the
323
+ // variable the server actually expects instead of guessing `String!`.
324
+ //
325
+ // Mutates the freshly derived schema in place — `deriveObjectSchema` has just
326
+ // built it and nothing else holds it yet. A property with no matching
327
+ // argument is left alone rather than annotated with a guess.
328
+ let annotateArgTypes = (schema: JSON.t, argTypes: dict<string>): JSON.t => {
329
+ schema
330
+ ->JSON.Decode.object
331
+ ->Option.flatMap(o => o->Dict.get("properties"))
332
+ ->Option.flatMap(JSON.Decode.object)
333
+ ->Option.forEach(props =>
334
+ props
335
+ ->Dict.toArray
336
+ ->Array.forEach(((key, prop)) =>
337
+ switch (argTypes->Dict.get(key), prop->JSON.Decode.object) {
338
+ | (Some(gqlType), Some(p)) =>
339
+ p->Dict.set("x-reventless-graphql-type", JSON.Encode.string(gqlType))
340
+ | _ => ()
341
+ }
342
+ )
343
+ )
344
+ schema
345
+ }
346
+
321
347
  let toCommandDef = (
322
348
  ~isAggregate,
323
349
  ~mutationFieldFor: string => string,
@@ -360,6 +386,20 @@ let make = (
360
386
  ? {"TAG": variantName}->Obj.magic
361
387
  : variantName->Obj.magic
362
388
  let requiredAccess = accessKeysFor(commandAuthorization(syntheticCommand))
389
+ // See the note on the record's `mutationField` for why a non-exposed
390
+ // variant gets the empty sentinel. It has no callable field, and the
391
+ // argument type names are composed *from* that field name, so there is
392
+ // nothing to publish for it either.
393
+ let mutationField = apiExposed ? mutationFieldFor(variantName) : ""
394
+ let jsonSchema = v->SuryToJsonSchema.deriveObjectSchema
395
+ let annotatedSchema = if apiExposed {
396
+ GraphQL_FragmentGenerator.mutationArgTypes(~fieldName=mutationField, v)->Option.mapOr(
397
+ jsonSchema,
398
+ annotateArgTypes(jsonSchema, _),
399
+ )
400
+ } else {
401
+ jsonSchema
402
+ }
363
403
  ({
364
404
  Reventless.Plugin.name: variantName,
365
405
  // The derived schema, not sury's raw one: `S.toJSONSchema` carries the
@@ -369,7 +409,10 @@ let make = (
369
409
  // matches a field against its setter — or picks the upload endpoint of
370
410
  // the store a command argument declares — then has nothing to match on.
371
411
  // `MCP_SchemaGenerator` already derives these same variant schemas.
372
- schema: v->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
412
+ //
413
+ // Carries `x-reventless-graphql-type` per property — see
414
+ // `annotateArgTypes`.
415
+ schema: annotatedSchema->JSON.stringify,
373
416
  level,
374
417
  aggregateIdField,
375
418
  // A non-exposed (`@noApi`) variant has no callable mutation field. For a
@@ -381,7 +424,7 @@ let make = (
381
424
  // stays listed with `apiExposed: false` for the event-graph badge, but
382
425
  // no consumer can mistake it for a callable field. Exposed variants are
383
426
  // byte-identical.
384
- mutationField: apiExposed ? mutationFieldFor(variantName) : "",
427
+ mutationField,
385
428
  references,
386
429
  allowedStates,
387
430
  targetState,
@@ -1,5 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
3
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
5
  import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
5
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
@@ -294,12 +295,27 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
294
295
  TAG: variantName
295
296
  }) : variantName;
296
297
  let requiredAccess = accessKeysFor(commandAuthorization(syntheticCommand));
298
+ let mutationField = apiExposed ? mutationFieldFor(variantName) : "";
299
+ let jsonSchema = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(v);
300
+ let annotatedSchema = apiExposed ? Stdlib_Option.mapOr(GraphQL_FragmentGenerator$ReventlessCore.mutationArgTypes(mutationField, v), jsonSchema, __x => {
301
+ Stdlib_Option.forEach(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(jsonSchema), o => o["properties"]), Stdlib_JSON.Decode.object), props => {
302
+ Object.entries(props).forEach(param => {
303
+ let match = __x[param[0]];
304
+ let match$1 = Stdlib_JSON.Decode.object(param[1]);
305
+ if (match !== undefined && match$1 !== undefined) {
306
+ match$1["x-reventless-graphql-type"] = match;
307
+ return;
308
+ }
309
+ });
310
+ });
311
+ return jsonSchema;
312
+ }) : jsonSchema;
297
313
  return {
298
314
  name: variantName,
299
- schema: JSON.stringify(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(v)),
315
+ schema: JSON.stringify(annotatedSchema),
300
316
  level: match[0],
301
317
  aggregateIdField: match[1],
302
- mutationField: apiExposed ? mutationFieldFor(variantName) : "",
318
+ mutationField: mutationField,
303
319
  references: references,
304
320
  allowedStates: allowedStates,
305
321
  targetState: targetState,
@@ -256,3 +256,87 @@ describe("semantic composites are named once, not once per field", () => {
256
256
  ))->toEqual((1, 1, true))
257
257
  })
258
258
  })
259
+
260
+ // `mutationArgTypes` is what a client that assembles its own mutation document
261
+ // reads instead of guessing the variable types. Its whole value is that it
262
+ // agrees with the SDL, so the last case here checks every published string
263
+ // against the argument the generator emitted for the same command.
264
+ module PlaceOrder = {
265
+ @schema
266
+ type shippingMethod = Standard | Express
267
+
268
+ // A single-payload command reaches the generator as a plain object schema —
269
+ // the `Object(_)` branch of `generate` — which is also the shape one variant
270
+ // of a union presents to `mutationArgTypes`.
271
+ let schema =
272
+ S.schema(s =>
273
+ {
274
+ "orderId": s.matches(S.string),
275
+ "shippingMethod": s.matches(shippingMethodSchema),
276
+ "total": s.matches(Reventless.Money.schema),
277
+ "tip": s.matches(S.option(Reventless.Money.schema)),
278
+ "itemCount": s.matches(S.int),
279
+ }
280
+ )->S.castToUnknown
281
+ }
282
+
283
+ describe("GraphQL_FragmentGenerator.mutationArgTypes", () => {
284
+ let argTypes =
285
+ GraphQL_FragmentGenerator.mutationArgTypes(
286
+ ~fieldName="Ordering_PlaceOrder",
287
+ PlaceOrder.schema,
288
+ )->Option.getOr(Dict.make())
289
+
290
+ let typeOf = (name: string) => argTypes->Dict.get(name)
291
+
292
+ // The defect this exists for: a client that fell back to `String!` here got
293
+ // `Variable "$shippingMethod" of type "String!" used in position expecting
294
+ // type "Ordering_PlaceOrderShippingMethod!"` — a 200 carrying no data. The
295
+ // name is composed from the mutation field, so nothing downstream of the
296
+ // JSON Schema can reconstruct it.
297
+ testSync("names an enum by the type the mutation field composes", () =>
298
+ expect(typeOf("shippingMethod"))->toEqual(Some("Ordering_PlaceOrderShippingMethod!"))
299
+ )
300
+
301
+ // A semantic composite is named after the semantic, and takes the `Input`
302
+ // suffix in argument position.
303
+ testSync("names a semantic composite by its input type", () =>
304
+ expect(typeOf("total"))->toEqual(Some("MoneyInput!"))
305
+ )
306
+
307
+ // Nullability is the other half of the answer: with the right name and an
308
+ // unconditional `!` appended, the declaration still would not match.
309
+ testSync("distinguishes an optional argument from a required one", () =>
310
+ expect((typeOf("tip"), typeOf("total")))->toEqual((Some("MoneyInput"), Some("MoneyInput!")))
311
+ )
312
+
313
+ // Both of these differ from what the JSON-Schema type alone suggests —
314
+ // `orderId` is a plain string there and `itemCount` an integer — which is the
315
+ // narrower reason a client cannot derive even the scalars itself.
316
+ testSync("reports the scalar the server chose, not the JSON-Schema one", () =>
317
+ expect((typeOf("orderId"), typeOf("itemCount")))->toEqual((Some("ID!"), Some("Float!")))
318
+ )
319
+
320
+ // The property that makes publishing worth more than re-deriving downstream:
321
+ // one producer, so what a client declares and what the server declares cannot
322
+ // drift apart.
323
+ testSync("agrees with every argument the SDL declares", () => {
324
+ let fragment = GraphQL_FragmentGenerator.generate(
325
+ ~mutationEntries=[
326
+ {
327
+ ReventlessInfra.Api.fieldNames: ["Ordering_PlaceOrder"],
328
+ commandSchema: PlaceOrder.schema,
329
+ injectIdArg: false,
330
+ },
331
+ ],
332
+ ~queryEntries=[],
333
+ )
334
+ let field = mutationFor(fragment, "Ordering_PlaceOrder")->Option.getOr("")
335
+ let mismatched =
336
+ argTypes
337
+ ->Dict.toArray
338
+ ->Array.filter(((arg, gqlType)) => !(field->String.includes(`${arg}: ${gqlType}`)))
339
+ ->Array.map(((arg, gqlType)) => `${arg}: ${gqlType}`)
340
+ expect((mismatched, field->String.length > 0))->toEqual(([], true))
341
+ })
342
+ })
@@ -260,6 +260,68 @@ globalThis.describe("semantic composites are named once, not once per field", ()
260
260
  });
261
261
  });
262
262
 
263
+ let shippingMethodSchema = S.union([
264
+ S.literal("Standard"),
265
+ S.literal("Express")
266
+ ]);
267
+
268
+ let schema = S.schema(s => ({
269
+ orderId: s.m(S.string),
270
+ shippingMethod: s.m(shippingMethodSchema),
271
+ total: s.m(Money$Reventless.schema),
272
+ tip: s.m(S.option(Money$Reventless.schema)),
273
+ itemCount: s.m(S.int)
274
+ }));
275
+
276
+ let PlaceOrder = {
277
+ shippingMethodSchema: shippingMethodSchema,
278
+ schema: schema
279
+ };
280
+
281
+ globalThis.describe("GraphQL_FragmentGenerator.mutationArgTypes", () => {
282
+ let argTypes = Stdlib_Option.getOr(GraphQL_FragmentGenerator$ReventlessCore.mutationArgTypes("Ordering_PlaceOrder", schema), {});
283
+ globalThis.test("names an enum by the type the mutation field composes", () => {
284
+ globalThis.expect(argTypes["shippingMethod"]).toEqual("Ordering_PlaceOrderShippingMethod!");
285
+ });
286
+ globalThis.test("names a semantic composite by its input type", () => {
287
+ globalThis.expect(argTypes["total"]).toEqual("MoneyInput!");
288
+ });
289
+ globalThis.test("distinguishes an optional argument from a required one", () => {
290
+ globalThis.expect([
291
+ argTypes["tip"],
292
+ argTypes["total"]
293
+ ]).toEqual([
294
+ "MoneyInput",
295
+ "MoneyInput!"
296
+ ]);
297
+ });
298
+ globalThis.test("reports the scalar the server chose, not the JSON-Schema one", () => {
299
+ globalThis.expect([
300
+ argTypes["orderId"],
301
+ argTypes["itemCount"]
302
+ ]).toEqual([
303
+ "ID!",
304
+ "Float!"
305
+ ]);
306
+ });
307
+ globalThis.test("agrees with every argument the SDL declares", () => {
308
+ let fragment = GraphQL_FragmentGenerator$ReventlessCore.generate([{
309
+ fieldNames: ["Ordering_PlaceOrder"],
310
+ commandSchema: schema,
311
+ injectIdArg: false
312
+ }], []);
313
+ let field = Stdlib_Option.getOr(mutationFor(fragment, "Ordering_PlaceOrder"), "");
314
+ let mismatched = Object.entries(argTypes).filter(param => !field.includes(param[0] + `: ` + param[1])).map(param => param[0] + `: ` + param[1]);
315
+ globalThis.expect([
316
+ mismatched,
317
+ field.length > 0
318
+ ]).toEqual([
319
+ [],
320
+ true
321
+ ]);
322
+ });
323
+ });
324
+
263
325
  export {
264
326
  OrderSliceCmd,
265
327
  mutationFor,
@@ -267,5 +329,6 @@ export {
267
329
  typeDefFor,
268
330
  AddProductCmd,
269
331
  ChangePriceCmd,
332
+ PlaceOrder,
270
333
  }
271
334
  /* commandSchema Not a pure module */
@@ -259,6 +259,26 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
259
259
  expect((shipField->String.length > 0, cancelField))->toEqual((true, ""))
260
260
  })
261
261
 
262
+ // A consumer that builds its own mutation document declares one variable
263
+ // per argument, and JSON Schema does not carry the GraphQL type those
264
+ // variables need — `orderId` reads as a plain string there while the server
265
+ // declares `ID!`. So the rendered type rides along on the property.
266
+ testSync("ShipOrder: the command schema publishes each argument's GraphQL type", () => {
267
+ let shipOrder = structure.stateChangeSlices->Array.getUnsafe(1)
268
+ let ship = shipOrder.commands->Array.find(c => c.name == "ShipOrder")->Option.getOrThrow
269
+ expect(ship.schema->String.includes(`"x-reventless-graphql-type":"ID!"`))->toBe(true)
270
+ })
271
+
272
+ // The names are composed from the mutation field, and a `@noApi` variant
273
+ // has none — publishing a type derived from the empty sentinel would name
274
+ // types no schema declares.
275
+ testSync("CancelShipment: a @noApi variant publishes no argument types", () => {
276
+ let shipOrder = structure.stateChangeSlices->Array.getUnsafe(1)
277
+ let cancel =
278
+ shipOrder.commands->Array.find(c => c.name == "CancelShipment")->Option.getOrThrow
279
+ expect(cancel.schema->String.includes("x-reventless-graphql-type"))->toBe(false)
280
+ })
281
+
262
282
  testSync("ShipOrder: payload-less event ShipmentVoided is surfaced in events", () => {
263
283
  let shipOrder = structure.stateChangeSlices->Array.getUnsafe(1)
264
284
  expect(shipOrder.events->Array.map(e => e.name))->toEqual(["OrderShipped", "ShipmentVoided"])
@@ -447,6 +447,16 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
447
447
  ""
448
448
  ]);
449
449
  });
450
+ globalThis.test("ShipOrder: the command schema publishes each argument's GraphQL type", () => {
451
+ let shipOrder = structure.stateChangeSlices[1];
452
+ let ship = Stdlib_Option.getOrThrow(shipOrder.commands.find(c => c.name === "ShipOrder"), undefined);
453
+ globalThis.expect(ship.schema.includes(`"x-reventless-graphql-type":"ID!"`)).toBe(true);
454
+ });
455
+ globalThis.test("CancelShipment: a @noApi variant publishes no argument types", () => {
456
+ let shipOrder = structure.stateChangeSlices[1];
457
+ let cancel = Stdlib_Option.getOrThrow(shipOrder.commands.find(c => c.name === "CancelShipment"), undefined);
458
+ globalThis.expect(cancel.schema.includes("x-reventless-graphql-type")).toBe(false);
459
+ });
450
460
  globalThis.test("ShipOrder: payload-less event ShipmentVoided is surfaced in events", () => {
451
461
  let shipOrder = structure.stateChangeSlices[1];
452
462
  globalThis.expect(shipOrder.events.map(e => e.name)).toEqual([