@reventlessdev/reventless-core 3.0.0-alpha.253 → 3.0.0-alpha.255

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +8 -8
  3. package/src/adapter/Messaging/Messaging_Log_Backend.res +98 -0
  4. package/src/adapter/Messaging/Messaging_Log_Backend.res.mjs +86 -0
  5. package/src/admin/Platform_ComponentDefinitionsApi.res +5 -1
  6. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  7. package/src/components/Api/GraphQL_FragmentGenerator.res +55 -14
  8. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +43 -12
  9. package/src/components/Api/SchemaType.res +1 -0
  10. package/src/components/Api/SchemaType.res.mjs +4 -0
  11. package/src/components/Api/SuryToJsonSchema.res +41 -0
  12. package/src/components/Api/SuryToJsonSchema.res.mjs +50 -17
  13. package/src/components/Dcb/Dcb_Builder.res +21 -4
  14. package/src/components/Dcb/Dcb_Builder.res.mjs +6 -3
  15. package/src/plugin/component/Plugin_Structure.res +13 -0
  16. package/src/plugin/component/Plugin_Structure.res.mjs +5 -1
  17. package/tests/admin/AdminApiSchemaDriftTest.res +1 -0
  18. package/tests/admin/AdminApiSchemaDriftTest.res.mjs +4 -1
  19. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +2 -0
  20. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +4 -2
  21. package/tests/admin/Platform_PluginStructuresApiTest.res +32 -0
  22. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +9 -0
  23. package/tests/api/GraphQL_FragmentGeneratorTest.res +47 -0
  24. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +32 -0
  25. package/tests/api/SuryToJsonSchemaTest.res +157 -0
  26. package/tests/api/SuryToJsonSchemaTest.res.mjs +81 -0
  27. package/tests/plugin/PluginStructureTest.res +22 -0
  28. package/tests/plugin/PluginStructureTest.res.mjs +7 -0
  29. package/tests/plugin/StateViewSlice/PsAnnotatedView.res +13 -3
  30. package/tests/plugin/StateViewSlice/PsAnnotatedView.res.mjs +7 -3
  31. package/tests/plugin/pluginDefinitionRequiredScalars.txt +6 -0
@@ -3,6 +3,7 @@
3
3
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
6
+ import * as Sensitive$Reventless from "@reventlessdev/reventless-spec/src/components/Sensitive.res.mjs";
6
7
  import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
7
8
  import * as SchemaType$ReventlessCore from "./SchemaType.res.mjs";
8
9
  import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
@@ -232,7 +233,7 @@ function fromSchemaType(st) {
232
233
  ]
233
234
  ]);
234
235
  case "ObjectRef" :
235
- return objectRefToJsonSchema(undefined, undefined, undefined, st._1);
236
+ return objectRefToJsonSchema(undefined, undefined, undefined, undefined, st._1);
236
237
  case "Enum" :
237
238
  return Object.fromEntries([
238
239
  [
@@ -269,7 +270,7 @@ function armToJsonSchema(tag, armType) {
269
270
  if (armType.TAG !== "ObjectRef") {
270
271
  return fromSchemaType(armType);
271
272
  }
272
- let base = objectRefToJsonSchema(undefined, undefined, undefined, armType._1);
273
+ let base = objectRefToJsonSchema(undefined, undefined, undefined, undefined, armType._1);
273
274
  let obj = Stdlib_JSON.Decode.object(base);
274
275
  if (obj === undefined) {
275
276
  return base;
@@ -303,26 +304,47 @@ function withSemantic(fieldSchema, sem) {
303
304
  obj["x-reventless-semantic-source"] = "type";
304
305
  let match = sem.payload;
305
306
  if (typeof match === "object") {
306
- if (match.TAG === "ReferenceTo") {
307
- let match$1 = match._0;
308
- obj["x-reventless-semantic-target"] = Object.fromEntries(withOptionalPlugin([[
309
- "entity",
310
- match$1.entity
311
- ]], match$1.plugin));
312
- } else {
313
- let match$2 = match._0;
314
- obj["x-reventless-semantic-target"] = Object.fromEntries(withOptionalPlugin([[
315
- "store",
316
- match$2.store
317
- ]], match$2.plugin));
307
+ switch (match.TAG) {
308
+ case "ReferenceTo" :
309
+ let match$1 = match._0;
310
+ obj["x-reventless-semantic-target"] = Object.fromEntries(withOptionalPlugin([[
311
+ "entity",
312
+ match$1.entity
313
+ ]], match$1.plugin));
314
+ break;
315
+ case "StoredIn" :
316
+ let match$2 = match._0;
317
+ obj["x-reventless-semantic-target"] = Object.fromEntries(withOptionalPlugin([[
318
+ "store",
319
+ match$2.store
320
+ ]], match$2.plugin));
321
+ break;
322
+ case "MemberOf" :
323
+ let match$3 = match._0;
324
+ let optional = (pairs, key, value) => {
325
+ if (value !== undefined) {
326
+ return pairs.concat([[
327
+ key,
328
+ value
329
+ ]]);
330
+ } else {
331
+ return pairs;
332
+ }
333
+ };
334
+ obj["x-reventless-semantic-target"] = Object.fromEntries(optional(optional(withOptionalPlugin([[
335
+ "field",
336
+ match$3.field
337
+ ]], match$3.plugin), "view", match$3.view), "content", match$3.content));
338
+ break;
318
339
  }
319
340
  }
320
341
  return obj;
321
342
  }
322
343
 
323
- function objectRefToJsonSchema(annotations, optionalOpt, ownersOpt, fields) {
344
+ function objectRefToJsonSchema(annotations, optionalOpt, ownersOpt, sensitiveOpt, fields) {
324
345
  let optional = optionalOpt !== undefined ? optionalOpt : [];
325
346
  let owners = ownersOpt !== undefined ? ownersOpt : [];
347
+ let sensitive = sensitiveOpt !== undefined ? sensitiveOpt : [];
326
348
  let props = {};
327
349
  let required = [];
328
350
  let internal = Stdlib_Option.getOr(Stdlib_Option.flatMap(annotations, spec => spec.internal), []);
@@ -343,7 +365,18 @@ function objectRefToJsonSchema(annotations, optionalOpt, ownersOpt, fields) {
343
365
  } else {
344
366
  withAnnotations$1 = withAnnotations;
345
367
  }
346
- props[fieldName] = withAnnotations$1;
368
+ let obj$1 = Stdlib_JSON.Decode.object(withAnnotations$1);
369
+ let withAnnotations$2;
370
+ if (obj$1 !== undefined) {
371
+ let bySemantic = Stdlib_Option.mapOr(Stdlib_Option.flatMap(obj$1["x-reventless-semantic"], Stdlib_JSON.Decode.string), false, Sensitive$Reventless.impliedBySemantic);
372
+ if (sensitive.includes(fieldName) || bySemantic) {
373
+ obj$1["x-reventless-sensitive"] = true;
374
+ }
375
+ withAnnotations$2 = obj$1;
376
+ } else {
377
+ withAnnotations$2 = withAnnotations$1;
378
+ }
379
+ props[fieldName] = withAnnotations$2;
347
380
  if (!optional.includes(fieldName) && !isNullableType(fieldType)) {
348
381
  required.push(fieldName);
349
382
  return;
@@ -377,7 +410,7 @@ function deriveObjectSchema(schema) {
377
410
  ]]);
378
411
  }
379
412
  let annotations = StateAnnotations$Reventless.getSpec(schema);
380
- let objSchema = objectRefToJsonSchema(annotations, SchemaType$ReventlessCore.optionalFieldNames(schema), Owner$Reventless.fieldNames(schema), fields);
413
+ let objSchema = objectRefToJsonSchema(annotations, SchemaType$ReventlessCore.optionalFieldNames(schema), Owner$Reventless.fieldNames(schema), Sensitive$Reventless.fieldNames(schema), fields);
381
414
  if (annotations === undefined) {
382
415
  return objSchema;
383
416
  }
@@ -379,10 +379,7 @@ module Make = (
379
379
  // (DcbCommandTopicEntryPoint.mjs) route through `DcbTag.deriveEffectiveScope`
380
380
  // so the runtime decision query cannot drift from this scope — see
381
381
  // docs/analysis/dcb-runtime-scope-annotation-drift.md.
382
- let {
383
- crossPartitionTagKeys: effectiveCrossPartitionTagKeys,
384
- tagKeysByEventType: effectiveTagKeysByEventType,
385
- } = Reventless.DcbTag.deriveEffectiveScope(
382
+ let effective = Reventless.DcbTag.deriveEffectiveScope(
386
383
  stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) => {
387
384
  Reventless.DcbTag.name: Sc.Spec.name,
388
385
  commandSchema: Sc.Spec.commandSchema->S.castToUnknown,
@@ -390,6 +387,26 @@ module Make = (
390
387
  eventSchema: Sc.Spec.eventSchema->S.castToUnknown,
391
388
  }),
392
389
  )
390
+ let effectiveCrossPartitionTagKeys = effective.crossPartitionTagKeys
391
+ let effectiveTagKeysByEventType = effective.tagKeysByEventType
392
+
393
+ // An ambiguity is a warning above because a boundary can fall back and
394
+ // lose nothing. Losing a cross-partition key is not that: the decision
395
+ // read for every slice that references another entity silently narrows to
396
+ // its own partition, so it decides against an empty history and rejects a
397
+ // command whose facts are sitting in the log. Nothing downstream fails —
398
+ // the wrong answer *is* the failure — so this is the only place it can be
399
+ // said, and it is said at error level naming the slice to fix.
400
+ if effective.droppedCrossPartitionTagKeys->Array.length > 0 {
401
+ log.error(
402
+ ~comp="Dcb_Builder",
403
+ `DCB scope degraded (${name}): cross-partition reads of [${effective.droppedCrossPartitionTagKeys->Array.join(
404
+ ", ",
405
+ )}] were derived but dropped — an ambiguous slice forced the boundary onto @crossPartition annotations that do not carry them. Slices referencing another entity by these keys will decide against an empty history and reject valid commands. Resolve: ${effective.ambiguities
406
+ ->Array.map(((slice, reason)) => `${slice} (${reason})`)
407
+ ->Array.join(" | ")}`,
408
+ )
409
+ }
393
410
 
394
411
  module DcbEventLog = DcbEventLog_Builder.Make(
395
412
  DcbEventLogStorage,
@@ -231,14 +231,17 @@ function Make(DcbEventLogStorage) {
231
231
  let scopeIssues = DcbValidation$Reventless.validateScopeVsInference(scopeAnnotations, inferred);
232
232
  scopeIssues.contradictions.forEach(e => log.warn("Dcb_Builder", undefined, `DCB scope contradiction (` + e.sliceName + `): ` + e.message));
233
233
  scopeIssues.redundancies.forEach(e => log.info("Dcb_Builder", undefined, `DCB scope (` + e.sliceName + `): ` + e.message));
234
- let match = DcbTag$Reventless.deriveEffectiveScope(stateChangeSlices.map(Sc => ({
234
+ let effective = DcbTag$Reventless.deriveEffectiveScope(stateChangeSlices.map(Sc => ({
235
235
  name: Sc.Spec.name,
236
236
  commandSchema: Sc.Spec.commandSchema,
237
237
  consumedEventSchema: Sc.Spec.consumedEventSchema,
238
238
  eventSchema: Sc.Spec.eventSchema
239
239
  })));
240
- let effectiveTagKeysByEventType = match.tagKeysByEventType;
241
- let effectiveCrossPartitionTagKeys = match.crossPartitionTagKeys;
240
+ let effectiveCrossPartitionTagKeys = effective.crossPartitionTagKeys;
241
+ let effectiveTagKeysByEventType = effective.tagKeysByEventType;
242
+ if (effective.droppedCrossPartitionTagKeys.length !== 0) {
243
+ log.error("Dcb_Builder", undefined, `DCB scope degraded (` + name + `): cross-partition reads of [` + effective.droppedCrossPartitionTagKeys.join(", ") + `] were derived but dropped — an ambiguous slice forced the boundary onto @crossPartition annotations that do not carry them. Slices referencing another entity by these keys will decide against an empty history and reject valid commands. Resolve: ` + effective.ambiguities.map(param => param[0] + ` (` + param[1] + `)`).join(" | "));
244
+ }
242
245
  let DcbEventLog = DcbEventLog_Builder$ReventlessCore.Make(DcbEventLogStorage)(DcbEventTopicPublisher);
243
246
  let dcbEventLog = DcbEventLog.make(name, indexes$1, partitionTag, effectiveCrossPartitionTagKeys, opts);
244
247
  Stdlib_Option.forEach(HooksConfig.hooks.onDcbEventLogCreated, hook => hook(dcbEventLog));
@@ -1282,6 +1282,13 @@ let make = (
1282
1282
  | Checked(failures) => failures->Array.forEach(f => retiredFailures->Array.push(f)->ignore)
1283
1283
  }
1284
1284
 
1285
+ // Warned rather than refused: an unkeyed view still answers, just without a
1286
+ // filter or an ordering, and a lint that stops a deploy gets silenced.
1287
+ let recordKeyField = (~entityName, stateSchema) =>
1288
+ GraphQL_FragmentGenerator.classifyKeyField(~entityName, stateSchema)
1289
+ ->GraphQL_FragmentGenerator.keyFieldGapMessage
1290
+ ->Option.forEach(why => log.warn(~comp="Plugin_Structure", `${name}/${entityName} ${why}`))
1291
+
1285
1292
  let readModelDefs =
1286
1293
  readModels
1287
1294
  ->Array.map((
@@ -1300,6 +1307,7 @@ let make = (
1300
1307
  let consumed = qualify(~prefix=name, R.consumedEventNames)
1301
1308
  recordRetired(~entityName=R.Spec.name, stateSchema)
1302
1309
  recordLifecycle(~entityName=R.Spec.name, stateSchema)
1310
+ recordKeyField(~entityName=R.Spec.name, stateSchema)
1303
1311
  ({
1304
1312
  Reventless.Plugin.name: R.Spec.name,
1305
1313
  queryField: qf.listFieldName,
@@ -1340,6 +1348,7 @@ let make = (
1340
1348
  )
1341
1349
  recordRetired(~entityName=SVS.Spec.name, stateSchema)
1342
1350
  recordLifecycle(~entityName=SVS.Spec.name, stateSchema)
1351
+ recordKeyField(~entityName=SVS.Spec.name, stateSchema)
1343
1352
  ({
1344
1353
  Reventless.Plugin.name: SVS.Spec.name,
1345
1354
  queryField: qf.listFieldName,
@@ -1452,6 +1461,10 @@ let make = (
1452
1461
  targetName: OTS.Spec.targetName,
1453
1462
  externalSystem: OTS.Spec.externalSystem,
1454
1463
  chapter: chapterOf(OTS.Spec.name),
1464
+ // Published verbatim, `[]` included: `[]` is the declared default meaning
1465
+ // this plugin's own DCB log, and resolving it here to a derived name would
1466
+ // be a second place that name is spelled.
1467
+ consumedSources: Some(OTS.Spec.sourceNames),
1455
1468
  }: Reventless.Plugin.outboundTranslationSliceDef))
1456
1469
 
1457
1470
  // ── Inbound translation slices ────────────────────────────────────────────
@@ -962,6 +962,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
962
962
  retiredFailures.push(f);
963
963
  });
964
964
  };
965
+ let recordKeyField = (entityName, stateSchema) => Stdlib_Option.forEach(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField(entityName, stateSchema)), why => log.warn("Plugin_Structure", undefined, name + `/` + entityName + ` ` + why));
965
966
  let readModelDefs = readModels.map(R => {
966
967
  let qf = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(name, R.Spec.name, undefined);
967
968
  let stateSchema = R.Spec.stateSchema;
@@ -970,6 +971,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
970
971
  let consumed = qualify(name, R.consumedEventNames);
971
972
  recordRetired(R.Spec.name, stateSchema);
972
973
  recordLifecycle(R.Spec.name, stateSchema);
974
+ recordKeyField(R.Spec.name, stateSchema);
973
975
  return {
974
976
  name: R.Spec.name,
975
977
  queryField: qf.listFieldName,
@@ -1001,6 +1003,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1001
1003
  let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(SVS.Spec.name, stateSchema);
1002
1004
  recordRetired(SVS.Spec.name, stateSchema);
1003
1005
  recordLifecycle(SVS.Spec.name, stateSchema);
1006
+ recordKeyField(SVS.Spec.name, stateSchema);
1004
1007
  return {
1005
1008
  name: SVS.Spec.name,
1006
1009
  queryField: qf.listFieldName,
@@ -1071,7 +1074,8 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1071
1074
  inboundCommandTypes: qualify(name, DcbTag$Reventless.extractAllVariantNames(OTS.Spec.inboundCommandSchema)),
1072
1075
  targetName: OTS.Spec.targetName,
1073
1076
  externalSystem: OTS.Spec.externalSystem,
1074
- chapter: componentChapters[OTS.Spec.name]
1077
+ chapter: componentChapters[OTS.Spec.name],
1078
+ consumedSources: OTS.Spec.sourceNames
1075
1079
  }));
1076
1080
  let inboundTranslationSliceDefs = inboundTranslationSlices.map(ITS => ({
1077
1081
  name: ITS.Spec.name,
@@ -128,6 +128,7 @@ let outbound: outboundTranslationSliceDef = {
128
128
  targetName: Some("Shipment"),
129
129
  externalSystem: Some("Shipper"),
130
130
  chapter: Some("Fulfilment"),
131
+ consumedSources: Some(["OrderingDcbEventLog"]),
131
132
  }
132
133
 
133
134
  let inbound: inboundTranslationSliceDef = {
@@ -199,13 +199,16 @@ let outbound_externalSystem = "Shipper";
199
199
 
200
200
  let outbound_chapter = "Fulfilment";
201
201
 
202
+ let outbound_consumedSources = ["OrderingDcbEventLog"];
203
+
202
204
  let outbound = {
203
205
  name: "ToShipper",
204
206
  consumedEventTypes: outbound_consumedEventTypes,
205
207
  inboundCommandTypes: outbound_inboundCommandTypes,
206
208
  targetName: outbound_targetName,
207
209
  externalSystem: outbound_externalSystem,
208
- chapter: outbound_chapter
210
+ chapter: outbound_chapter,
211
+ consumedSources: outbound_consumedSources
209
212
  };
210
213
 
211
214
  let inbound_commandTypes = ["RecordPayment"];
@@ -77,6 +77,7 @@ let structure: pluginStructure = {
77
77
  targetName: None,
78
78
  externalSystem: None,
79
79
  chapter: None,
80
+ consumedSources: None,
80
81
  },
81
82
  ],
82
83
  inboundTranslationSlices: [
@@ -227,6 +228,7 @@ describe("translation-slice externalSystem round-trip", () => {
227
228
  targetName: None,
228
229
  externalSystem: Some("ShipperGateway"),
229
230
  chapter: None,
231
+ consumedSources: None,
230
232
  },
231
233
  ],
232
234
  inboundTranslationSlices: [
@@ -118,7 +118,8 @@ let structure_outboundTranslationSlices = [{
118
118
  inboundCommandTypes: ["Ship"],
119
119
  targetName: undefined,
120
120
  externalSystem: undefined,
121
- chapter: undefined
121
+ chapter: undefined,
122
+ consumedSources: undefined
122
123
  }];
123
124
 
124
125
  let structure_inboundTranslationSlices = [{
@@ -245,7 +246,8 @@ globalThis.describe("translation-slice externalSystem round-trip", () => {
245
246
  inboundCommandTypes: ["Ship"],
246
247
  targetName: undefined,
247
248
  externalSystem: "ShipperGateway",
248
- chapter: undefined
249
+ chapter: undefined,
250
+ consumedSources: undefined
249
251
  }];
250
252
  let externalStructure_inboundTranslationSlices = [{
251
253
  name: "FromBilling",
@@ -183,4 +183,36 @@ describe("absent optional collections", () => {
183
183
  testSync("encodes absent requiredStores as null", () =>
184
184
  expect(bareJson->String.includes("\"requiredStores\":null"))->toEqual(true)
185
185
  )
186
+
187
+ // The case the frozen corpus cannot reach: every fixture in it carries
188
+ // `"outboundTranslationSlices":[]`, so no stored payload has an outbound slice
189
+ // to be missing a field. A structure written before `consumedSources` existed
190
+ // has no such key, and the schema alone refuses it — `S.nullAsOption` is
191
+ // `T | null`, and the variant that also accepts `undefined` fails sury's
192
+ // jsonable validation inside a union payload, which is where a structure
193
+ // travels. `parseJsonTolerant` is what makes the absence survivable, and it is
194
+ // the path the Plugin aggregate replays through.
195
+ testSync("an outbound slice stored before consumedSources existed still replays", () =>
196
+ expect(
197
+ (
198
+ `{"name":"ToShipper","consumedEventTypes":[],"inboundCommandTypes":[],` ++
199
+ `"targetName":null,"externalSystem":null,"chapter":null}`
200
+ )
201
+ ->JSON.parseOrThrow
202
+ ->Reventless.Message.parseJsonTolerant(outboundTranslationSliceDefSchema)
203
+ ->(slice => slice.consumedSources),
204
+ )->toEqual(None)
205
+ )
206
+
207
+ testSync("an explicit null decodes as None", () =>
208
+ expect(
209
+ (
210
+ `{"name":"ToShipper","consumedEventTypes":[],"inboundCommandTypes":[],` ++
211
+ `"targetName":null,"externalSystem":null,"chapter":null,"consumedSources":null}`
212
+ )
213
+ ->JSON.parseOrThrow
214
+ ->Reventless.Util_Sury.fromJson(outboundTranslationSliceDefSchema)
215
+ ->(slice => slice.consumedSources),
216
+ )->toEqual(None)
217
+ )
186
218
  })
@@ -4,6 +4,9 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
7
+ import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
8
+ import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
9
+ import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
7
10
  import * as Platform_PluginStructuresApi$ReventlessCore from "../../src/admin/Platform_PluginStructuresApi.res.mjs";
8
11
 
9
12
  let publicRm_consumedEventTypes = ["ProductAdded"];
@@ -237,6 +240,12 @@ globalThis.describe("absent optional collections", () => {
237
240
  globalThis.test("encodes absent requiredStores as null", () => {
238
241
  globalThis.expect(bareJson.includes("\"requiredStores\":null")).toEqual(true);
239
242
  });
243
+ globalThis.test("an outbound slice stored before consumedSources existed still replays", () => {
244
+ globalThis.expect(Message$Reventless.parseJsonTolerant(JSON.parse(`{"name":"ToShipper","consumedEventTypes":[],"inboundCommandTypes":[],"targetName":null,"externalSystem":null,"chapter":null}`), Plugin$Reventless.outboundTranslationSliceDefSchema).consumedSources).toEqual(undefined);
245
+ });
246
+ globalThis.test("an explicit null decodes as None", () => {
247
+ globalThis.expect(Util_Sury$Reventless.fromJson(JSON.parse(`{"name":"ToShipper","consumedEventTypes":[],"inboundCommandTypes":[],"targetName":null,"externalSystem":null,"chapter":null,"consumedSources":null}`), Plugin$Reventless.outboundTranslationSliceDefSchema).consumedSources).toEqual(undefined);
248
+ });
240
249
  });
241
250
 
242
251
  export {
@@ -139,6 +139,53 @@ describe("resolveKeyField — the ladder", () => {
139
139
  })
140
140
  })
141
141
 
142
+ // Losing the key is invisible — the view keeps every field and every row, and
143
+ // drops its `<field>Eq` filter and its whole `orderBy` from the SDL. These pin
144
+ // which of the two ways to lose it is worth saying out loud.
145
+ describe("keyFieldGapMessage — which gap is worth a warning", () => {
146
+ let gapFor = (~entityName, schema) =>
147
+ GraphQL_FragmentGenerator.classifyKeyField(~entityName, schema->S.castToUnknown)
148
+ ->GraphQL_FragmentGenerator.keyFieldGapMessage
149
+
150
+ // The accident §14b.2 named: the view had `productId` alone and somebody added
151
+ // `categoryId`, so `sole` stopped firing and the name matches neither.
152
+ testSync("a second `*Id` field taking the key away is named, with both fields", () => {
153
+ let schema = S.schema(s =>
154
+ {"productId": s.matches(S.string), "categoryId": s.matches(S.string)}
155
+ )
156
+ let message = gapFor(~entityName="ProductDemand", schema)->Option.getOr("")
157
+ expect(message->String.includes("productId, categoryId"))->toBe(true)
158
+ expect(message->String.includes("productDemandId"))->toBe(true)
159
+ })
160
+
161
+ // A read model over an aggregate keeps the row's id on the row key, not in its
162
+ // state. Warning here would fire on most of them and get the rule silenced —
163
+ // six views across the example plugins are exactly this shape.
164
+ testSync("a state with no `*Id` field at all is silent, not warned about", () =>
165
+ expect(
166
+ gapFor(
167
+ ~entityName="Customers",
168
+ S.schema(s => {"email": s.matches(S.string), "address": s.matches(S.string)}),
169
+ ),
170
+ )->toEqual(None)
171
+ )
172
+
173
+ testSync("a resolved key says nothing", () =>
174
+ expect(
175
+ gapFor(~entityName="Orders", S.schema(s => {"orderId": s.matches(S.string)})),
176
+ )->toEqual(None)
177
+ )
178
+
179
+ testSync("an @id-less view whose name picks one of its `*Id` fields is fine", () =>
180
+ expect(
181
+ gapFor(
182
+ ~entityName="Products",
183
+ S.schema(s => {"productId": s.matches(S.string), "categoryId": s.matches(S.string)}),
184
+ ),
185
+ )->toEqual(None)
186
+ )
187
+ })
188
+
142
189
  describe("deriveServerCapability — inferred keys reach the SDL surface", () => {
143
190
  let capabilityFor = (~entityName, schema) =>
144
191
  GraphQL_FragmentGenerator.deriveServerCapability(~entityName, schema->S.castToUnknown)
@@ -148,6 +148,38 @@ globalThis.describe("resolveKeyField — the ladder", () => {
148
148
  });
149
149
  });
150
150
 
151
+ globalThis.describe("keyFieldGapMessage — which gap is worth a warning", () => {
152
+ globalThis.test("a second `*Id` field taking the key away is named, with both fields", () => {
153
+ let schema = Sury.$schema(s => ({
154
+ productId: s.m(Sury.string),
155
+ categoryId: s.m(Sury.string)
156
+ }));
157
+ let message = Stdlib_Option.getOr(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField("ProductDemand", schema)), "");
158
+ globalThis.expect(message.includes("productId, categoryId")).toBe(true);
159
+ globalThis.expect(message.includes("productDemandId")).toBe(true);
160
+ });
161
+ globalThis.test("a state with no `*Id` field at all is silent, not warned about", () => {
162
+ let schema = Sury.$schema(s => ({
163
+ email: s.m(Sury.string),
164
+ address: s.m(Sury.string)
165
+ }));
166
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField("Customers", schema))).toEqual(undefined);
167
+ });
168
+ globalThis.test("a resolved key says nothing", () => {
169
+ let schema = Sury.$schema(s => ({
170
+ orderId: s.m(Sury.string)
171
+ }));
172
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField("Orders", schema))).toEqual(undefined);
173
+ });
174
+ globalThis.test("an @id-less view whose name picks one of its `*Id` fields is fine", () => {
175
+ let schema = Sury.$schema(s => ({
176
+ productId: s.m(Sury.string),
177
+ categoryId: s.m(Sury.string)
178
+ }));
179
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField("Products", schema))).toEqual(undefined);
180
+ });
181
+ });
182
+
151
183
  globalThis.describe("deriveServerCapability — inferred keys reach the SDL surface", () => {
152
184
  globalThis.test("an inferred key yields an eq filter and a sort field", () => {
153
185
  let schema = Sury.$schema(s => ({
@@ -119,6 +119,78 @@ describe("SuryToJsonSchema:", () => {
119
119
  })
120
120
  })
121
121
 
122
+ describe("deriveObjectSchema with a sensitive field:", () => {
123
+ let sensitiveOf = (json, name) =>
124
+ getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, "x-reventless-sensitive"))
125
+ let ownerOf = (json, name) =>
126
+ getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, "x-reventless-owner"))
127
+
128
+ let json = SuryToJsonSchema.deriveObjectSchema(
129
+ S.schema(s =>
130
+ {
131
+ "resetToken": s.matches(Reventless.Sensitive.string),
132
+ "note": s.matches(S.string),
133
+ "contact": s.matches(Reventless.Email.schema),
134
+ }
135
+ )->S.castToUnknown,
136
+ )
137
+
138
+ testSync("the marked field carries x-reventless-sensitive", () =>
139
+ expect(sensitiveOf(json, "resetToken"))->toEqual(Some(JSON.Encode.bool(true)))
140
+ )
141
+
142
+ // The control. Absent means "not stated", and a reader that misses the
143
+ // marker renders the value — so a bug that marked everything would look like
144
+ // caution while a bug that marked nothing leaks. Both need catching, and
145
+ // only this assertion catches the first.
146
+ testSync("an unmarked string field carries nothing", () =>
147
+ expect(sensitiveOf(json, "note"))->toBe(None)
148
+ )
149
+
150
+ // A contact detail is sensitive whether or not anybody wrote it down: the
151
+ // whole meaning of the semantic is "how to reach a particular person".
152
+ testSync("an email field is sensitive with no annotation", () =>
153
+ expect(sensitiveOf(json, "contact"))->toEqual(Some(JSON.Encode.bool(true)))
154
+ )
155
+
156
+ testSync("the field is still a plain string on the wire", () =>
157
+ expect(
158
+ getPropertyOf(json, "resetToken")->Option.flatMap(s => getProperty(s, "type")),
159
+ )->toEqual(Some(JSON.Encode.string("string")))
160
+ )
161
+
162
+ // Same trap as the owner case, and worse in consequence: a reader that only
163
+ // inspects the outer schema answers "not sensitive" for an optional field,
164
+ // and the value goes into a message.
165
+ testSync("an optional sensitive field is still recognised", () => {
166
+ let optJson = SuryToJsonSchema.deriveObjectSchema(
167
+ S.schema(s =>
168
+ {
169
+ "resetToken": s.matches(S.option(Reventless.Sensitive.string)),
170
+ }
171
+ )->S.castToUnknown,
172
+ )
173
+ expect(sensitiveOf(optJson, "resetToken"))->toEqual(Some(JSON.Encode.bool(true)))
174
+ })
175
+
176
+ // The composition rule. `mark` wraps rather than replaces, so a field that
177
+ // is both an owner and sensitive keeps both — a marker that silently
178
+ // replaced the other would unscope a view or unmask a value.
179
+ testSync("sensitivity composes with ownership on one field", () => {
180
+ let bothJson = SuryToJsonSchema.deriveObjectSchema(
181
+ S.schema(s =>
182
+ {
183
+ "customerId": s.matches(Reventless.Sensitive.mark(Reventless.Owner.string)),
184
+ }
185
+ )->S.castToUnknown,
186
+ )
187
+ expect((sensitiveOf(bothJson, "customerId"), ownerOf(bothJson, "customerId")))->toEqual((
188
+ Some(JSON.Encode.bool(true)),
189
+ Some(JSON.Encode.bool(true)),
190
+ ))
191
+ })
192
+ })
193
+
122
194
  describe("deriveObjectSchema with stateAnnotations metadata:", () => {
123
195
  let withSpec = (schema, spec) =>
124
196
  schema->S.Metadata.set(~id=Reventless.StateAnnotations.stateAnnotationsId, spec)
@@ -927,6 +999,91 @@ describe("SuryToJsonSchema:", () => {
927
999
  })
928
1000
  })
929
1001
 
1002
+ // A selection, not an input. The whole point of the id is that it travels the
1003
+ // same channel as a store declaration and says something a store declaration
1004
+ // cannot: the candidates are on the row, so nothing is provisioned for it.
1005
+ describe("a member-reference field:", () => {
1006
+ let targetOf = (json, name) =>
1007
+ getPropertyOf(json, name)->Option.flatMap(s =>
1008
+ getProperty(s, "x-reventless-semantic-target")
1009
+ )
1010
+
1011
+ testSync("carries the collection, the view holding it, and what a member is", () => {
1012
+ let json = SuryToJsonSchema.deriveObjectSchema(
1013
+ S.schema(s =>
1014
+ {
1015
+ "productImage": s.matches(
1016
+ Reventless.MemberRef.of_(
1017
+ ~view="Products",
1018
+ ~content=Reventless.Semantic.Id.imageRef,
1019
+ ~field="productImages",
1020
+ ),
1021
+ ),
1022
+ }
1023
+ )->S.castToUnknown,
1024
+ )
1025
+ expect(
1026
+ getPropertyOf(json, "productImage")->Option.flatMap(s =>
1027
+ getProperty(s, "x-reventless-semantic")
1028
+ ),
1029
+ )->toEqual(Some(JSON.Encode.string("memberRef")))
1030
+ expect(targetOf(json, "productImage"))->toEqual(
1031
+ Some(
1032
+ JSON.Encode.object(
1033
+ Dict.fromArray([
1034
+ ("field", JSON.Encode.string("productImages")),
1035
+ ("view", JSON.Encode.string("Products")),
1036
+ ("content", JSON.Encode.string("imageRef")),
1037
+ ]),
1038
+ ),
1039
+ ),
1040
+ )
1041
+ })
1042
+
1043
+ // The second position: a declaration on a view's own state, where the
1044
+ // collection is on the same record and naming a view would invite the reader
1045
+ // to think another one could be meant.
1046
+ testSync("omits the view where the collection is on this very record", () =>
1047
+ expect(
1048
+ targetOf(
1049
+ SuryToJsonSchema.deriveObjectSchema(
1050
+ S.schema(s =>
1051
+ {"productImage": s.matches(Reventless.MemberRef.of_(~field="productImages"))}
1052
+ )->S.castToUnknown,
1053
+ ),
1054
+ "productImage",
1055
+ ),
1056
+ )->toEqual(
1057
+ Some(JSON.Encode.object(Dict.fromArray([("field", JSON.Encode.string("productImages"))]))),
1058
+ )
1059
+ )
1060
+
1061
+ // The assertion the whole design rests on, and the one a reader will want to
1062
+ // see rather than infer: a `memberRef` field declares NO store, so the
1063
+ // provisioning walk passes it by and no upload endpoint is bound. This is
1064
+ // exactly what `imageRef` gets today, reached through a different payload.
1065
+ testSync("declares no store, so nothing is provisioned for it", () =>
1066
+ expect(
1067
+ Reventless.StorageRef.getFieldStore(
1068
+ Reventless.MemberRef.of_(~view="Products", ~field="productImages"),
1069
+ ),
1070
+ )->toBe(None)
1071
+ )
1072
+
1073
+ testSync("stays a plain string on the wire", () =>
1074
+ expect(
1075
+ getPropertyOf(
1076
+ SuryToJsonSchema.deriveObjectSchema(
1077
+ S.schema(s =>
1078
+ {"productImage": s.matches(Reventless.MemberRef.of_(~field="productImages"))}
1079
+ )->S.castToUnknown,
1080
+ ),
1081
+ "productImage",
1082
+ )->Option.flatMap(s => getProperty(s, "type")),
1083
+ )->toEqual(Some(JSON.Encode.string("string")))
1084
+ )
1085
+ })
1086
+
930
1087
  // The claim the branded scalars rest on: marking a field adds an annotation,
931
1088
  // not a shape. A string stays a string and a number stays a number on the
932
1089
  // wire, which is what makes them retrofittable onto a log that already has