@reventlessdev/reventless-core 3.0.0-alpha.254 → 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.
- package/CHANGELOG.md +11 -0
- package/package.json +7 -7
- package/src/admin/Platform_ComponentDefinitionsApi.res +5 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
- package/src/components/Api/GraphQL_FragmentGenerator.res +55 -14
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +43 -12
- package/src/components/Api/SchemaType.res +1 -0
- package/src/components/Api/SchemaType.res.mjs +4 -0
- package/src/components/Api/SuryToJsonSchema.res +41 -0
- package/src/components/Api/SuryToJsonSchema.res.mjs +50 -17
- package/src/plugin/component/Plugin_Structure.res +13 -0
- package/src/plugin/component/Plugin_Structure.res.mjs +5 -1
- package/tests/admin/AdminApiSchemaDriftTest.res +1 -0
- package/tests/admin/AdminApiSchemaDriftTest.res.mjs +4 -1
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +2 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +4 -2
- package/tests/admin/Platform_PluginStructuresApiTest.res +32 -0
- package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +9 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res +47 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +32 -0
- package/tests/api/SuryToJsonSchemaTest.res +157 -0
- package/tests/api/SuryToJsonSchemaTest.res.mjs +81 -0
- package/tests/plugin/PluginStructureTest.res +22 -0
- package/tests/plugin/PluginStructureTest.res.mjs +7 -0
- package/tests/plugin/StateViewSlice/PsAnnotatedView.res +13 -3
- package/tests/plugin/StateViewSlice/PsAnnotatedView.res.mjs +7 -3
- package/tests/plugin/pluginDefinitionRequiredScalars.txt +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
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.255 (2026-09-04)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **api:** a view says when a second *Id field took its key away ([10b5a4e](https://github.com/ReventlessDev/reventless-core/commit/10b5a4e41b01cfd27146f7f596573af27e4937d9))
|
|
11
|
+
* **plugin:** a slice publishes which topics it subscribes to ([c689695](https://github.com/ReventlessDev/reventless-core/commit/c6896957ecb636204678222ac5a26b30870439cb))
|
|
12
|
+
* **spec,traits:** an image carries the text that goes with it, and a set's first member is its primary ([e4e5845](https://github.com/ReventlessDev/reventless-core/commit/e4e58458aee7b3db5564727d358a3a9767362ca4))
|
|
13
|
+
* **spec:** a field can say it selects one of the values its row already holds ([2ae50c3](https://github.com/ReventlessDev/reventless-core/commit/2ae50c34deee48508a9f1f39e3eef6a5d2f5df00))
|
|
14
|
+
* **spec:** a field can say its value must not be rendered into a message ([3183f53](https://github.com/ReventlessDev/reventless-core/commit/3183f53a0ae667f794bbd1a3d77acf362dfa8e57))
|
|
15
|
+
|
|
16
|
+
|
|
6
17
|
# 3.0.0-alpha.254 (2026-09-02)
|
|
7
18
|
|
|
8
19
|
* feat(aws)!: the messaging sender is configuration, and a stack can choose to only log ([23b8b4b](https://github.com/ReventlessDev/reventless-core/commit/23b8b4bfe9c70555de4d74266ca686cb427485ca))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.255",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -29,21 +29,21 @@
|
|
|
29
29
|
"sury": "11.0.0-rc.2",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
31
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
32
|
-
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
|
|
33
32
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
34
|
-
"@reventlessdev/rescript-
|
|
33
|
+
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
|
|
35
34
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
35
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.8",
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
37
|
-
"@reventlessdev/
|
|
37
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.156",
|
|
38
38
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
|
|
39
|
-
"@reventlessdev/
|
|
40
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
39
|
+
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
40
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.128",
|
|
41
41
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.34"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
|
45
45
|
"sury-ppx": "11.0.0-rc.2",
|
|
46
|
-
"@reventlessdev/reventless-ppx": "1.0.0-alpha.
|
|
46
|
+
"@reventlessdev/reventless-ppx": "1.0.0-alpha.77"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"rescript": "12.3.0"
|
|
@@ -31,7 +31,7 @@ let sdlTypes: array<string> = [
|
|
|
31
31
|
// `idField` to report.
|
|
32
32
|
`type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n lifecycleField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n retiredField: String\n retiredValues: [String!]\n namedWhenRetired: Boolean!\n}`,
|
|
33
33
|
`type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
|
|
34
|
-
`type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
|
|
34
|
+
`type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n consumedSources: [String!]\n}`,
|
|
35
35
|
`type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
|
|
36
36
|
// The subscriber's half of the port's translation table — see `handledEventDef`.
|
|
37
37
|
`type Platform_HandledEventDef {\n name: String!\n toCommandTypes: [String!]!\n}`,
|
|
@@ -193,6 +193,10 @@ let encodeOutboundTranslationSliceDef = (o: outboundTranslationSliceDef): JSON.t
|
|
|
193
193
|
// the external-system boundary box without workspace access. None → null.
|
|
194
194
|
("externalSystem", o.externalSystem->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
|
|
195
195
|
("chapter", o.chapter->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
|
|
196
|
+
// Which topics this slice subscribes to, so a graph consumer can tell two
|
|
197
|
+
// topics carrying an event of the same name apart. `[]` means this plugin's
|
|
198
|
+
// own DCB log; null is a structure from before the field existed.
|
|
199
|
+
("consumedSources", o.consumedSources->Option.mapOr(JSON.Encode.null, encodeStrings)),
|
|
196
200
|
])->JSON.Encode.object
|
|
197
201
|
|
|
198
202
|
let encodeInboundTranslationSliceDef = (i: inboundTranslationSliceDef): JSON.t =>
|
|
@@ -12,7 +12,7 @@ let sdlTypes = [
|
|
|
12
12
|
`type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n errors: [Platform_ErrorDef!]!\n chapter: String\n}`,
|
|
13
13
|
`type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n lifecycleField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n retiredField: String\n retiredValues: [String!]\n namedWhenRetired: Boolean!\n}`,
|
|
14
14
|
`type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
|
|
15
|
-
`type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
|
|
15
|
+
`type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n consumedSources: [String!]\n}`,
|
|
16
16
|
`type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
|
|
17
17
|
`type Platform_HandledEventDef {\n name: String!\n toCommandTypes: [String!]!\n}`,
|
|
18
18
|
`type Platform_IssuedCommandDef {\n name: String!\n fromEventTypes: [String!]!\n}`,
|
|
@@ -303,6 +303,10 @@ function encodeOutboundTranslationSliceDef(o) {
|
|
|
303
303
|
[
|
|
304
304
|
"chapter",
|
|
305
305
|
Stdlib_Option.mapOr(o.chapter, null, prim => prim)
|
|
306
|
+
],
|
|
307
|
+
[
|
|
308
|
+
"consumedSources",
|
|
309
|
+
Stdlib_Option.mapOr(o.consumedSources, null, encodeStrings)
|
|
306
310
|
]
|
|
307
311
|
]);
|
|
308
312
|
}
|
|
@@ -300,28 +300,34 @@ let rec scalarOfSchemaType = (st: SchemaType.schemaType): string =>
|
|
|
300
300
|
let isKeyFieldName = (name: string): bool =>
|
|
301
301
|
name->String.length > 2 && name->String.endsWith("Id")
|
|
302
302
|
|
|
303
|
+
/** What the ladder below concluded. The two ways to have no key are opposite
|
|
304
|
+
mistakes, so they are kept apart rather than collapsed into `None`. */
|
|
305
|
+
type keyFieldResolution =
|
|
306
|
+
| Resolved({field: string, rung: string})
|
|
307
|
+
| /** Several `*Id` fields and none matching the name — usually a field
|
|
308
|
+
somebody added to a view that used to have exactly one. */
|
|
309
|
+
Ambiguous({candidates: array<string>, conventional: string})
|
|
310
|
+
| NoCandidate
|
|
311
|
+
|
|
303
312
|
/**
|
|
304
313
|
The field that identifies a row, and which rung answered:
|
|
305
314
|
|
|
306
315
|
- `"annotation"` — the state declares `@id`. Nothing outranks it.
|
|
307
316
|
- `"convention"` — a field named `<singular entity name>Id` exists
|
|
308
|
-
(`Products` → `productId`).
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
match (`ProductDemand`: `productId` + `categoryId`), or with none at all — those
|
|
315
|
-
need `@id`. Convention outranks sole so a view carrying one foreign key and no
|
|
316
|
-
key of its own is not keyed by the foreign key.
|
|
317
|
+
(`Products` → `productId`).
|
|
318
|
+
- `"sole"` — the state has exactly one `*Id` field (`AvailableProducts`).
|
|
319
|
+
|
|
320
|
+
Convention outranks sole so a view carrying one foreign key and no key of its own
|
|
321
|
+
is not keyed by the foreign key. `resolveKeyField` is this, with both gaps
|
|
322
|
+
flattened to `None`.
|
|
317
323
|
*/
|
|
318
|
-
let
|
|
324
|
+
let classifyKeyField = (~entityName: string, schema: S.t<unknown>): keyFieldResolution => {
|
|
319
325
|
let declared = switch Reventless.StateAnnotations.getSpec(schema) {
|
|
320
326
|
| Some({ids}) => ids->Array.get(0)
|
|
321
327
|
| None => None
|
|
322
328
|
}
|
|
323
329
|
switch declared {
|
|
324
|
-
| Some(field) =>
|
|
330
|
+
| Some(field) => Resolved({field, rung: "annotation"})
|
|
325
331
|
| None =>
|
|
326
332
|
let candidates =
|
|
327
333
|
SchemaType.fromSuryObject(~typeName="", schema)
|
|
@@ -333,15 +339,50 @@ let resolveKeyField = (~entityName: string, schema: S.t<unknown>): option<(strin
|
|
|
333
339
|
singular->String.slice(~start=0, ~end=1)->String.toLowerCase ++
|
|
334
340
|
singular->String.slice(~start=1, ~end=singular->String.length) ++ "Id"
|
|
335
341
|
if candidates->Array.includes(conventional) {
|
|
336
|
-
|
|
342
|
+
Resolved({field: conventional, rung: "convention"})
|
|
337
343
|
} else if candidates->Array.length == 1 {
|
|
338
|
-
|
|
344
|
+
Resolved({field: candidates->Array.getUnsafe(0), rung: "sole"})
|
|
345
|
+
} else if Array.length(candidates) == 0 {
|
|
346
|
+
NoCandidate
|
|
339
347
|
} else {
|
|
340
|
-
|
|
348
|
+
Ambiguous({candidates, conventional})
|
|
341
349
|
}
|
|
342
350
|
}
|
|
343
351
|
}
|
|
344
352
|
|
|
353
|
+
let resolveKeyField = (~entityName: string, schema: S.t<unknown>): option<(string, string)> =>
|
|
354
|
+
switch classifyKeyField(~entityName, schema) {
|
|
355
|
+
| Resolved({field, rung}) => Some((field, rung))
|
|
356
|
+
| Ambiguous(_) | NoCandidate => None
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
What losing the key costs this view, said where the ladder is so a caller only
|
|
361
|
+
has to report it.
|
|
362
|
+
|
|
363
|
+
Invisible otherwise: the view keeps every field and every row, and loses its
|
|
364
|
+
`<field>Eq` filter and its whole `orderBy` from the schema, so a client's
|
|
365
|
+
narrowing silently becomes a page fetched and filtered on the client.
|
|
366
|
+
|
|
367
|
+
`None` for `NoCandidate` as well as for a resolved key, and that is the whole
|
|
368
|
+
judgement here. A read model over an aggregate keeps the row's id on the row key
|
|
369
|
+
rather than in its state, so having no `*Id` field is its ordinary shape — warning
|
|
370
|
+
about it would fire on most of them and get the rule silenced. `Ambiguous` is the
|
|
371
|
+
accident: the view HAD a key and a second `*Id` field took it away.
|
|
372
|
+
*/
|
|
373
|
+
let keyFieldGapMessage = (resolution: keyFieldResolution): option<string> =>
|
|
374
|
+
switch resolution {
|
|
375
|
+
| Resolved(_) | NoCandidate => None
|
|
376
|
+
| Ambiguous({candidates, conventional}) =>
|
|
377
|
+
Some(
|
|
378
|
+
`has no row key: it declares no @id and its \`*Id\` fields ` ++
|
|
379
|
+
`(${candidates->Array.join(", ")}) include no "${conventional}" for the name to ` ++
|
|
380
|
+
`pick. Adding a second \`*Id\` field to a view that had one is what lands here, ` ++
|
|
381
|
+
`and it costs the view its filter and its whole orderBy in the schema. Declare ` ++
|
|
382
|
+
`@id on the field that identifies a row.`,
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
345
386
|
// The component name the key-field convention is read against. `specName` is the
|
|
346
387
|
// read model's own `Spec.name`; without it, `returnTypeName` minus its plugin
|
|
347
388
|
// prefix is the same string (`Catalog_Product` → `Product`).
|
|
@@ -244,30 +244,59 @@ function isKeyFieldName(name) {
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
function
|
|
247
|
+
function classifyKeyField(entityName, schema) {
|
|
248
248
|
let match = StateAnnotations$Reventless.getSpec(schema);
|
|
249
249
|
let declared = match !== undefined ? match.ids[0] : undefined;
|
|
250
250
|
if (declared !== undefined) {
|
|
251
|
-
return
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
251
|
+
return {
|
|
252
|
+
TAG: "Resolved",
|
|
253
|
+
field: declared,
|
|
254
|
+
rung: "annotation"
|
|
255
|
+
};
|
|
255
256
|
}
|
|
256
257
|
let candidates = Object.keys(Stdlib_Option.getOr(SchemaType$ReventlessCore.fromSuryObject("", schema), {})).filter(isKeyFieldName);
|
|
257
258
|
let singular = Api_Naming$ReventlessCore.singularize(Api_Naming$ReventlessCore.stripViewSuffix(entityName));
|
|
258
259
|
let conventional = singular.slice(0, 1).toLowerCase() + singular.slice(1, singular.length) + "Id";
|
|
259
260
|
if (candidates.includes(conventional)) {
|
|
260
|
-
return
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
261
|
+
return {
|
|
262
|
+
TAG: "Resolved",
|
|
263
|
+
field: conventional,
|
|
264
|
+
rung: "convention"
|
|
265
|
+
};
|
|
264
266
|
} else if (candidates.length === 1) {
|
|
267
|
+
return {
|
|
268
|
+
TAG: "Resolved",
|
|
269
|
+
field: candidates[0],
|
|
270
|
+
rung: "sole"
|
|
271
|
+
};
|
|
272
|
+
} else if (candidates.length === 0) {
|
|
273
|
+
return "NoCandidate";
|
|
274
|
+
} else {
|
|
275
|
+
return {
|
|
276
|
+
TAG: "Ambiguous",
|
|
277
|
+
candidates: candidates,
|
|
278
|
+
conventional: conventional
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function resolveKeyField(entityName, schema) {
|
|
284
|
+
let match = classifyKeyField(entityName, schema);
|
|
285
|
+
if (typeof match !== "object" || match.TAG !== "Resolved") {
|
|
286
|
+
return;
|
|
287
|
+
} else {
|
|
265
288
|
return [
|
|
266
|
-
|
|
267
|
-
|
|
289
|
+
match.field,
|
|
290
|
+
match.rung
|
|
268
291
|
];
|
|
269
|
-
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function keyFieldGapMessage(resolution) {
|
|
296
|
+
if (typeof resolution !== "object" || resolution.TAG === "Resolved") {
|
|
270
297
|
return;
|
|
298
|
+
} else {
|
|
299
|
+
return `has no row key: it declares no @id and its \`*Id\` fields ` + (`(` + resolution.candidates.join(", ") + `) include no "` + resolution.conventional + `" for the name to `) + `pick. Adding a second \`*Id\` field to a view that had one is what lands here, and it costs the view its filter and its whole orderBy in the schema. Declare @id on the field that identifies a row.`;
|
|
271
300
|
}
|
|
272
301
|
}
|
|
273
302
|
|
|
@@ -655,7 +684,9 @@ export {
|
|
|
655
684
|
emptyCapability,
|
|
656
685
|
scalarOfSchemaType,
|
|
657
686
|
isKeyFieldName,
|
|
687
|
+
classifyKeyField,
|
|
658
688
|
resolveKeyField,
|
|
689
|
+
keyFieldGapMessage,
|
|
659
690
|
entityNameOf,
|
|
660
691
|
deriveServerCapability,
|
|
661
692
|
validateScanSortAlignment,
|
|
@@ -60,6 +60,7 @@ let semanticCompositeNames = [
|
|
|
60
60
|
(Reventless.Semantic.Id.money, "Money"),
|
|
61
61
|
(Reventless.Semantic.Id.dateRange, "DateRange"),
|
|
62
62
|
(Reventless.Semantic.Id.geoPoint, "GeoPoint"),
|
|
63
|
+
(Reventless.Semantic.Id.captionedImage, "CaptionedImage"),
|
|
63
64
|
]
|
|
64
65
|
|
|
65
66
|
let canonicalName = (id: string): option<string> =>
|
|
@@ -271,6 +271,24 @@ and withSemantic = (fieldSchema: JSON.t, sem: Reventless.Semantic.t): JSON.t =>
|
|
|
271
271
|
"x-reventless-semantic-target",
|
|
272
272
|
jsonObject(withOptionalPlugin([("store", str(store))], plugin)),
|
|
273
273
|
)
|
|
274
|
+
| MemberOf({plugin, view, field, content}) =>
|
|
275
|
+
// Same channel as the two above, and `view` and `content` are omitted on
|
|
276
|
+
// the same terms as `plugin`: absent means the collection is on the row
|
|
277
|
+
// this field is already part of, and that the member's kind is the
|
|
278
|
+
// reader's own to work out.
|
|
279
|
+
let optional = (pairs, key, value) =>
|
|
280
|
+
switch value {
|
|
281
|
+
| Some(v) => Array.concat(pairs, [(key, str(v))])
|
|
282
|
+
| None => pairs
|
|
283
|
+
}
|
|
284
|
+
obj->Dict.set(
|
|
285
|
+
"x-reventless-semantic-target",
|
|
286
|
+
jsonObject(
|
|
287
|
+
withOptionalPlugin([("field", str(field))], plugin)
|
|
288
|
+
->optional("view", view)
|
|
289
|
+
->optional("content", content),
|
|
290
|
+
),
|
|
291
|
+
)
|
|
274
292
|
}
|
|
275
293
|
JSON.Encode.object(obj)
|
|
276
294
|
}
|
|
@@ -291,10 +309,15 @@ and withSemantic = (fieldSchema: JSON.t, sem: Reventless.Semantic.t): JSON.t =>
|
|
|
291
309
|
// belongs to, leaving the field a plain string either way. Threading it here
|
|
292
310
|
// also keeps it available on command variants, which carry no annotation spec
|
|
293
311
|
// for `mergeAnnotations` to read.
|
|
312
|
+
// `sensitive` rides here on the same reasoning as `owners`, and is read the same
|
|
313
|
+
// way — off the sury schema by the caller. What it marks is a field whose value
|
|
314
|
+
// must not be rendered into content somebody receives, which is a property of
|
|
315
|
+
// the domain model rather than of the field's shape.
|
|
294
316
|
and objectRefToJsonSchema = (
|
|
295
317
|
~annotations: option<Reventless.StateAnnotations.stateAnnotationSpec>=?,
|
|
296
318
|
~optional: array<string>=[],
|
|
297
319
|
~owners: array<string>=[],
|
|
320
|
+
~sensitive: array<string>=[],
|
|
298
321
|
fields: dict<SchemaType.schemaType>,
|
|
299
322
|
): JSON.t => {
|
|
300
323
|
let props = Dict.make()
|
|
@@ -328,6 +351,23 @@ and objectRefToJsonSchema = (
|
|
|
328
351
|
} else {
|
|
329
352
|
withAnnotations
|
|
330
353
|
}
|
|
354
|
+
// Last, so it can read the semantic both earlier paths may have written. A
|
|
355
|
+
// field whose semantic is a way of reaching a particular person is sensitive
|
|
356
|
+
// whether or not anybody marked it — the annotation is for the values a type
|
|
357
|
+
// cannot betray.
|
|
358
|
+
let withAnnotations = switch withAnnotations->JSON.Decode.object {
|
|
359
|
+
| Some(obj) =>
|
|
360
|
+
let bySemantic =
|
|
361
|
+
obj
|
|
362
|
+
->Dict.get("x-reventless-semantic")
|
|
363
|
+
->Option.flatMap(JSON.Decode.string)
|
|
364
|
+
->Option.mapOr(false, Reventless.Sensitive.impliedBySemantic)
|
|
365
|
+
if sensitive->Array.includes(fieldName) || bySemantic {
|
|
366
|
+
obj->Dict.set("x-reventless-sensitive", JSON.Encode.bool(true))
|
|
367
|
+
}
|
|
368
|
+
JSON.Encode.object(obj)
|
|
369
|
+
| None => withAnnotations
|
|
370
|
+
}
|
|
331
371
|
props->Dict.set(fieldName, withAnnotations)
|
|
332
372
|
// Optional two ways, because neither source answers alone. `optional` is
|
|
333
373
|
// read off the sury schema and is the only thing that can speak for a
|
|
@@ -362,6 +402,7 @@ let deriveObjectSchema = (schema: S.t<unknown>): JSON.t =>
|
|
|
362
402
|
~annotations?,
|
|
363
403
|
~optional=SchemaType.optionalFieldNames(schema),
|
|
364
404
|
~owners=Reventless.Owner.fieldNames(schema),
|
|
405
|
+
~sensitive=Reventless.Sensitive.fieldNames(schema),
|
|
365
406
|
fields,
|
|
366
407
|
)
|
|
367
408
|
// Surface component-level hints on the top-level object schema.
|
|
@@ -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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
@@ -13,8 +13,11 @@ import * as Percent$Reventless from "@reventlessdev/reventless-spec/src/semantic
|
|
|
13
13
|
import * as Currency$Reventless from "@reventlessdev/reventless-spec/src/semantic/Currency.res.mjs";
|
|
14
14
|
import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
|
|
15
15
|
import * as GeoPoint$Reventless from "@reventlessdev/reventless-spec/src/semantic/GeoPoint.res.mjs";
|
|
16
|
+
import * as Semantic$Reventless from "@reventlessdev/reventless-spec/src/semantic/Semantic.res.mjs";
|
|
16
17
|
import * as DateRange$Reventless from "@reventlessdev/reventless-spec/src/semantic/DateRange.res.mjs";
|
|
18
|
+
import * as MemberRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/MemberRef.res.mjs";
|
|
17
19
|
import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
|
|
20
|
+
import * as Sensitive$Reventless from "@reventlessdev/reventless-spec/src/components/Sensitive.res.mjs";
|
|
18
21
|
import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
|
|
19
22
|
import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
|
|
20
23
|
import * as SuryToJsonSchema$ReventlessCore from "../../src/components/Api/SuryToJsonSchema.res.mjs";
|
|
@@ -130,6 +133,45 @@ globalThis.describe("SuryToJsonSchema:", () => {
|
|
|
130
133
|
globalThis.expect(ownerOf(optJson, "customerId")).toEqual(true);
|
|
131
134
|
});
|
|
132
135
|
});
|
|
136
|
+
globalThis.describe("deriveObjectSchema with a sensitive field:", () => {
|
|
137
|
+
let sensitiveOf = (json, name) => Stdlib_Option.flatMap(getPropertyOf(json, name), s => getProperty(s, "x-reventless-sensitive"));
|
|
138
|
+
let ownerOf = (json, name) => Stdlib_Option.flatMap(getPropertyOf(json, name), s => getProperty(s, "x-reventless-owner"));
|
|
139
|
+
let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(Sury.$schema(s => ({
|
|
140
|
+
resetToken: s.m(Sensitive$Reventless.string),
|
|
141
|
+
note: s.m(Sury.string),
|
|
142
|
+
contact: s.m(Email$Reventless.schema)
|
|
143
|
+
})));
|
|
144
|
+
globalThis.test("the marked field carries x-reventless-sensitive", () => {
|
|
145
|
+
globalThis.expect(sensitiveOf(json, "resetToken")).toEqual(true);
|
|
146
|
+
});
|
|
147
|
+
globalThis.test("an unmarked string field carries nothing", () => {
|
|
148
|
+
globalThis.expect(sensitiveOf(json, "note")).toBe(undefined);
|
|
149
|
+
});
|
|
150
|
+
globalThis.test("an email field is sensitive with no annotation", () => {
|
|
151
|
+
globalThis.expect(sensitiveOf(json, "contact")).toEqual(true);
|
|
152
|
+
});
|
|
153
|
+
globalThis.test("the field is still a plain string on the wire", () => {
|
|
154
|
+
globalThis.expect(Stdlib_Option.flatMap(getPropertyOf(json, "resetToken"), s => getProperty(s, "type"))).toEqual("string");
|
|
155
|
+
});
|
|
156
|
+
globalThis.test("an optional sensitive field is still recognised", () => {
|
|
157
|
+
let optJson = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(Sury.$schema(s => ({
|
|
158
|
+
resetToken: s.m(Sury.$option(Sensitive$Reventless.string))
|
|
159
|
+
})));
|
|
160
|
+
globalThis.expect(sensitiveOf(optJson, "resetToken")).toEqual(true);
|
|
161
|
+
});
|
|
162
|
+
globalThis.test("sensitivity composes with ownership on one field", () => {
|
|
163
|
+
let bothJson = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(Sury.$schema(s => ({
|
|
164
|
+
customerId: s.m(Sensitive$Reventless.mark(Owner$Reventless.string))
|
|
165
|
+
})));
|
|
166
|
+
globalThis.expect([
|
|
167
|
+
sensitiveOf(bothJson, "customerId"),
|
|
168
|
+
ownerOf(bothJson, "customerId")
|
|
169
|
+
]).toEqual([
|
|
170
|
+
true,
|
|
171
|
+
true
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
133
175
|
globalThis.describe("deriveObjectSchema with stateAnnotations metadata:", () => {
|
|
134
176
|
globalThis.test("emits x-reventless-id on field listed in ids", () => {
|
|
135
177
|
let schema = Sury.$schema(s => ({
|
|
@@ -872,6 +914,45 @@ globalThis.describe("SuryToJsonSchema:", () => {
|
|
|
872
914
|
globalThis.expect(semanticOf(json, "imageUrl")).toEqual("storageRef");
|
|
873
915
|
});
|
|
874
916
|
});
|
|
917
|
+
globalThis.describe("a member-reference field:", () => {
|
|
918
|
+
let targetOf = (json, name) => Stdlib_Option.flatMap(getPropertyOf(json, name), s => getProperty(s, "x-reventless-semantic-target"));
|
|
919
|
+
globalThis.test("carries the collection, the view holding it, and what a member is", () => {
|
|
920
|
+
let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(Sury.$schema(s => ({
|
|
921
|
+
productImage: s.m(MemberRef$Reventless.of_(undefined, "Products", Semantic$Reventless.Id.imageRef, "productImages"))
|
|
922
|
+
})));
|
|
923
|
+
globalThis.expect(Stdlib_Option.flatMap(getPropertyOf(json, "productImage"), s => getProperty(s, "x-reventless-semantic"))).toEqual("memberRef");
|
|
924
|
+
globalThis.expect(targetOf(json, "productImage")).toEqual(Object.fromEntries([
|
|
925
|
+
[
|
|
926
|
+
"field",
|
|
927
|
+
"productImages"
|
|
928
|
+
],
|
|
929
|
+
[
|
|
930
|
+
"view",
|
|
931
|
+
"Products"
|
|
932
|
+
],
|
|
933
|
+
[
|
|
934
|
+
"content",
|
|
935
|
+
"imageRef"
|
|
936
|
+
]
|
|
937
|
+
]));
|
|
938
|
+
});
|
|
939
|
+
globalThis.test("omits the view where the collection is on this very record", () => {
|
|
940
|
+
globalThis.expect(targetOf(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(Sury.$schema(s => ({
|
|
941
|
+
productImage: s.m(MemberRef$Reventless.of_(undefined, undefined, undefined, "productImages"))
|
|
942
|
+
}))), "productImage")).toEqual(Object.fromEntries([[
|
|
943
|
+
"field",
|
|
944
|
+
"productImages"
|
|
945
|
+
]]));
|
|
946
|
+
});
|
|
947
|
+
globalThis.test("declares no store, so nothing is provisioned for it", () => {
|
|
948
|
+
globalThis.expect(StorageRef$Reventless.getFieldStore(MemberRef$Reventless.of_(undefined, "Products", undefined, "productImages"))).toBe(undefined);
|
|
949
|
+
});
|
|
950
|
+
globalThis.test("stays a plain string on the wire", () => {
|
|
951
|
+
globalThis.expect(Stdlib_Option.flatMap(getPropertyOf(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(Sury.$schema(s => ({
|
|
952
|
+
productImage: s.m(MemberRef$Reventless.of_(undefined, undefined, undefined, "productImages"))
|
|
953
|
+
}))), "productImage"), s => getProperty(s, "type"))).toEqual("string");
|
|
954
|
+
});
|
|
955
|
+
});
|
|
875
956
|
globalThis.describe("branded scalars keep their underlying shape:", () => {
|
|
876
957
|
let keyOf = (json, name, key) => Stdlib_Option.flatMap(getPropertyOf(json, name), s => getProperty(s, key));
|
|
877
958
|
let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(Sury.$schema(s => ({
|
|
@@ -1300,6 +1300,28 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
1300
1300
|
)->toBe(Some("currency"))
|
|
1301
1301
|
})
|
|
1302
1302
|
|
|
1303
|
+
testSync("@sensitive flows through the PPX to x-reventless-sensitive", () => {
|
|
1304
|
+
expect(
|
|
1305
|
+
annotatedSchema
|
|
1306
|
+
->getPropertyOf("contact")
|
|
1307
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-sensitive"))
|
|
1308
|
+
->Option.flatMap(JSON.Decode.bool),
|
|
1309
|
+
)->toBe(Some(true))
|
|
1310
|
+
})
|
|
1311
|
+
|
|
1312
|
+
// The case the marker exists for. A renderer composes from an event payload,
|
|
1313
|
+
// and the annotation path `deriveObjectSchema` reads for state is
|
|
1314
|
+
// state-only — so a marker that lived there would be absent exactly where
|
|
1315
|
+
// the value is about to be interpolated into a message.
|
|
1316
|
+
testSync("@sensitive reaches an event variant's payload, not only state", () =>
|
|
1317
|
+
expect(
|
|
1318
|
+
Reventless.Sensitive.variantFieldNames(
|
|
1319
|
+
PsAnnotatedView.consumedEventSchema->S.castToUnknown,
|
|
1320
|
+
~variant="ItemRecorded",
|
|
1321
|
+
),
|
|
1322
|
+
)->toEqual(["contact"])
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1303
1325
|
testSync("@metric flows through the PPX to x-reventless-metric {aggregate,label}", () => {
|
|
1304
1326
|
let metricObj =
|
|
1305
1327
|
annotatedSchema
|
|
@@ -11,6 +11,7 @@ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/component
|
|
|
11
11
|
import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
|
|
12
12
|
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
13
13
|
import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
|
|
14
|
+
import * as Sensitive$Reventless from "@reventlessdev/reventless-spec/src/components/Sensitive.res.mjs";
|
|
14
15
|
import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
|
|
15
16
|
import * as Api_Naming$ReventlessCore from "../../src/components/Api/Api_Naming.res.mjs";
|
|
16
17
|
import * as PsShipOrder$ReventlessCore from "./StateChangeSlice/PsShipOrder.res.mjs";
|
|
@@ -1444,6 +1445,12 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
1444
1445
|
globalThis.test("@semantic(\"currency\") flows through the PPX to x-reventless-semantic", () => {
|
|
1445
1446
|
globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(getPropertyOf(annotatedSchema, "total"), s => getProperty(s, "x-reventless-semantic")), Stdlib_JSON.Decode.string)).toBe("currency");
|
|
1446
1447
|
});
|
|
1448
|
+
globalThis.test("@sensitive flows through the PPX to x-reventless-sensitive", () => {
|
|
1449
|
+
globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(getPropertyOf(annotatedSchema, "contact"), s => getProperty(s, "x-reventless-sensitive")), Stdlib_JSON.Decode.bool)).toBe(true);
|
|
1450
|
+
});
|
|
1451
|
+
globalThis.test("@sensitive reaches an event variant's payload, not only state", () => {
|
|
1452
|
+
globalThis.expect(Sensitive$Reventless.variantFieldNames(PsAnnotatedView$ReventlessCore.consumedEventSchema, "ItemRecorded")).toEqual(["contact"]);
|
|
1453
|
+
});
|
|
1447
1454
|
globalThis.test("@metric flows through the PPX to x-reventless-metric {aggregate,label}", () => {
|
|
1448
1455
|
let metricObj = Stdlib_Option.flatMap(getPropertyOf(annotatedSchema, "total"), s => getProperty(s, "x-reventless-metric"));
|
|
1449
1456
|
globalThis.expect([
|
|
@@ -7,7 +7,16 @@
|
|
|
7
7
|
|
|
8
8
|
@schema
|
|
9
9
|
type consumedEvent =
|
|
10
|
-
| ItemRecorded({
|
|
10
|
+
| ItemRecorded({
|
|
11
|
+
itemId: string,
|
|
12
|
+
ownerId: string,
|
|
13
|
+
version: string,
|
|
14
|
+
name: string,
|
|
15
|
+
total: float,
|
|
16
|
+
// On the event, not just the state: a renderer composes from an event
|
|
17
|
+
// payload, so this is the case the marker has to reach.
|
|
18
|
+
@sensitive contact: string,
|
|
19
|
+
})
|
|
11
20
|
|
|
12
21
|
@live(false)
|
|
13
22
|
@schema
|
|
@@ -17,10 +26,11 @@ type state = {
|
|
|
17
26
|
@index("byOwner") ownerId: string,
|
|
18
27
|
name: string,
|
|
19
28
|
@semantic("currency") @metric({aggregate: "sum", label: "Revenue"}) total: float,
|
|
29
|
+
@sensitive contact: string,
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
let project = ({event}: Reventless.StateViewSlice.consumed<consumedEvent>) =>
|
|
23
33
|
switch event {
|
|
24
|
-
| ItemRecorded({itemId, ownerId, version, name, total}) =>
|
|
25
|
-
[Set(itemId, {itemId, ownerId, version, name, total})]
|
|
34
|
+
| ItemRecorded({itemId, ownerId, version, name, total, contact}) =>
|
|
35
|
+
[Set(itemId, {itemId, ownerId, version, name, total, contact})]
|
|
26
36
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as Sury from "sury";
|
|
4
4
|
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
5
5
|
import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
|
|
6
|
+
import * as Sensitive$Reventless from "@reventlessdev/reventless-spec/src/components/Sensitive.res.mjs";
|
|
6
7
|
import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
|
|
7
8
|
|
|
8
9
|
let consumedEventSchema = Sury.$schema(s => ({
|
|
@@ -11,7 +12,8 @@ let consumedEventSchema = Sury.$schema(s => ({
|
|
|
11
12
|
ownerId: s.m(DcbTag$Reventless.string),
|
|
12
13
|
version: s.m(Sury.string),
|
|
13
14
|
name: s.m(Sury.string),
|
|
14
|
-
total: s.m(Sury.float)
|
|
15
|
+
total: s.m(Sury.float),
|
|
16
|
+
contact: s.m(Sensitive$Reventless.string)
|
|
15
17
|
}));
|
|
16
18
|
|
|
17
19
|
let stateSchema = Sury.$schema(s => ({
|
|
@@ -19,7 +21,8 @@ let stateSchema = Sury.$schema(s => ({
|
|
|
19
21
|
version: s.m(Sury.string),
|
|
20
22
|
ownerId: s.m(Sury.string),
|
|
21
23
|
name: s.m(Sury.string),
|
|
22
|
-
total: s.m(Sury.float)
|
|
24
|
+
total: s.m(Sury.float),
|
|
25
|
+
contact: s.m(Sensitive$Reventless.string)
|
|
23
26
|
}));
|
|
24
27
|
|
|
25
28
|
function project(param) {
|
|
@@ -33,7 +36,8 @@ function project(param) {
|
|
|
33
36
|
version: event.version,
|
|
34
37
|
ownerId: event.ownerId,
|
|
35
38
|
name: event.name,
|
|
36
|
-
total: event.total
|
|
39
|
+
total: event.total,
|
|
40
|
+
contact: event.contact
|
|
37
41
|
}
|
|
38
42
|
}];
|
|
39
43
|
}
|
|
@@ -94,6 +94,12 @@
|
|
|
94
94
|
.structure.inboundTranslationSlices[].name: string
|
|
95
95
|
.structure.inboundTranslationSlices[].targetName: string
|
|
96
96
|
.structure.outboundTranslationSlices[].consumedEventTypes[]: string
|
|
97
|
+
# `consumedSources` is itself `js_nullable` and is NEW, so no persisted payload
|
|
98
|
+
# carries the array: a stored structure heals to null and this line is never
|
|
99
|
+
# reached — the same call `traitDeclarations` made below. Required inside it
|
|
100
|
+
# because a source name is a topic key the slice subscribes to, and a fabricated
|
|
101
|
+
# `""` would name a topic that does not exist rather than saying nothing.
|
|
102
|
+
.structure.outboundTranslationSlices[].consumedSources[]: string
|
|
97
103
|
.structure.outboundTranslationSlices[].inboundCommandTypes[]: string
|
|
98
104
|
.structure.outboundTranslationSlices[].name: string
|
|
99
105
|
.structure.readModels[].consumedEventTypes[]: string
|