@reventlessdev/reventless-aws 3.0.0-alpha.316 → 3.0.0-alpha.318

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 (26) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +10 -10
  3. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +12 -3
  4. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +16 -3
  5. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +106 -51
  6. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +52 -18
  7. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +10 -2
  8. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +3 -2
  9. package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res +31 -0
  10. package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res.mjs +26 -1
  11. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +1 -0
  12. package/src/adapter/Runtime/ReadModelEntryPoint_Ops.res +5 -1
  13. package/src/adapter/Runtime/ReadModelEntryPoint_Ops.res.mjs +1 -1
  14. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +1 -0
  15. package/src/adapter/Runtime/StateViewSliceEntryPoint_Ops.res +3 -1
  16. package/src/adapter/Runtime/StateViewSliceEntryPoint_Ops.res.mjs +1 -1
  17. package/tests/AppSync_RetirementNarrowingTest.res +78 -0
  18. package/tests/AppSync_RetirementNarrowingTest.res.mjs +52 -0
  19. package/tests/ProjectionEntryPoint_UnionStampTest.res +88 -0
  20. package/tests/ProjectionEntryPoint_UnionStampTest.res.mjs +137 -0
  21. package/tests/integration/StateViewSliceEntryPoint_IntegrationTest.res +16 -0
  22. package/tests/integration/StateViewSliceEntryPoint_IntegrationTest.res.mjs +6 -0
  23. package/tests/integration/SvsTestSlice.res +10 -0
  24. package/tests/integration/SvsTestSlice.res.mjs +17 -1
  25. package/tests/integration/SvsTestSlice_Projection.res +3 -1
  26. package/tests/integration/SvsTestSlice_Projection.res.mjs +5 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
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.318 (2026-08-21)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core:** make @resolves/[@resolves](https://github.com/resolves)Many work end to end ([4c52957](https://github.com/ReventlessDev/reventless-core/commit/4c5295759dcb4b3eda4f0f4f2c1bc387fed88fcb))
11
+ * **deps:** update sury to 11.0.0-rc.2 to fix unreachable union constructors ([fa5744f](https://github.com/ReventlessDev/reventless-core/commit/fa5744fed8de975e2f14725c856c6e5ce7d04a74))
12
+
13
+
14
+ # 3.0.0-alpha.317 (2026-08-20)
15
+
16
+ ### Bug Fixes
17
+
18
+ * **aws:** stamp union member types on the deployed projection path ([140251a](https://github.com/ReventlessDev/reventless-core/commit/140251ac7c18f484be32e8683a725f6fac2b9519))
19
+
20
+
6
21
  # 3.0.0-alpha.316 (2026-08-20)
7
22
 
8
23
  **Note:** Version bump only for package @reventlessdev/reventless-aws
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.316",
3
+ "version": "3.0.0-alpha.318",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -10,24 +10,24 @@
10
10
  "@aws-sdk/client-location": "3.970.0",
11
11
  "@aws-sdk/client-s3": "3.970.0",
12
12
  "@aws-sdk/s3-request-presigner": "3.970.0",
13
- "sury": "11.0.0-rc.1",
13
+ "sury": "11.0.0-rc.2",
14
14
  "uuid": "^13.0.0",
15
15
  "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.12",
16
16
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
17
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
18
- "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.1",
19
18
  "@reventlessdev/rescript-node": "2.0.0-alpha.8",
19
+ "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.2",
20
20
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
21
- "@reventlessdev/reventless-core": "3.0.0-alpha.245",
22
- "@reventlessdev/reventless-infra": "3.0.0-alpha.148",
23
- "@reventlessdev/reventless-spec": "3.0.0-alpha.120",
24
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.109",
25
- "@reventlessdev/reventless-interop": "3.0.0-alpha.32",
26
- "@reventlessdev/rescript-uuid": "2.0.0-alpha.0"
21
+ "@reventlessdev/reventless-core": "3.0.0-alpha.246",
22
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.149",
23
+ "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
24
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.33",
25
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.121",
26
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.110"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
30
- "sury-ppx": "11.0.0-rc.1"
30
+ "sury-ppx": "11.0.0-rc.2"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "rescript": "12.3.0"
@@ -443,10 +443,15 @@ let dispatch = async (
443
443
  items->Array.filter(it => it->argStr(subField)->Option.getOr("") == subVal)
444
444
  | _ => items
445
445
  }
446
+ // The target's own rules, applied to the target's rows — `binding` here is
447
+ // the target's, so `ownerAllows` / `retiredAllows` ask its questions. A
448
+ // nested field takes no `includeRetired` argument, so a retired row never
449
+ // travels through one.
450
+ let allowed = filtered->Array.filter(item => ownerAllows(item) && retiredAllows(item))
446
451
  if payload.multi->Option.getOr(false) {
447
- JSON.Encode.array(filtered)
452
+ JSON.Encode.array(allowed)
448
453
  } else {
449
- switch filtered->Array.get(0) {
454
+ switch allowed->Array.get(0) {
450
455
  | Some(item) => item
451
456
  | None => JSON.Encode.null
452
457
  }
@@ -459,7 +464,11 @@ let dispatch = async (
459
464
  let target = payload.target->Option.getOr(rm)
460
465
  let source = payload.source->Option.getOr(JSON.Encode.null)
461
466
  let ids = source->argStrs(payload.sourceIdsField->Option.getOr(""))
462
- JSON.Encode.array(await binding.pushdowns.byIds(~readModelName=target, ids))
467
+ JSON.Encode.array(
468
+ (await binding.pushdowns.byIds(~readModelName=target, ids))->Array.filter(item =>
469
+ ownerAllows(item) && retiredAllows(item)
470
+ ),
471
+ )
463
472
 
464
473
  | other =>
465
474
  // Fail loudly on unmapped kinds (feature-parity guard, per the B3.2 plan).
@@ -241,7 +241,13 @@ async function dispatch(binding, lookupBindingOpt, payload) {
241
241
  let target = Stdlib_Option.getOr(payload.target, rm);
242
242
  let source = Stdlib_Option.getOr(payload.source, null);
243
243
  let ids$2 = argStrs(source, Stdlib_Option.getOr(payload.sourceIdsField, ""));
244
- return await binding.pushdowns.byIds(target, ids$2);
244
+ return (await binding.pushdowns.byIds(target, ids$2)).filter(item => {
245
+ if (ownerAllows(item)) {
246
+ return retiredAllows(item);
247
+ } else {
248
+ return false;
249
+ }
250
+ });
245
251
  case "resolveOne" :
246
252
  let target$1 = Stdlib_Option.getOr(payload.target, rm);
247
253
  let source$1 = Stdlib_Option.getOr(payload.source, null);
@@ -265,10 +271,17 @@ async function dispatch(binding, lookupBindingOpt, payload) {
265
271
  } else {
266
272
  filtered = items$2;
267
273
  }
274
+ let allowed = filtered.filter(item => {
275
+ if (ownerAllows(item)) {
276
+ return retiredAllows(item);
277
+ } else {
278
+ return false;
279
+ }
280
+ });
268
281
  if (Stdlib_Option.getOr(payload.multi, false)) {
269
- return filtered;
282
+ return allowed;
270
283
  }
271
- let item = filtered[0];
284
+ let item = allowed[0];
272
285
  if (item !== undefined) {
273
286
  return item;
274
287
  } else {
@@ -522,6 +522,32 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
522
522
  None
523
523
  }
524
524
 
525
+ // The GraphQL type the cross-table fields hang off. `name` is the QueryDb's
526
+ // own (capitalised spec) name; the SDL calls the type by its plugin-prefixed
527
+ // name, and a resolver naming the other one is refused at deploy with
528
+ // "No field named X found on type Y".
529
+ let parentTypeName = switch registryEntry {
530
+ | Some({returnTypeName}) => returnTypeName
531
+ | None => name
532
+ }
533
+
534
+ // A cross-table field hands back rows from the TARGET's table, so the owner
535
+ // and retirement rules that apply are the target's. Read from the same
536
+ // registry this view reads its own from — populated for every queryable in
537
+ // the plugin before any resolver is built.
538
+ let targetScope = (targetName: string) => {
539
+ let schema = ReventlessCore.Plugin_Helpers.stateSchemaRegistry->Dict.get(targetName)
540
+ let retired =
541
+ schema
542
+ ->Option.flatMap(Reventless.StateAnnotations.getSpec)
543
+ ->Option.flatMap(spec => spec.retired)
544
+ (
545
+ schema->Option.flatMap(s => Reventless.Owner.fieldNames(s)->Array.get(0)),
546
+ retired->Option.map(r => r.field),
547
+ retired->Option.flatMap(r => r.values),
548
+ )
549
+ }
550
+
525
551
  let idResolvers = idResolverConfigs->Array.map(config => {
526
552
  let {
527
553
  source: {idField: sourceIdField, subId: sourceSubId, resolvedField},
@@ -533,71 +559,100 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
533
559
  | _ => ("", "")
534
560
  }
535
561
  let storageResource = storageResource(~pluginName=target.pluginName, ~tableName)
536
- switch resolvedField {
537
- | Single(field)
538
- | Multi(field) =>
539
- let dataSourceName =
540
- DataSource.makeDynamoDBDataSourceWithTableName(
541
- ~name=name ++ (field->String.capitalize ++ "Resolver"),
542
- ~api,
543
- ~tableName=storageResource.name,
544
- ~serviceRole=apiRole,
545
- ~opts,
546
- ).name->Pulumi.Output.asInput
547
-
548
- Resolver.makeUnitJsResolver(
549
- ~name=name ++ field->String.capitalize,
562
+ let (targetOwnerField, targetRetiredField, targetRetiredValues) = targetScope(tableName)
563
+ let (field, multi) = switch resolvedField {
564
+ | Single(field) => (field, false)
565
+ | Multi(field) => (field, true)
566
+ }
567
+ let response = Resolver.Functions.resolvedFieldResponse(
568
+ ~multi,
569
+ ~ownerField=targetOwnerField,
570
+ ~elevatedGroups,
571
+ ~retiredField=?targetRetiredField,
572
+ ~retiredValues=?targetRetiredValues,
573
+ )
574
+ let dataSourceName =
575
+ DataSource.makeDynamoDBDataSourceWithTableName(
576
+ ~name=name ++ (field->String.capitalize ++ "Resolver"),
550
577
  ~api,
551
- ~dataSourceName,
552
- ~type_=name->Pulumi.Input.make,
553
- ~field=field->Pulumi.Input.make,
554
- ~code=switch (targetId, sourceSubId, target.subIdField) {
555
- | (Id, Field(sourceSortField), Some(targetSortField)) =>
556
- Resolver.Functions.resolveIdSort(~sourceIdField, ~sourceSortField, ~targetSortField)
557
- | (Id, Argument(sourceSortArgument), Some(targetSortField)) =>
558
- Resolver.Functions.resolveIdSortArgument(
559
- ~sourceIdField,
560
- ~sourceSortArgument,
561
- ~targetSortField,
562
- )
563
- | (Id, _, _) => Resolver.Functions.resolveId(~sourceIdField)
564
-
565
- | (_, Field(sourceSortField), Some(targetSortField)) =>
566
- Resolver.Functions.resolveIdByIndexSort(
567
- ~index,
568
- ~sourceIdField,
569
- ~targetIdField,
570
- ~sourceSortField,
571
- ~targetSortField,
572
- )
573
- | (_, Argument(sourceSortArgument), Some(targetSortField)) =>
574
- Resolver.Functions.resolveIdByIndexSortArgument(
575
- ~index,
576
- ~sourceIdField,
577
- ~targetIdField,
578
- ~sourceSortArgument,
579
- ~targetSortField,
580
- )
581
- | _ => Resolver.Functions.resolveIdByIndex(~index, ~sourceIdField, ~targetIdField)
582
- },
578
+ ~tableName=storageResource.name,
579
+ ~serviceRole=apiRole,
583
580
  ~opts,
584
- )
585
- }
581
+ ).name->Pulumi.Output.asInput
582
+
583
+ Resolver.makeUnitJsResolver(
584
+ ~name=name ++ field->String.capitalize,
585
+ ~api,
586
+ ~dataSourceName,
587
+ ~type_=parentTypeName->Pulumi.Input.make,
588
+ ~field=field->Pulumi.Input.make,
589
+ ~code=switch (targetId, sourceSubId, target.subIdField) {
590
+ | (Id, Field(sourceSortField), Some(targetSortField)) =>
591
+ Resolver.Functions.resolveIdSort(
592
+ ~sourceIdField,
593
+ ~sourceSortField,
594
+ ~targetSortField,
595
+ ~response,
596
+ )
597
+ | (Id, Argument(sourceSortArgument), Some(targetSortField)) =>
598
+ Resolver.Functions.resolveIdSortArgument(
599
+ ~sourceIdField,
600
+ ~sourceSortArgument,
601
+ ~targetSortField,
602
+ ~response,
603
+ )
604
+ | (Id, _, _) => Resolver.Functions.resolveId(~sourceIdField, ~response)
605
+
606
+ | (_, Field(sourceSortField), Some(targetSortField)) =>
607
+ Resolver.Functions.resolveIdByIndexSort(
608
+ ~index,
609
+ ~sourceIdField,
610
+ ~targetIdField,
611
+ ~sourceSortField,
612
+ ~targetSortField,
613
+ ~response,
614
+ )
615
+ | (_, Argument(sourceSortArgument), Some(targetSortField)) =>
616
+ Resolver.Functions.resolveIdByIndexSortArgument(
617
+ ~index,
618
+ ~sourceIdField,
619
+ ~targetIdField,
620
+ ~sourceSortArgument,
621
+ ~targetSortField,
622
+ ~response,
623
+ )
624
+ | _ =>
625
+ Resolver.Functions.resolveIdByIndex(~index, ~sourceIdField, ~targetIdField, ~response)
626
+ },
627
+ ~opts,
628
+ )
586
629
  })
587
630
 
631
+ // `@resolvesMany` — BatchGetItem against the target's table. Runs on THIS
632
+ // view's data source rather than one of the target's: the tables map names
633
+ // the target explicitly, and every QueryDb in the plugin has already granted
634
+ // `dynamodb:*` on itself to the API role the data source assumes.
588
635
  let idsResolvers = idsResolverConfigs->Array.map(config => {
589
636
  let {source: {idsField, resolvedField}, target: {tableName} as target} = config
590
637
  let storageResource = storageResource(~pluginName=target.pluginName, ~tableName)
638
+ let (targetOwnerField, targetRetiredField, targetRetiredValues) = targetScope(tableName)
591
639
 
592
640
  Resolver.makeUnitJsResolver(
593
- ~name=name ++ idsField->String.capitalize,
641
+ ~name=name ++ resolvedField->String.capitalize,
594
642
  ~api,
595
643
  ~dataSourceName,
596
- ~type_=name->Pulumi.Input.make,
644
+ ~type_=parentTypeName->Pulumi.Input.make,
597
645
  ~field=resolvedField->Pulumi.Input.make,
598
646
  ~code=generateCode(
599
647
  ~storageResource,
600
- ~template=Resolver.Functions.resolveIds(~idsField, ~sortField=target.subIdField, ...),
648
+ ~template=Resolver.Functions.resolveIds(
649
+ ~idsField,
650
+ ~sortField=target.subIdField,
651
+ ~ownerField=?targetOwnerField,
652
+ ~retiredField=?targetRetiredField,
653
+ ~retiredValues=?targetRetiredValues,
654
+ ~elevatedGroups,
655
+ ),
601
656
  ),
602
657
  ~opts,
603
658
  )
@@ -171,10 +171,22 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
171
171
  } else {
172
172
  resolverRefs = undefined;
173
173
  }
174
+ let parentTypeName = registryEntry !== undefined ? registryEntry.returnTypeName : name$1;
175
+ let targetScope = targetName => {
176
+ let schema = Plugin_Helpers$ReventlessCore.stateSchemaRegistry[targetName];
177
+ let retired = Stdlib_Option.flatMap(Stdlib_Option.flatMap(schema, StateAnnotations$Reventless.getSpec), spec => spec.retired);
178
+ return [
179
+ Stdlib_Option.flatMap(schema, s => Owner$Reventless.fieldNames(s)[0]),
180
+ Stdlib_Option.map(retired, r => r.field),
181
+ Stdlib_Option.flatMap(retired, r => r.values)
182
+ ];
183
+ };
174
184
  let idResolvers = idResolverConfigs.map(config => {
175
185
  let target = config.target;
176
186
  let targetId = target.idField;
187
+ let tableName = target.tableName;
177
188
  let match = config.source;
189
+ let resolvedField = match.resolvedField;
178
190
  let sourceSubId = match.subId;
179
191
  let sourceIdField = match.idField;
180
192
  let match$1;
@@ -197,40 +209,62 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
197
209
  }
198
210
  let targetIdField = match$1[1];
199
211
  let index$1 = match$1[0];
200
- let storageResource$1 = storageResource(target.pluginName, target.tableName);
201
- let field = match.resolvedField._0;
212
+ let storageResource$1 = storageResource(target.pluginName, tableName);
213
+ let match$2 = targetScope(tableName);
214
+ let match$3;
215
+ match$3 = resolvedField.TAG === "Single" ? [
216
+ resolvedField._0,
217
+ false
218
+ ] : [
219
+ resolvedField._0,
220
+ true
221
+ ];
222
+ let field = match$3[0];
223
+ let response = AppSync_Resolver_Functions$PulumiAws.resolvedFieldResponse(match$3[1], match$2[0], elevatedGroups, match$2[1], match$2[2]);
202
224
  let dataSourceName = AppSync_DataSource$PulumiAws.makeDynamoDBDataSourceWithTableName(name$1 + (Stdlib_String.capitalize(field) + "Resolver"), api, storageResource$1.name, apiRole, opts).name;
203
- let match$2 = target.subIdField;
225
+ let match$4 = target.subIdField;
204
226
  let tmp;
205
227
  let exit = 0;
228
+ let exit$1 = 0;
206
229
  if (typeof targetId !== "object") {
207
- tmp = typeof sourceSubId !== "object" ? AppSync_Resolver_Functions$PulumiAws.resolveId(sourceIdField) : (
230
+ tmp = typeof sourceSubId !== "object" ? AppSync_Resolver_Functions$PulumiAws.resolveId(sourceIdField, response) : (
208
231
  sourceSubId.TAG === "Field" ? (
209
- match$2 !== undefined ? AppSync_Resolver_Functions$PulumiAws.resolveIdSort(sourceIdField, sourceSubId._0, match$2) : AppSync_Resolver_Functions$PulumiAws.resolveId(sourceIdField)
232
+ match$4 !== undefined ? AppSync_Resolver_Functions$PulumiAws.resolveIdSort(sourceIdField, sourceSubId._0, match$4, response) : AppSync_Resolver_Functions$PulumiAws.resolveId(sourceIdField, response)
210
233
  ) : (
211
- match$2 !== undefined ? AppSync_Resolver_Functions$PulumiAws.resolveIdSortArgument(sourceIdField, sourceSubId._0, match$2) : AppSync_Resolver_Functions$PulumiAws.resolveId(sourceIdField)
234
+ match$4 !== undefined ? AppSync_Resolver_Functions$PulumiAws.resolveIdSortArgument(sourceIdField, sourceSubId._0, match$4, response) : AppSync_Resolver_Functions$PulumiAws.resolveId(sourceIdField, response)
212
235
  )
213
236
  );
214
237
  } else {
215
- exit = 1;
238
+ exit$1 = 2;
239
+ }
240
+ if (exit$1 === 2) {
241
+ if (typeof sourceSubId !== "object") {
242
+ exit = 1;
243
+ } else if (sourceSubId.TAG === "Field") {
244
+ if (match$4 !== undefined) {
245
+ tmp = AppSync_Resolver_Functions$PulumiAws.resolveIdByIndexSort(index$1, sourceIdField, sourceSubId._0, targetIdField, match$4, response);
246
+ } else {
247
+ exit = 1;
248
+ }
249
+ } else if (match$4 !== undefined) {
250
+ tmp = AppSync_Resolver_Functions$PulumiAws.resolveIdByIndexSortArgument(index$1, sourceIdField, sourceSubId._0, targetIdField, match$4, response);
251
+ } else {
252
+ exit = 1;
253
+ }
216
254
  }
217
255
  if (exit === 1) {
218
- tmp = typeof sourceSubId !== "object" ? AppSync_Resolver_Functions$PulumiAws.resolveIdByIndex(index$1, sourceIdField, targetIdField) : (
219
- sourceSubId.TAG === "Field" ? (
220
- match$2 !== undefined ? AppSync_Resolver_Functions$PulumiAws.resolveIdByIndexSort(index$1, sourceIdField, sourceSubId._0, targetIdField, match$2) : AppSync_Resolver_Functions$PulumiAws.resolveIdByIndex(index$1, sourceIdField, targetIdField)
221
- ) : (
222
- match$2 !== undefined ? AppSync_Resolver_Functions$PulumiAws.resolveIdByIndexSortArgument(index$1, sourceIdField, sourceSubId._0, targetIdField, match$2) : AppSync_Resolver_Functions$PulumiAws.resolveIdByIndex(index$1, sourceIdField, targetIdField)
223
- )
224
- );
256
+ tmp = AppSync_Resolver_Functions$PulumiAws.resolveIdByIndex(index$1, sourceIdField, targetIdField, response);
225
257
  }
226
- return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(field), api, dataSourceName, name$1, field, tmp, opts);
258
+ return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(field), api, dataSourceName, parentTypeName, field, tmp, opts);
227
259
  });
228
260
  let idsResolvers = idsResolverConfigs.map(config => {
229
261
  let target = config.target;
262
+ let tableName = target.tableName;
230
263
  let match = config.source;
231
- let idsField = match.idsField;
232
- let storageResource$1 = storageResource(target.pluginName, target.tableName);
233
- return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(idsField), api, dataSourceName, name$1, match.resolvedField, generateCode(storageResource$1, none => AppSync_Resolver_Functions$PulumiAws.resolveIds(none, idsField, target.subIdField)), opts);
264
+ let resolvedField = match.resolvedField;
265
+ let storageResource$1 = storageResource(target.pluginName, tableName);
266
+ let match$1 = targetScope(tableName);
267
+ return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(resolvedField), api, dataSourceName, parentTypeName, resolvedField, generateCode(storageResource$1, AppSync_Resolver_Functions$PulumiAws.resolveIds(match.idsField, target.subIdField, match$1[0], match$1[1], match$1[2], elevatedGroups)), opts);
234
268
  });
235
269
  let mainResolvers = resolverByIdMultiple !== undefined ? (
236
270
  resolverByIds !== undefined ? [
@@ -231,6 +231,14 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
231
231
  }
232
232
  })
233
233
 
234
+ // The GraphQL type the cross-table fields hang off — the plugin-prefixed SDL
235
+ // name, not the QueryDb's own. A resolver naming the other one is refused at
236
+ // deploy with "No field named X found on type Y".
237
+ let parentTypeName = switch registryEntry {
238
+ | Some({returnTypeName}) => returnTypeName
239
+ | None => name
240
+ }
241
+
234
242
  // @resolves — single cross-table field resolver on this (parent) type. The
235
243
  // target's binding key is its capitalized spec name (mirrors how the binding
236
244
  // registry is keyed); the parent object flows via ctx.source.
@@ -259,7 +267,7 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
259
267
  ~name=name ++ field->String.capitalize,
260
268
  ~api,
261
269
  ~dataSourceName,
262
- ~type_=name->Pulumi.Input.make,
270
+ ~type_=parentTypeName->Pulumi.Input.make,
263
271
  ~field=field->Pulumi.Input.make,
264
272
  ~code=invokeFieldTemplate(~sourceType=name, ~kind="resolveOne", ~target=targetKey, ~extra),
265
273
  ~opts,
@@ -275,7 +283,7 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
275
283
  ~name=name ++ resolvedField->String.capitalize,
276
284
  ~api,
277
285
  ~dataSourceName,
278
- ~type_=name->Pulumi.Input.make,
286
+ ~type_=parentTypeName->Pulumi.Input.make,
279
287
  ~field=resolvedField->Pulumi.Input.make,
280
288
  ~code=invokeFieldTemplate(~sourceType=name, ~kind="resolveMany", ~target=targetKey, ~extra),
281
289
  ~opts,
@@ -107,6 +107,7 @@ function make(name, api, param, dataSourceName, indexes, subIdField, idResolverC
107
107
  return mkResolver(resolverName, field, "index", index, undefined, undefined);
108
108
  }
109
109
  });
110
+ let parentTypeName = registryEntry !== undefined ? registryEntry.returnTypeName : name$1;
110
111
  let idResolvers = idResolverConfigs.map(config => {
111
112
  let target = config.target;
112
113
  let match = config.source;
@@ -139,14 +140,14 @@ function make(name, api, param, dataSourceName, indexes, subIdField, idResolverC
139
140
  let extra = `\n sourceIdField: '` + match.idField + `',\n multi: ` + (
140
141
  match$1[1] ? "true" : "false"
141
142
  ) + `,` + targetFrag + subIdFrag;
142
- return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(field), api, dataSourceName, name$1, field, invokeFieldTemplate(name$1, "resolveOne", targetKey, extra), opts);
143
+ return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(field), api, dataSourceName, parentTypeName, field, invokeFieldTemplate(name$1, "resolveOne", targetKey, extra), opts);
143
144
  });
144
145
  let idsResolvers = idsResolverConfigs.map(config => {
145
146
  let match = config.source;
146
147
  let resolvedField = match.resolvedField;
147
148
  let targetKey = Stdlib_String.capitalize(config.target.tableName);
148
149
  let extra = `\n sourceIdsField: '` + match.idsField + `',`;
149
- return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(resolvedField), api, dataSourceName, name$1, resolvedField, invokeFieldTemplate(name$1, "resolveMany", targetKey, extra), opts);
150
+ return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(resolvedField), api, dataSourceName, parentTypeName, resolvedField, invokeFieldTemplate(name$1, "resolveMany", targetKey, extra), opts);
150
151
  });
151
152
  return [
152
153
  byId,
@@ -55,6 +55,37 @@ external withLiveUpdates: (
55
55
  liveConfig,
56
56
  ) => ReventlessCore.QueryDb_Adapter.operations = "withLiveUpdates"
57
57
 
58
+ /**
59
+ Stamps every union value in a saved row with the GraphQL member type it is.
60
+
61
+ `QueryDb_Operations.Make` does this for the typed path — the local platform and
62
+ the storage tests — but the deployed projection Lambdas do not go through that
63
+ functor: they assemble JSON-level operations here. Without this the stamp never
64
+ ran on AWS, every union member resolved to null, and the null took its
65
+ non-nullable parent, so rows vanished from a view rather than erroring.
66
+
67
+ `stateSchema` is `None` for a spec module that predates it, which leaves rows
68
+ exactly as they were.
69
+ */
70
+ let withUnionMemberTypes = (
71
+ base: ReventlessCore.QueryDb_Adapter.operations,
72
+ ~stateSchema: option<S.t<unknown>>,
73
+ ): ReventlessCore.QueryDb_Adapter.operations =>
74
+ switch stateSchema {
75
+ | None => base
76
+ | Some(schema) =>
77
+ let stamp = (state: JSON.t) => {
78
+ Reventless.TaggedUnion.stampInto(~schema, state)
79
+ state
80
+ }
81
+ {
82
+ ...base,
83
+ save: (id, state, saveMode, ttl) => base.save(id, stamp(state), saveMode, ttl),
84
+ saveBatch: items =>
85
+ base.saveBatch(items->Array.map(((id, state, ttl)) => (id, stamp(state), ttl))),
86
+ }
87
+ }
88
+
58
89
  let makeQueryDbOps = (
59
90
  ~queryDbTableName: string,
60
91
  ~pgConnection: option<PgConnection.connectionConfig>,
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
4
5
  import * as StateTopicPublishMjs from "./StateTopicPublish.mjs";
5
6
  import * as QueryDbEntryPoint_Ops$ReventlessAws from "./QueryDbEntryPoint_Ops.res.mjs";
6
7
  import * as QueryDbStorage_Postgres_Runtime$ReventlessAws from "../QueryDb/QueryDbStorage_Postgres_Runtime.res.mjs";
@@ -17,6 +18,29 @@ function withLiveUpdates(prim0, prim1) {
17
18
  return StateTopicPublishMjs.withLiveUpdates(prim0, prim1);
18
19
  }
19
20
 
21
+ function withUnionMemberTypes(base, stateSchema) {
22
+ if (stateSchema !== undefined) {
23
+ return {
24
+ load: base.load,
25
+ loadStream: base.loadStream,
26
+ save: (id, state, saveMode, ttl) => base.save(id, (TaggedUnion$Reventless.stampInto(stateSchema, state), state), saveMode, ttl),
27
+ saveBatch: items => base.saveBatch(items.map(param => {
28
+ let state = param[1];
29
+ return [
30
+ param[0],
31
+ (TaggedUnion$Reventless.stampInto(stateSchema, state), state),
32
+ param[2]
33
+ ];
34
+ })),
35
+ count: base.count,
36
+ delete: base.delete,
37
+ deleteBatch: base.deleteBatch
38
+ };
39
+ } else {
40
+ return base;
41
+ }
42
+ }
43
+
20
44
  function makeQueryDbOps(queryDbTableName, pgConnection, stateTopicName, indexes, subIdField) {
21
45
  if (pgConnection !== undefined) {
22
46
  return StateTopicPublishMjs.withLiveUpdates(QueryDbStorage_Postgres_Runtime$ReventlessAws.opsFor(pgConnection, queryDbTableName, indexes, subIdField), {
@@ -34,6 +58,7 @@ export {
34
58
  indexesOf,
35
59
  subIdFieldOf,
36
60
  withLiveUpdates,
61
+ withUnionMemberTypes,
37
62
  makeQueryDbOps,
38
63
  }
39
- /* ./StateTopicPublish.mjs Not a pure module */
64
+ /* TaggedUnion-Reventless Not a pure module */
@@ -47,6 +47,7 @@ async function buildAllHandlers() {
47
47
  const operations = Ops.buildOperations(entry, {
48
48
  config: specModule.config,
49
49
  subIdConfig: specModule.subIdConfig,
50
+ stateSchema: specModule.stateSchema,
50
51
  });
51
52
  const callback = readModelCallbackMake(specModule)(mappingsModule)({
52
53
  ReadModelSpec: specModule,
@@ -90,6 +90,8 @@ let withInjectedId = (
90
90
  type specInfo = {
91
91
  config: option<ProjectionEntryPoint_Ops.specConfig>,
92
92
  subIdConfig: option<Reventless.ReadModel.subIdConfig<JSON.t>>,
93
+ // Read off the runtime-loaded spec module; `None` on a module that predates it.
94
+ stateSchema: option<S.t<unknown>>,
93
95
  }
94
96
 
95
97
  let buildOperations = (
@@ -102,7 +104,9 @@ let buildOperations = (
102
104
  ~stateTopicName=entry.stateTopicName,
103
105
  ~indexes=ProjectionEntryPoint_Ops.indexesOf(spec.config),
104
106
  ~subIdField=ProjectionEntryPoint_Ops.subIdFieldOf(spec.subIdConfig),
105
- )->withInjectedId
107
+ )
108
+ ->ProjectionEntryPoint_Ops.withUnionMemberTypes(~stateSchema=spec.stateSchema)
109
+ ->withInjectedId
106
110
 
107
111
  let makeRegisteredHandler = (
108
112
  entry: handlerEntry,
@@ -60,7 +60,7 @@ function withInjectedId(base) {
60
60
  }
61
61
 
62
62
  function buildOperations(entry, spec) {
63
- return withInjectedId(ProjectionEntryPoint_Ops$ReventlessAws.makeQueryDbOps(entry.queryDbTableName, entry.pgConnection, entry.stateTopicName, ProjectionEntryPoint_Ops$ReventlessAws.indexesOf(spec.config), ProjectionEntryPoint_Ops$ReventlessAws.subIdFieldOf(spec.subIdConfig)));
63
+ return withInjectedId(ProjectionEntryPoint_Ops$ReventlessAws.withUnionMemberTypes(ProjectionEntryPoint_Ops$ReventlessAws.makeQueryDbOps(entry.queryDbTableName, entry.pgConnection, entry.stateTopicName, ProjectionEntryPoint_Ops$ReventlessAws.indexesOf(spec.config), ProjectionEntryPoint_Ops$ReventlessAws.subIdFieldOf(spec.subIdConfig)), spec.stateSchema));
64
64
  }
65
65
 
66
66
  function makeRegisteredHandler(entry, handleJsonEvents) {
@@ -42,6 +42,7 @@ async function buildAllHandlers() {
42
42
  project: projectionModule.project,
43
43
  config: specModule.config,
44
44
  subIdConfig: specModule.subIdConfig,
45
+ stateSchema: specModule.stateSchema,
45
46
  })
46
47
  );
47
48
  }));
@@ -176,6 +176,8 @@ type sliceModules<'event> = {
176
176
  >,
177
177
  config: option<ProjectionEntryPoint_Ops.specConfig>,
178
178
  subIdConfig: option<Reventless.ReadModel.subIdConfig<JSON.t>>,
179
+ // Same union stamp the read-model path needs; a slice's state can hold one too.
180
+ stateSchema: option<S.t<unknown>>,
179
181
  }
180
182
 
181
183
  let makeRegisteredHandler = (
@@ -188,7 +190,7 @@ let makeRegisteredHandler = (
188
190
  ~stateTopicName=entry.stateTopicName,
189
191
  ~indexes=ProjectionEntryPoint_Ops.indexesOf(modules.config),
190
192
  ~subIdField=ProjectionEntryPoint_Ops.subIdFieldOf(modules.subIdConfig),
191
- )
193
+ )->ProjectionEntryPoint_Ops.withUnionMemberTypes(~stateSchema=modules.stateSchema)
192
194
  {
193
195
  handler: StreamRoutedEntryPoint_Ops.toStreamHandler(
194
196
  makeJsonEventsHandler(
@@ -84,7 +84,7 @@ function makeJsonEventsHandler(sliceName, eventSchema, project, queryDbOps, subI
84
84
  }
85
85
 
86
86
  function makeRegisteredHandler(entry, modules) {
87
- let queryDbOps = ProjectionEntryPoint_Ops$ReventlessAws.makeQueryDbOps(entry.queryDbTableName, entry.pgConnection, entry.stateTopicName, ProjectionEntryPoint_Ops$ReventlessAws.indexesOf(modules.config), ProjectionEntryPoint_Ops$ReventlessAws.subIdFieldOf(modules.subIdConfig));
87
+ let queryDbOps = ProjectionEntryPoint_Ops$ReventlessAws.withUnionMemberTypes(ProjectionEntryPoint_Ops$ReventlessAws.makeQueryDbOps(entry.queryDbTableName, entry.pgConnection, entry.stateTopicName, ProjectionEntryPoint_Ops$ReventlessAws.indexesOf(modules.config), ProjectionEntryPoint_Ops$ReventlessAws.subIdFieldOf(modules.subIdConfig)), modules.stateSchema);
88
88
  return {
89
89
  handler: StreamRoutedEntryPoint_Ops$ReventlessAws.toStreamHandler(makeJsonEventsHandler(modules.name, modules.consumedEventSchema, modules.project, queryDbOps, modules.subIdConfig)),
90
90
  comp: `StateViewSlice(` + modules.name + `)`
@@ -308,3 +308,81 @@ describe("the unowned stub is callable where the door calls it", () => {
308
308
  ))->Expect.toEqual((true, true))
309
309
  })
310
310
  })
311
+
312
+ // ── The cross-table doors (`@resolves` / `@resolvesMany`) ────────────────────
313
+ //
314
+ // A field that follows a foreign key hands back the TARGET's rows, so the
315
+ // target's rules narrow them. The field takes no `includeRetired` argument, so
316
+ // `_wantsRetired` is never true through one — `{list}Refs` is the door that
317
+ // names a row the archive took.
318
+
319
+ describe("the cross-table batch door", () => {
320
+ let code = F.resolveIds(
321
+ ~idsField="productIds",
322
+ ~sortField=None,
323
+ ~retiredField="shelfStatus",
324
+ ~retiredValues=["Archived"],
325
+ ~elevatedGroups=elevated,
326
+ )("ProductsTable")
327
+
328
+ // BatchGetItem answers `{data: {<table>: [...]}}`; returning `ctx.result`
329
+ // handed the whole envelope to a field typed as a list, which fails at
330
+ // execution for every caller that selects it.
331
+ testSync("returns the rows out of the batch envelope", () =>
332
+ expect(code->String.includes("ctx.result?.data?.['ProductsTable']"))->Expect.toBe(true)
333
+ )
334
+
335
+ testSync("drops retired rows from the returned array", () =>
336
+ expect(code->String.includes("item !== null && _live(item)"))->Expect.toBe(true)
337
+ )
338
+
339
+ // An empty id array used to fall through to a GetItem on the PARENT's own id
340
+ // against the target's table — a read that answers a shape the field cannot be.
341
+ testSync("short-circuits an empty id array instead of reading", () =>
342
+ expect((
343
+ code->String.includes("if (idList.length === 0) return runtime.earlyReturn([]);"),
344
+ code->String.includes("operation: 'GetItem'"),
345
+ ))->toEqual((true, false))
346
+ )
347
+
348
+ testSync("is unchanged for a target that declares neither rule", () => {
349
+ let plain = F.resolveIds(~idsField="productIds", ~sortField=None)("ProductsTable")
350
+ expect((plain->String.includes("_owns"), plain->String.includes("_live")))->toEqual((
351
+ false,
352
+ false,
353
+ ))
354
+ })
355
+ })
356
+
357
+ describe("the cross-table single door", () => {
358
+ let response = (~multi) =>
359
+ F.resolvedFieldResponse(
360
+ ~multi,
361
+ ~ownerField=Some("customerId"),
362
+ ~elevatedGroups=elevated,
363
+ ~retiredField="shelfStatus",
364
+ ~retiredValues=["Archived"],
365
+ )
366
+
367
+ testSync("narrows the one row it hands back", () =>
368
+ expect(response(~multi=false)->String.includes("_owns(_row) && _live(_row) ? _row : null"))
369
+ ->Expect.toBe(true)
370
+ )
371
+
372
+ testSync("narrows every row of the list form", () =>
373
+ expect(
374
+ response(~multi=true)->String.includes("filter(_row => _owns(_row) && _live(_row))"),
375
+ )->Expect.toBe(true)
376
+ )
377
+
378
+ // The list form used to answer with the first row alone, whatever the SDL said.
379
+ testSync("the list form returns the list", () =>
380
+ expect(response(~multi=true)->String.includes("ctx.result.items[0]"))->Expect.toBe(false)
381
+ )
382
+
383
+ testSync("a target with neither rule keeps the plain response", () => {
384
+ let plain = F.resolvedFieldResponse(~multi=false, ~ownerField=None, ~elevatedGroups=elevated)
385
+ expect((plain->String.includes("_owns"), plain->String.includes("ctx.result.items[0]")))
386
+ ->toEqual((false, true))
387
+ })
388
+ })
@@ -206,6 +206,58 @@ globalThis.describe("the unowned stub is callable where the door calls it", () =
206
206
  });
207
207
  });
208
208
 
209
+ globalThis.describe("the cross-table batch door", () => {
210
+ let code = AppSync_Resolver_Functions$PulumiAws.resolveIds("productIds", undefined, undefined, "shelfStatus", ["Archived"], elevated)("ProductsTable");
211
+ globalThis.test("returns the rows out of the batch envelope", () => {
212
+ globalThis.expect(code.includes("ctx.result?.data?.['ProductsTable']")).toBe(true);
213
+ });
214
+ globalThis.test("drops retired rows from the returned array", () => {
215
+ globalThis.expect(code.includes("item !== null && _live(item)")).toBe(true);
216
+ });
217
+ globalThis.test("short-circuits an empty id array instead of reading", () => {
218
+ globalThis.expect([
219
+ code.includes("if (idList.length === 0) return runtime.earlyReturn([]);"),
220
+ code.includes("operation: 'GetItem'")
221
+ ]).toEqual([
222
+ true,
223
+ false
224
+ ]);
225
+ });
226
+ globalThis.test("is unchanged for a target that declares neither rule", () => {
227
+ let plain = AppSync_Resolver_Functions$PulumiAws.resolveIds("productIds", undefined, undefined, undefined, undefined, undefined)("ProductsTable");
228
+ globalThis.expect([
229
+ plain.includes("_owns"),
230
+ plain.includes("_live")
231
+ ]).toEqual([
232
+ false,
233
+ false
234
+ ]);
235
+ });
236
+ });
237
+
238
+ globalThis.describe("the cross-table single door", () => {
239
+ let response = multi => AppSync_Resolver_Functions$PulumiAws.resolvedFieldResponse(multi, "customerId", elevated, "shelfStatus", ["Archived"]);
240
+ globalThis.test("narrows the one row it hands back", () => {
241
+ globalThis.expect(response(false).includes("_owns(_row) && _live(_row) ? _row : null")).toBe(true);
242
+ });
243
+ globalThis.test("narrows every row of the list form", () => {
244
+ globalThis.expect(response(true).includes("filter(_row => _owns(_row) && _live(_row))")).toBe(true);
245
+ });
246
+ globalThis.test("the list form returns the list", () => {
247
+ globalThis.expect(response(true).includes("ctx.result.items[0]")).toBe(false);
248
+ });
249
+ globalThis.test("a target with neither rule keeps the plain response", () => {
250
+ let plain = AppSync_Resolver_Functions$PulumiAws.resolvedFieldResponse(false, undefined, elevated, undefined, undefined);
251
+ globalThis.expect([
252
+ plain.includes("_owns"),
253
+ plain.includes("ctx.result.items[0]")
254
+ ]).toEqual([
255
+ false,
256
+ true
257
+ ]);
258
+ });
259
+ });
260
+
209
261
  let F;
210
262
 
211
263
  export {
@@ -0,0 +1,88 @@
1
+ // The deployed projection Lambdas assemble JSON-level QueryDb operations rather
2
+ // than going through `QueryDb_Operations.Make`, so the union `__typename` stamp
3
+ // that functor applies never ran on AWS. Every union member resolved to null and
4
+ // took its non-nullable parent with it — rows vanished from a view instead of
5
+ // erroring, on every read door, with nothing reporting it.
6
+ //
7
+ // The typed path was covered from the start; this side was not, which is the
8
+ // whole reason it shipped.
9
+
10
+ open JestGlobals
11
+
12
+ @schema
13
+ type geolocation =
14
+ | Pending({requestedFor: string})
15
+ | Located({lat: float, lng: float})
16
+
17
+ let geolocationSchema = Reventless.TaggedUnion.named(~name="Geolocation", geolocationSchema)
18
+
19
+ @schema
20
+ type rowState = {customerId: string, geolocation: geolocation}
21
+
22
+ // A recording stand-in for the storage the runtime would wrap.
23
+ let capture = (): (ReventlessCore.QueryDb_Adapter.operations, array<JSON.t>) => {
24
+ let saved: array<JSON.t> = []
25
+ let base: ReventlessCore.QueryDb_Adapter.operations = {
26
+ load: _ => Promise.resolve(Ok([])),
27
+ loadStream: _ => Stream.empty,
28
+ save: (_id, state, _saveMode, _ttl) => {
29
+ saved->Array.push(state)
30
+ Promise.resolve(Ok())
31
+ },
32
+ saveBatch: items => {
33
+ items->Array.forEach(((_, state, _)) => saved->Array.push(state))
34
+ Promise.resolve(Ok())
35
+ },
36
+ count: (_, _, _) => Promise.resolve(Ok(0)),
37
+ delete: (_, _) => Promise.resolve(Ok()),
38
+ deleteBatch: _ => Promise.resolve(Ok()),
39
+ }
40
+ (base, saved)
41
+ }
42
+
43
+ let memberOf = (json: JSON.t) =>
44
+ json
45
+ ->JSON.Decode.object
46
+ ->Option.flatMap(o => o->Dict.get("geolocation"))
47
+ ->Option.flatMap(JSON.Decode.object)
48
+ ->Option.flatMap(o => o->Dict.get("__typename"))
49
+ ->Option.flatMap(JSON.Decode.string)
50
+
51
+ let row = (): JSON.t =>
52
+ ({customerId: "c1", geolocation: Located({lat: 1.0, lng: 2.0})}: rowState)
53
+ ->ReventlessCore.Message.encode(rowStateSchema)
54
+
55
+ describe("ProjectionEntryPoint_Ops.withUnionMemberTypes", () => {
56
+ testPromise("stamps the member type on save", async () => {
57
+ let (base, saved) = capture()
58
+ let ops =
59
+ base->ProjectionEntryPoint_Ops.withUnionMemberTypes(
60
+ ~stateSchema=Some(rowStateSchema->S.castToUnknown),
61
+ )
62
+ let _ = await ops.save("c1", row(), ReventlessCore.QueryDb.Any, None)
63
+ expect(saved->Array.get(0)->Option.flatMap(memberOf))->toEqual(Some("GeolocationLocated"))
64
+ })
65
+
66
+ // saveBatch is the path a multi-event projection takes, and it was missing the
67
+ // stamp for the same reason save was.
68
+ testPromise("stamps every row in a batch", async () => {
69
+ let (base, saved) = capture()
70
+ let ops =
71
+ base->ProjectionEntryPoint_Ops.withUnionMemberTypes(
72
+ ~stateSchema=Some(rowStateSchema->S.castToUnknown),
73
+ )
74
+ let _ = await ops.saveBatch([("c1", row(), None), ("c2", row(), None)])
75
+ expect(saved->Array.map(memberOf))->toEqual([
76
+ Some("GeolocationLocated"),
77
+ Some("GeolocationLocated"),
78
+ ])
79
+ })
80
+
81
+ // A spec module without a stateSchema leaves rows untouched rather than throwing.
82
+ testPromise("passes rows through when the spec has no schema", async () => {
83
+ let (base, saved) = capture()
84
+ let ops = base->ProjectionEntryPoint_Ops.withUnionMemberTypes(~stateSchema=None)
85
+ let _ = await ops.save("c1", row(), ReventlessCore.QueryDb.Any, None)
86
+ expect(saved->Array.get(0)->Option.flatMap(memberOf))->toEqual(None)
87
+ })
88
+ })
@@ -0,0 +1,137 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Sury from "sury";
4
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Stream from "effect/Stream";
7
+ import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
8
+ import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
9
+ import * as ProjectionEntryPoint_Ops$ReventlessAws from "../src/adapter/Runtime/ProjectionEntryPoint_Ops.res.mjs";
10
+
11
+ let geolocationSchema = Sury.union([
12
+ Sury.$schema(s => ({
13
+ TAG: "Pending",
14
+ requestedFor: s.m(Sury.string)
15
+ })),
16
+ Sury.$schema(s => ({
17
+ TAG: "Located",
18
+ lat: s.m(Sury.float),
19
+ lng: s.m(Sury.float)
20
+ }))
21
+ ]);
22
+
23
+ let geolocationSchema$1 = TaggedUnion$Reventless.named("Geolocation", geolocationSchema);
24
+
25
+ let rowStateSchema = Sury.$schema(s => ({
26
+ customerId: s.m(Sury.string),
27
+ geolocation: s.m(geolocationSchema$1)
28
+ }));
29
+
30
+ function capture() {
31
+ let saved = [];
32
+ let base_load = param => Promise.resolve({
33
+ TAG: "Ok",
34
+ _0: []
35
+ });
36
+ let base_loadStream = param => Stream.empty;
37
+ let base_save = (_id, state, _saveMode, _ttl) => {
38
+ saved.push(state);
39
+ return Promise.resolve({
40
+ TAG: "Ok",
41
+ _0: undefined
42
+ });
43
+ };
44
+ let base_saveBatch = items => {
45
+ items.forEach(param => {
46
+ saved.push(param[1]);
47
+ });
48
+ return Promise.resolve({
49
+ TAG: "Ok",
50
+ _0: undefined
51
+ });
52
+ };
53
+ let base_count = (param, param$1, param$2) => Promise.resolve({
54
+ TAG: "Ok",
55
+ _0: 0
56
+ });
57
+ let base_delete = (param, param$1) => Promise.resolve({
58
+ TAG: "Ok",
59
+ _0: undefined
60
+ });
61
+ let base_deleteBatch = param => Promise.resolve({
62
+ TAG: "Ok",
63
+ _0: undefined
64
+ });
65
+ let base = {
66
+ load: base_load,
67
+ loadStream: base_loadStream,
68
+ save: base_save,
69
+ saveBatch: base_saveBatch,
70
+ count: base_count,
71
+ delete: base_delete,
72
+ deleteBatch: base_deleteBatch
73
+ };
74
+ return [
75
+ base,
76
+ saved
77
+ ];
78
+ }
79
+
80
+ function memberOf(json) {
81
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => o["geolocation"]), Stdlib_JSON.Decode.object), o => o["__typename"]), Stdlib_JSON.Decode.string);
82
+ }
83
+
84
+ function row() {
85
+ return Message$ReventlessCore.encode({
86
+ customerId: "c1",
87
+ geolocation: {
88
+ TAG: "Located",
89
+ lat: 1.0,
90
+ lng: 2.0
91
+ }
92
+ }, rowStateSchema);
93
+ }
94
+
95
+ globalThis.describe("ProjectionEntryPoint_Ops.withUnionMemberTypes", () => {
96
+ globalThis.test("stamps the member type on save", async () => {
97
+ let match = capture();
98
+ let ops = ProjectionEntryPoint_Ops$ReventlessAws.withUnionMemberTypes(match[0], rowStateSchema);
99
+ await ops.save("c1", row(), "Any", undefined);
100
+ globalThis.expect(Stdlib_Option.flatMap(match[1][0], memberOf)).toEqual("GeolocationLocated");
101
+ });
102
+ globalThis.test("stamps every row in a batch", async () => {
103
+ let match = capture();
104
+ let ops = ProjectionEntryPoint_Ops$ReventlessAws.withUnionMemberTypes(match[0], rowStateSchema);
105
+ await ops.saveBatch([
106
+ [
107
+ "c1",
108
+ row(),
109
+ undefined
110
+ ],
111
+ [
112
+ "c2",
113
+ row(),
114
+ undefined
115
+ ]
116
+ ]);
117
+ globalThis.expect(match[1].map(memberOf)).toEqual([
118
+ "GeolocationLocated",
119
+ "GeolocationLocated"
120
+ ]);
121
+ });
122
+ globalThis.test("passes rows through when the spec has no schema", async () => {
123
+ let match = capture();
124
+ let ops = ProjectionEntryPoint_Ops$ReventlessAws.withUnionMemberTypes(match[0], undefined);
125
+ await ops.save("c1", row(), "Any", undefined);
126
+ globalThis.expect(Stdlib_Option.flatMap(match[1][0], memberOf)).toEqual(undefined);
127
+ });
128
+ });
129
+
130
+ export {
131
+ geolocationSchema$1 as geolocationSchema,
132
+ rowStateSchema,
133
+ capture,
134
+ memberOf,
135
+ row,
136
+ }
137
+ /* geolocationSchema Not a pure module */
@@ -85,6 +85,7 @@ let runOneEvent: (string, JSON.t) => promise<unit> = %raw(`
85
85
  project: projectionModule.project,
86
86
  config: specModule.config,
87
87
  subIdConfig: specModule.subIdConfig,
88
+ stateSchema: specModule.stateSchema,
88
89
  },
89
90
  );
90
91
  const event = {
@@ -146,6 +147,21 @@ describe("StateViewSliceEntryPoint integration", () => {
146
147
  ->Array.toSorted(String.compare)
147
148
  expect(productIds)->toEqual(["prod-a", "prod-b"])
148
149
 
150
+ // The runtime path's union stamp, against real DynamoDB. The deployed
151
+ // Lambdas assemble JSON-level ops and skipped it entirely, so a member
152
+ // resolved to null and took its non-nullable parent with it.
153
+ let members =
154
+ rows
155
+ ->Array.filterMap(row =>
156
+ row
157
+ ->JSON.Decode.object
158
+ ->Option.flatMap(o => o->Dict.get("fulfilment"))
159
+ ->Option.flatMap(JSON.Decode.object)
160
+ ->Option.flatMap(o => o->Dict.get("__typename"))
161
+ ->Option.flatMap(JSON.Decode.string)
162
+ )
163
+ expect(members)->toEqual(["FulfilmentShipped", "FulfilmentShipped"])
164
+
149
165
  await H.deleteTable(table)
150
166
  },
151
167
  )
@@ -94,6 +94,7 @@ let runOneEvent = (async (tableName, eventJson) => {
94
94
  project: projectionModule.project,
95
95
  config: specModule.config,
96
96
  subIdConfig: specModule.subIdConfig,
97
+ stateSchema: specModule.stateSchema,
97
98
  },
98
99
  );
99
100
  const event = {
@@ -138,6 +139,11 @@ globalThis.describe("StateViewSliceEntryPoint integration", () => {
138
139
  "prod-a",
139
140
  "prod-b"
140
141
  ]);
142
+ let members = Stdlib_Array.filterMap(rows$1, row => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(row), o => o["fulfilment"]), Stdlib_JSON.Decode.object), o => o["__typename"]), Stdlib_JSON.Decode.string));
143
+ globalThis.expect(members).toEqual([
144
+ "FulfilmentShipped",
145
+ "FulfilmentShipped"
146
+ ]);
141
147
  return await DcbIntegrationHarness$ReventlessAws.deleteTable(table);
142
148
  });
143
149
  });
@@ -16,11 +16,21 @@
16
16
  @schema
17
17
  type consumedEvent = ItemAddedToCart({cartId: string, productId: string, qty: int})
18
18
 
19
+ // A union on the state, so the integration test covers the runtime path's
20
+ // `__typename` stamp — the one the deployed Lambdas skipped entirely.
21
+ @schema
22
+ type fulfilment =
23
+ | Pending({requestedFor: string})
24
+ | Shipped({carrier: string})
25
+
26
+ let fulfilmentSchema = Reventless.TaggedUnion.named(~name="Fulfilment", fulfilmentSchema)
27
+
19
28
  @schema
20
29
  type state = {
21
30
  cartId: string,
22
31
  productId: string,
23
32
  qty: int,
33
+ fulfilment: fulfilment,
24
34
  }
25
35
 
26
36
  // `Some({subIdField, getSubId})` — the compiled per-slice spec that a real
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Sury from "sury";
4
+ import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
4
5
 
5
6
  let consumedEventSchema = Sury.$schema(s => ({
6
7
  TAG: "ItemAddedToCart",
@@ -9,10 +10,24 @@ let consumedEventSchema = Sury.$schema(s => ({
9
10
  qty: s.m(Sury.int)
10
11
  }));
11
12
 
13
+ let fulfilmentSchema = Sury.union([
14
+ Sury.$schema(s => ({
15
+ TAG: "Pending",
16
+ requestedFor: s.m(Sury.string)
17
+ })),
18
+ Sury.$schema(s => ({
19
+ TAG: "Shipped",
20
+ carrier: s.m(Sury.string)
21
+ }))
22
+ ]);
23
+
24
+ let fulfilmentSchema$1 = TaggedUnion$Reventless.named("Fulfilment", fulfilmentSchema);
25
+
12
26
  let stateSchema = Sury.$schema(s => ({
13
27
  cartId: s.m(Sury.string),
14
28
  productId: s.m(Sury.string),
15
- qty: s.m(Sury.int)
29
+ qty: s.m(Sury.int),
30
+ fulfilment: s.m(fulfilmentSchema$1)
16
31
  }));
17
32
 
18
33
  let subIdConfig = {
@@ -22,6 +37,7 @@ let subIdConfig = {
22
37
 
23
38
  export {
24
39
  consumedEventSchema,
40
+ fulfilmentSchema$1 as fulfilmentSchema,
25
41
  stateSchema,
26
42
  subIdConfig,
27
43
  }
@@ -17,7 +17,9 @@ let project = ({event}: Reventless.StateViewSlice.consumed<consumedEvent>) =>
17
17
  cartId,
18
18
  states => {
19
19
  let others = states->Array.filter(s => s.productId != productId)
20
- others->Array.concat([{cartId, productId, qty}])
20
+ others->Array.concat([
21
+ {cartId, productId, qty, fulfilment: Shipped({carrier: "dhl"})},
22
+ ])
21
23
  },
22
24
  ),
23
25
  ]
@@ -14,7 +14,11 @@ function project(param) {
14
14
  return others.concat([{
15
15
  cartId: cartId,
16
16
  productId: productId,
17
- qty: qty
17
+ qty: qty,
18
+ fulfilment: {
19
+ TAG: "Shipped",
20
+ carrier: "dhl"
21
+ }
18
22
  }]);
19
23
  }
20
24
  }];