@reventlessdev/reventless-aws 3.0.0-alpha.181 → 3.0.0-alpha.182

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.182 (2026-07-07)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **reventless-aws:** fence composite-partition DCB slices on one composite key ([e5f2d95](https://github.com/ReventlessDev/reventless-core/commit/e5f2d95652d795e4dea60e28548f96100a997e78))
11
+
12
+
6
13
  # 3.0.0-alpha.181 (2026-07-07)
7
14
 
8
15
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.181",
3
+ "version": "3.0.0-alpha.182",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -10,15 +10,15 @@
10
10
  "uuid": "^13.0.0",
11
11
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.20",
12
12
  "@reventlessdev/rescript-jest": "1.0.0-alpha.6",
13
- "@reventlessdev/rescript-effect": "0.1.0-alpha.25",
14
13
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.46",
14
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.25",
15
15
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
16
16
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.14",
17
17
  "@reventlessdev/reventless-core": "3.0.0-alpha.144",
18
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.90",
18
19
  "@reventlessdev/reventless-interop": "3.0.0-alpha.24",
19
20
  "@reventlessdev/reventless-spec": "3.0.0-alpha.68",
20
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.8",
21
- "@reventlessdev/reventless-infra": "3.0.0-alpha.90"
21
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.8"
22
22
  },
23
23
  "devDependencies": {
24
24
  "rescript": "^12.3.0",
@@ -3,10 +3,33 @@ open AwsSdk.DynamoDb.DocumentClient
3
3
 
4
4
  // --- Position Generation ---
5
5
 
6
+ // Hybrid-logical-clock minimal variant. The module-level refs live for the life of
7
+ // a warm Lambda container (reset on cold start), giving strictly-monotonic positions
8
+ // per call WITHIN a container: same-millisecond calls increment `counter`; a forward
9
+ // tick resets it to 0. No cross-container coordination — two same-ms writers on
10
+ // different containers both start at counter 0 and are ordered by the UUID tiebreaker
11
+ // (best-effort, exactly as the old `<ms>-<uuid>` format was). Format:
12
+ // `<ms>-<6-digit counter>-<uuid>`. Correctness never depends on this — fence
13
+ // comparisons anchor to what a slice observed, and `TransactWriteItems` serialises
14
+ // commits; this only makes reader/replay ordering predictable per container. Old
15
+ // `<ms>-<uuid>` positions remain valid: the ms prefix keeps the same 13-digit width,
16
+ // so cross-format comparison still orders by timestamp. See
17
+ // docs/plans/done/dcb-monotonic-position-generation.md.
18
+ let lastMs = ref(0.0)
19
+ let counter = ref(0)
20
+
6
21
  let generatePosition = () => {
7
- let timestamp = Date.make()->Date.getTime->Float.toString
22
+ let now = Date.make()->Date.getTime
23
+ if now == lastMs.contents {
24
+ counter := counter.contents + 1
25
+ } else {
26
+ lastMs := now
27
+ counter := 0
28
+ }
29
+ let ms = now->Float.toString
30
+ let counterStr = counter.contents->Int.toString->String.padStart(6, "0")
8
31
  let uuid = Uuid.v4()
9
- `${timestamp}-${uuid}`
32
+ `${ms}-${counterStr}-${uuid}`
10
33
  }
11
34
 
12
35
  let generatePositionForBatch = (basePosition, index) => {
@@ -915,20 +938,39 @@ let appendUnconditional = async (
915
938
  }
916
939
  }
917
940
 
941
+ // A Composite partition fences on the WHOLE composite value, not on each member.
942
+ // The synthetic fence tag is keyed on `getCompositePartitionKeyValue` — the same
943
+ // value `derivePartitionKey` uses for the base-table `id` — so it is exactly as
944
+ // selective as the entity's storage partition (and the `tag_composite` read
945
+ // scope). Fencing per-member (the historical behaviour) gave every low-cardinality
946
+ // member (e.g. `environment`, `platformName`) its own fence, which a deploy-time
947
+ // fan-out sharing those prefixes turns into a hot partition → `TransactionConflict`
948
+ // → `retries exhausted`; it also over-fenced (two DISTINCT composite entities
949
+ // sharing a member value serialized needlessly). Plan:
950
+ // docs/plans/Backlog/dcb-hot-tag-fence-contention.md § "Root-cause correction".
951
+ let compositeFenceTagKey = "__dcb_composite__"
952
+
953
+ let makeCompositeFenceTag = (
954
+ tags: array<Reventless.DcbTag.tag>,
955
+ spec: Reventless.DcbTag.compositePartitionSpec,
956
+ ): Reventless.DcbTag.tag => {
957
+ key: compositeFenceTagKey,
958
+ value: Reventless.DcbTag.getCompositePartitionKeyValue(tags, spec),
959
+ }
960
+
918
961
  // The partition tag(s) of a written event — the ONLY fences an append may BUMP.
919
962
  // A tag's fence must track exactly the partition-scoped events a single-tag read
920
963
  // of that tag observes (events are stored under `id="<partitionKey>:<value>"`),
921
964
  // so only the partition tag may advance it. Mirrors `derivePartitionKey`.
922
965
  //
923
- // For a Composite partition tag there is no single fence key that represents the
924
- // partition, so we keep the historical behaviour (treat every tag as a partition
925
- // tag) rather than risk under-fencing composite-partition slices.
966
+ // A Composite partition collapses to a single synthetic composite fence tag (see
967
+ // `makeCompositeFenceTag`) one fence per composite entity, not one per member.
926
968
  let eventPartitionTags = (
927
969
  event: ReventlessCore.DcbEventLog_Adapter.rawStoredEvent,
928
970
  ~partitionTag: option<Reventless.DcbTag.derivedPartitionTag>,
929
971
  ): array<Reventless.DcbTag.tag> =>
930
972
  switch partitionTag {
931
- | Some(Composite(_)) => event.tags
973
+ | Some(Composite(spec)) => [makeCompositeFenceTag(event.tags, spec)]
932
974
  | Some(Simple(pt)) =>
933
975
  switch event.tags->Array.find(t => t.key == pt.key) {
934
976
  | Some(t) => [t]
@@ -994,6 +1036,32 @@ let buildConditionalTransactItems = (
994
1036
  ~partitionTag: option<Reventless.DcbTag.derivedPartitionTag>=?,
995
1037
  ~crossPartitionTagKeys: array<string>=[],
996
1038
  ): array<TransactWriteCommand.transactWriteItem> => {
1039
+ // For a Composite partition, fold the multi-tag composite read clause into a
1040
+ // single synthetic composite fence tag (`makeCompositeFenceTag`) so the rest of
1041
+ // this function fences it exactly like a Simple partition — one fence per
1042
+ // composite entity — instead of once per member (the hot-fence source). Only the
1043
+ // FENCE view of the query is rewritten here; the read path (`readStream`) keeps
1044
+ // the original member tags and its `tag_composite` GSI lookup. Gated on
1045
+ // `Composite`: Simple-partition composite-read slices (e.g. RecordProductDemand's
1046
+ // `{productId, orderId}` pair, where `productId` is a real independent partition)
1047
+ // are left untouched. `@crossPartition` carriers are unaffected — they are
1048
+ // handled by `crossPartitionEventTags` below, not by this composite clause.
1049
+ let cond = switch partitionTag {
1050
+ | Some(Composite(spec)) => {
1051
+ ...cond,
1052
+ query: cond.query->Array.map(qi =>
1053
+ switch qi.tags {
1054
+ | Some(clauseTags) if clauseTags->Array.length > 1 => {
1055
+ ...qi,
1056
+ tags: [makeCompositeFenceTag(clauseTags, spec)],
1057
+ }
1058
+ | _ => qi
1059
+ }
1060
+ ),
1061
+ }
1062
+ | _ => cond
1063
+ }
1064
+
997
1065
  // The partitions this append writes into — the only fences it may BUMP
998
1066
  // (partition-scoped tags only; cross-partition tags below override this).
999
1067
  let partitionTags = collectEventPartitionTags(events, ~partitionTag)
@@ -20,10 +20,26 @@ import * as DynamoDb_Error$ReventlessAws from "../../errors/DynamoDb_Error.res.m
20
20
  import * as DynamoDb_DocumentClient$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_DocumentClient.res.mjs";
21
21
  import * as Util_DynamoDb_Runtime$ReventlessAws from "../../util/Util_DynamoDb_Runtime.res.mjs";
22
22
 
23
+ let lastMs = {
24
+ contents: 0.0
25
+ };
26
+
27
+ let counter = {
28
+ contents: 0
29
+ };
30
+
23
31
  function generatePosition() {
24
- let timestamp = new Date().getTime().toString();
32
+ let now = new Date().getTime();
33
+ if (now === lastMs.contents) {
34
+ counter.contents = counter.contents + 1 | 0;
35
+ } else {
36
+ lastMs.contents = now;
37
+ counter.contents = 0;
38
+ }
39
+ let ms = now.toString();
40
+ let counterStr = counter.contents.toString().padStart(6, "0");
25
41
  let uuid = Uuid.v4();
26
- return timestamp + `-` + uuid;
42
+ return ms + `-` + counterStr + `-` + uuid;
27
43
  }
28
44
 
29
45
  function generatePositionForBatch(basePosition, index) {
@@ -695,10 +711,19 @@ async function appendUnconditional(table, events, partitionTag) {
695
711
  return await runTransactWrite(input, basePosition, "DCB append failed");
696
712
  }
697
713
 
714
+ let compositeFenceTagKey = "__dcb_composite__";
715
+
716
+ function makeCompositeFenceTag(tags, spec) {
717
+ return {
718
+ key: compositeFenceTagKey,
719
+ value: DcbTag$Reventless.getCompositePartitionKeyValue(tags, spec)
720
+ };
721
+ }
722
+
698
723
  function eventPartitionTags(event, partitionTag) {
699
724
  if (partitionTag !== undefined) {
700
725
  if (partitionTag.TAG !== "Simple") {
701
- return event.tags;
726
+ return [makeCompositeFenceTag(event.tags, partitionTag._0)];
702
727
  }
703
728
  let pt = partitionTag._0;
704
729
  let t = event.tags.find(t => t.key === pt.key);
@@ -753,6 +778,26 @@ function partitionTypesByTag(events, partitionTag) {
753
778
 
754
779
  function buildConditionalTransactItems(table, events, cond, basePosition, partitionTag, crossPartitionTagKeysOpt) {
755
780
  let crossPartitionTagKeys = crossPartitionTagKeysOpt !== undefined ? crossPartitionTagKeysOpt : [];
781
+ let cond$1;
782
+ if (partitionTag !== undefined && partitionTag.TAG !== "Simple") {
783
+ let spec = partitionTag._0;
784
+ let newrecord = {...cond};
785
+ newrecord.query = cond.query.map(qi => {
786
+ let clauseTags = qi.tags;
787
+ if (clauseTags === undefined) {
788
+ return qi;
789
+ }
790
+ if (clauseTags.length <= 1) {
791
+ return qi;
792
+ }
793
+ let newrecord = {...qi};
794
+ newrecord.tags = [makeCompositeFenceTag(clauseTags, spec)];
795
+ return newrecord;
796
+ });
797
+ cond$1 = newrecord;
798
+ } else {
799
+ cond$1 = cond;
800
+ }
756
801
  let partitionTags = collectEventPartitionTags(events, partitionTag);
757
802
  let partitionKeySet = new Set();
758
803
  partitionTags.forEach(t => {
@@ -770,7 +815,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
770
815
  )[k], []);
771
816
  };
772
817
  let consumedMap = {};
773
- cond.query.forEach(qi => {
818
+ cond$1.query.forEach(qi => {
774
819
  let match = qi.tags;
775
820
  let match$1 = qi.eventTypes;
776
821
  if (match !== undefined && match$1 !== undefined) {
@@ -785,7 +830,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
785
830
  let consumedTypesFor = t => Stdlib_Option.getOr(consumedMap[t.key + `:` + t.value], []);
786
831
  let compositeKeySet = new Set();
787
832
  let compositeQueryTags = [];
788
- cond.query.forEach(qi => {
833
+ cond$1.query.forEach(qi => {
789
834
  let tags = qi.tags;
790
835
  if (tags !== undefined && tags.length > 1) {
791
836
  tags.forEach(tag => {
@@ -812,7 +857,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
812
857
  return;
813
858
  }
814
859
  };
815
- cond.query.forEach(qi => {
860
+ cond$1.query.forEach(qi => {
816
861
  let tags = qi.tags;
817
862
  if (tags === undefined) {
818
863
  return;
@@ -821,7 +866,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
821
866
  if (tags.length <= 1) {
822
867
  return;
823
868
  }
824
- let match = cond.after;
869
+ let match = cond$1.after;
825
870
  if (match !== undefined) {
826
871
  tags.forEach(pushUpdate);
827
872
  return;
@@ -833,7 +878,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
833
878
  if (crossPartitionTagKeys.includes(tag.key)) {
834
879
  return pushUpdate(tag);
835
880
  }
836
- let match$1 = cond.after;
881
+ let match$1 = cond$1.after;
837
882
  if (match$1 !== undefined) {
838
883
  if (isPartition(tag) || isCompositeQueryTag(tag)) {
839
884
  return pushUpdate(tag);
@@ -853,7 +898,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
853
898
  return;
854
899
  }
855
900
  });
856
- let match = cond.after;
901
+ let match = cond$1.after;
857
902
  if (match !== undefined) {
858
903
 
859
904
  } else {
@@ -864,7 +909,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
864
909
  bumpSeen.add(t.key + `:` + t.value);
865
910
  });
866
911
  let bumpTags = [];
867
- let match$1 = cond.after;
912
+ let match$1 = cond$1.after;
868
913
  let candidateBumps = match$1 !== undefined ? partitionTags.concat(crossPartitionEventTags) : partitionTags.concat(compositeQueryTags.concat(crossPartitionEventTags));
869
914
  candidateBumps.forEach(tag => {
870
915
  let k = tag.key + `:` + tag.value;
@@ -881,7 +926,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
881
926
  return;
882
927
  } else {
883
928
  return {
884
- Update: buildConditionalFenceUpdate(table.name, tag, consumedTypesFor(tag), producedTypes, basePosition, cond.after)
929
+ Update: buildConditionalFenceUpdate(table.name, tag, consumedTypesFor(tag), producedTypes, basePosition, cond$1.after)
885
930
  };
886
931
  }
887
932
  });
@@ -891,7 +936,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
891
936
  return;
892
937
  } else {
893
938
  return {
894
- ConditionCheck: buildFenceConditionCheck(table.name, tag, consumedTypes, cond.after)
939
+ ConditionCheck: buildFenceConditionCheck(table.name, tag, consumedTypes, cond$1.after)
895
940
  };
896
941
  }
897
942
  });
@@ -1102,6 +1147,8 @@ function readStream(table, $staropt$star) {
1102
1147
  let transactWriteItemsLimit = 100;
1103
1148
 
1104
1149
  export {
1150
+ lastMs,
1151
+ counter,
1105
1152
  generatePosition,
1106
1153
  generatePositionForBatch,
1107
1154
  tagToAttributeName,
@@ -1140,6 +1187,8 @@ export {
1140
1187
  buildEventPuts,
1141
1188
  runTransactWrite,
1142
1189
  appendUnconditional,
1190
+ compositeFenceTagKey,
1191
+ makeCompositeFenceTag,
1143
1192
  eventPartitionTags,
1144
1193
  collectEventPartitionTags,
1145
1194
  partitionTypesByTag,
@@ -435,6 +435,97 @@ describe("Runtime.buildConditionalTransactItems — fence-scope = read-scope", (
435
435
  })
436
436
  })
437
437
 
438
+ describe("Runtime.buildConditionalTransactItems — composite partition fences on ONE composite key", () => {
439
+ // SyncResource-style: @compositePartitionTag over {environment, platformName,
440
+ // pluginName}. The read is one exact `tag_composite` match, so the fence must be
441
+ // a SINGLE composite-key fence — not one per member. Per-member fencing made the
442
+ // low-cardinality prefix (`environment`, `platformName`) hot under a deploy
443
+ // fan-out. Plan: docs/plans/Backlog/dcb-hot-tag-fence-contention.md.
444
+ let event = (eventType, tags): ReventlessCore.DcbEventLog_Adapter.rawStoredEvent => {
445
+ eventType,
446
+ data: JSON.Object(Dict.make()),
447
+ tags,
448
+ meta: testMeta(),
449
+ }
450
+ let findFence = (
451
+ items: array<AwsSdk.DynamoDb.DocumentClient.TransactWriteCommand.transactWriteItem>,
452
+ fenceId,
453
+ ) =>
454
+ items->Array.find(it => {
455
+ let idOf = key => key->Dict.get("id") == Some(fenceId->JSON.Encode.string)
456
+ switch (it.update, it.conditionCheck) {
457
+ | (Some(u), _) => idOf(u.key)
458
+ | (_, Some(c)) => idOf(c.key)
459
+ | _ => false
460
+ }
461
+ })
462
+ let isUpdate = it =>
463
+ it->Option.flatMap(i => i.AwsSdk.DynamoDb.DocumentClient.TransactWriteCommand.update)->Option.isSome
464
+ let fenceIds = (items: array<AwsSdk.DynamoDb.DocumentClient.TransactWriteCommand.transactWriteItem>) =>
465
+ items
466
+ ->Array.filterMap(it => {
467
+ let idOf = key => key->Dict.get("id")->Option.flatMap(JSON.Decode.string)
468
+ switch (it.update, it.conditionCheck) {
469
+ | (Some(u), _) => idOf(u.key)
470
+ | (_, Some(c)) => idOf(c.key)
471
+ | _ => None
472
+ }
473
+ })
474
+ ->Array.filter(s => s->String.startsWith("fence#"))
475
+
476
+ let spec: Reventless.DcbTag.compositePartitionSpec = {
477
+ keys: ["environment", "platformName", "pluginName"],
478
+ seps: ["/", "/"],
479
+ }
480
+ let partitionTag = Some(Reventless.DcbTag.Composite(spec))
481
+ let members = [tag("environment", "prod"), tag("platformName", "plat"), tag("pluginName", "plug")]
482
+ let compositeFence = "fence#__dcb_composite__:prod/plat/plug"
483
+
484
+ describe("at after=Some (entity exists)", () => {
485
+ let resource = event("ResourceAdded", members)
486
+ let cond: Reventless.DcbTag.appendCondition = {
487
+ query: [{tags: members, eventTypes: ["ResourceAdded"]}],
488
+ after: "50",
489
+ }
490
+ let items = Runtime.buildConditionalTransactItems(table, [resource], cond, "100", ~partitionTag?)
491
+
492
+ testSync("the whole composite key is a single conditional Update", () => {
493
+ expect(isUpdate(findFence(items, compositeFence)))->toBe(true)
494
+ })
495
+
496
+ testSync("no per-member fence is emitted (the hot-fence regression)", () => {
497
+ expect(findFence(items, "fence#environment:prod")->Option.isSome)->toBe(false)
498
+ expect(findFence(items, "fence#platformName:plat")->Option.isSome)->toBe(false)
499
+ expect(findFence(items, "fence#pluginName:plug")->Option.isSome)->toBe(false)
500
+ })
501
+
502
+ testSync("exactly one fence item total (the composite fence, no members)", () => {
503
+ expect(fenceIds(items))->toEqual([compositeFence])
504
+ })
505
+ })
506
+
507
+ describe("at after=None (folded create guard)", () => {
508
+ let resource = event("ResourceAdded", members)
509
+ let cond: Reventless.DcbTag.appendCondition = {
510
+ query: [{tags: members, eventTypes: ["ResourceAdded"]}],
511
+ }
512
+ let items = Runtime.buildConditionalTransactItems(table, [resource], cond, "100", ~partitionTag?)
513
+
514
+ testSync("the composite fence is a create-guard Update gated on attribute_not_exists", () => {
515
+ let u =
516
+ findFence(items, compositeFence)
517
+ ->Option.flatMap(i => i.AwsSdk.DynamoDb.DocumentClient.TransactWriteCommand.update)
518
+ ->Option.getOrThrow
519
+ expect(u.conditionExpression)->toEqual(Some("attribute_not_exists(#c0)"))
520
+ expect(u.expressionAttributeValues->Option.flatMap(v => v->Dict.get(":after")))->toEqual(None)
521
+ })
522
+
523
+ testSync("still exactly one composite fence (no per-member create guards)", () => {
524
+ expect(fenceIds(items))->toEqual([compositeFence])
525
+ })
526
+ })
527
+ })
528
+
438
529
  describe("Runtime.buildConditionalTransactItems — folded create guard (after=None)", () => {
439
530
  let event = (eventType, tags): ReventlessCore.DcbEventLog_Adapter.rawStoredEvent => {
440
531
  eventType,
@@ -604,3 +695,52 @@ describe("Runtime.indexKeepsFullProjection", () => {
604
695
  expect(Runtime.indexKeepsFullProjection("tag_productId"))->toBe(false)
605
696
  })
606
697
  })
698
+
699
+ describe("Runtime.generatePosition — monotonic HLC positions", () => {
700
+ testSync("a rapid sequence of positions is strictly increasing (lexical == commit order)", () => {
701
+ // Most of these land in the same millisecond (counter increments); the loop
702
+ // also crosses ms ticks (counter resets, ms prefix dominates). Either way the
703
+ // sequence must be strictly increasing under byte-wise (DynamoDB) string order.
704
+ let positions = Array.fromInitializer(~length=50, _ => Runtime.generatePosition())
705
+ let strictlyIncreasing = ref(true)
706
+ for i in 1 to Array.length(positions) - 1 {
707
+ let prev = positions->Array.getUnsafe(i - 1)
708
+ let curr = positions->Array.getUnsafe(i)
709
+ if !(prev < curr) {
710
+ strictlyIncreasing := false
711
+ }
712
+ }
713
+ expect(strictlyIncreasing.contents)->toBe(true)
714
+ })
715
+
716
+ testSync("format carries a 6-digit counter segment between ms and uuid", () => {
717
+ // `<ms>-<6-digit counter>-<uuid>`. Splitting on '-' yields [ms, counter, …uuid].
718
+ let segments = Runtime.generatePosition()->String.split("-")
719
+ expect(segments->Array.length >= 3)->toBe(true)
720
+ let counterSeg = segments->Array.getUnsafe(1)
721
+ expect(counterSeg->String.length)->toBe(6)
722
+ })
723
+
724
+ testSync("generatePositionForBatch preserves order for same base, ascending index", () => {
725
+ let base = Runtime.generatePosition()
726
+ let p0 = Runtime.generatePositionForBatch(base, 0)
727
+ let p1 = Runtime.generatePositionForBatch(base, 1)
728
+ let p2 = Runtime.generatePositionForBatch(base, 2)
729
+ // index 0 is the base itself; later batch items sort strictly after it, in order.
730
+ expect(p0)->toBe(base)
731
+ expect(base < p1)->toBe(true)
732
+ expect(p1 < p2)->toBe(true)
733
+ })
734
+
735
+ testSync("old <ms>-<uuid> positions still sort by timestamp against new-format ones", () => {
736
+ // Backwards compatibility: the ms prefix keeps the same 13-digit width, so an
737
+ // older position with a smaller ms sorts before a newer-format one regardless of
738
+ // the counter segment. Sorting an unsorted mix must recover timestamp order.
739
+ let older = "1700000000000-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" // old <ms>-<uuid>
740
+ let newer = "1700000000001-000000-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" // new <ms>-<ctr>-<uuid>
741
+ let cmp = (a, b) => a < b ? -1.0 : a > b ? 1.0 : 0.0
742
+ let sorted = [newer, older]->Array.toSorted(cmp)
743
+ expect(sorted->Array.getUnsafe(0))->toBe(older)
744
+ expect(sorted->Array.getUnsafe(1))->toBe(newer)
745
+ })
746
+ })
@@ -544,6 +544,115 @@ globalThis.describe("Runtime.buildConditionalTransactItems — fence-scope = rea
544
544
  });
545
545
  });
546
546
 
547
+ globalThis.describe("Runtime.buildConditionalTransactItems — composite partition fences on ONE composite key", () => {
548
+ let event = (eventType, tags) => ({
549
+ eventType: eventType,
550
+ data: {},
551
+ tags: tags,
552
+ meta: testMeta()
553
+ });
554
+ let findFence = (items, fenceId) => items.find(it => {
555
+ let idOf = key => Primitive_object.equal(key["id"], fenceId);
556
+ let match = it.Update;
557
+ let match$1 = it.ConditionCheck;
558
+ if (match !== undefined) {
559
+ return idOf(match.Key);
560
+ } else if (match$1 !== undefined) {
561
+ return idOf(match$1.Key);
562
+ } else {
563
+ return false;
564
+ }
565
+ });
566
+ let isUpdate = it => Stdlib_Option.isSome(Stdlib_Option.flatMap(it, i => i.Update));
567
+ let fenceIds = items => Stdlib_Array.filterMap(items, it => {
568
+ let idOf = key => Stdlib_Option.flatMap(key["id"], Stdlib_JSON.Decode.string);
569
+ let match = it.Update;
570
+ let match$1 = it.ConditionCheck;
571
+ if (match !== undefined) {
572
+ return idOf(match.Key);
573
+ } else if (match$1 !== undefined) {
574
+ return idOf(match$1.Key);
575
+ } else {
576
+ return;
577
+ }
578
+ }).filter(s => s.startsWith("fence#"));
579
+ let spec_keys = [
580
+ "environment",
581
+ "platformName",
582
+ "pluginName"
583
+ ];
584
+ let spec_seps = [
585
+ "/",
586
+ "/"
587
+ ];
588
+ let spec = {
589
+ keys: spec_keys,
590
+ seps: spec_seps
591
+ };
592
+ let partitionTag = {
593
+ TAG: "Composite",
594
+ _0: spec
595
+ };
596
+ let members = [
597
+ {
598
+ key: "environment",
599
+ value: "prod"
600
+ },
601
+ {
602
+ key: "platformName",
603
+ value: "plat"
604
+ },
605
+ {
606
+ key: "pluginName",
607
+ value: "plug"
608
+ }
609
+ ];
610
+ let compositeFence = "fence#__dcb_composite__:prod/plat/plug";
611
+ globalThis.describe("at after=Some (entity exists)", () => {
612
+ let resource = event("ResourceAdded", members);
613
+ let cond_query = [{
614
+ eventTypes: ["ResourceAdded"],
615
+ tags: members
616
+ }];
617
+ let cond_after = "50";
618
+ let cond = {
619
+ query: cond_query,
620
+ after: cond_after
621
+ };
622
+ let items = DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.buildConditionalTransactItems(table, [resource], cond, "100", partitionTag, undefined);
623
+ globalThis.test("the whole composite key is a single conditional Update", () => {
624
+ globalThis.expect(isUpdate(findFence(items, compositeFence))).toBe(true);
625
+ });
626
+ globalThis.test("no per-member fence is emitted (the hot-fence regression)", () => {
627
+ globalThis.expect(Stdlib_Option.isSome(findFence(items, "fence#environment:prod"))).toBe(false);
628
+ globalThis.expect(Stdlib_Option.isSome(findFence(items, "fence#platformName:plat"))).toBe(false);
629
+ globalThis.expect(Stdlib_Option.isSome(findFence(items, "fence#pluginName:plug"))).toBe(false);
630
+ });
631
+ globalThis.test("exactly one fence item total (the composite fence, no members)", () => {
632
+ globalThis.expect(fenceIds(items)).toEqual([compositeFence]);
633
+ });
634
+ });
635
+ globalThis.describe("at after=None (folded create guard)", () => {
636
+ let resource = event("ResourceAdded", members);
637
+ let cond_query = [{
638
+ eventTypes: ["ResourceAdded"],
639
+ tags: members
640
+ }];
641
+ let cond = {
642
+ query: cond_query
643
+ };
644
+ let items = DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.buildConditionalTransactItems(table, [resource], cond, "100", partitionTag, undefined);
645
+ globalThis.test("the composite fence is a create-guard Update gated on attribute_not_exists", () => {
646
+ let u = Stdlib_Option.getOrThrow(Stdlib_Option.flatMap(findFence(items, compositeFence), i => i.Update), undefined);
647
+ globalThis.expect(u.ConditionExpression).toEqual("attribute_not_exists(#c0)");
648
+ globalThis.expect(Stdlib_Option.flatMap(u.ExpressionAttributeValues, v => v[":after"])).toEqual(undefined);
649
+ });
650
+ globalThis.test("still exactly one composite fence (no per-member create guards)", () => {
651
+ globalThis.expect(fenceIds(items)).toEqual([compositeFence]);
652
+ });
653
+ });
654
+ });
655
+
547
656
  globalThis.describe("Runtime.buildConditionalTransactItems — folded create guard (after=None)", () => {
548
657
  let event = (eventType, tags) => ({
549
658
  eventType: eventType,
@@ -746,6 +855,55 @@ globalThis.describe("Runtime.indexKeepsFullProjection", () => {
746
855
  });
747
856
  });
748
857
 
858
+ globalThis.describe("Runtime.generatePosition — monotonic HLC positions", () => {
859
+ globalThis.test("a rapid sequence of positions is strictly increasing (lexical == commit order)", () => {
860
+ let positions = Stdlib_Array.fromInitializer(50, param => DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.generatePosition());
861
+ let strictlyIncreasing = true;
862
+ for (let i = 1, i_finish = positions.length; i < i_finish; ++i) {
863
+ let prev = positions[i - 1 | 0];
864
+ let curr = positions[i];
865
+ if (prev >= curr) {
866
+ strictlyIncreasing = false;
867
+ }
868
+ }
869
+ globalThis.expect(strictlyIncreasing).toBe(true);
870
+ });
871
+ globalThis.test("format carries a 6-digit counter segment between ms and uuid", () => {
872
+ let segments = DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.generatePosition().split("-");
873
+ globalThis.expect(segments.length >= 3).toBe(true);
874
+ let counterSeg = segments[1];
875
+ globalThis.expect(counterSeg.length).toBe(6);
876
+ });
877
+ globalThis.test("generatePositionForBatch preserves order for same base, ascending index", () => {
878
+ let base = DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.generatePosition();
879
+ let p0 = DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.generatePositionForBatch(base, 0);
880
+ let p1 = DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.generatePositionForBatch(base, 1);
881
+ let p2 = DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.generatePositionForBatch(base, 2);
882
+ globalThis.expect(p0).toBe(base);
883
+ globalThis.expect(base < p1).toBe(true);
884
+ globalThis.expect(p1 < p2).toBe(true);
885
+ });
886
+ globalThis.test("old <ms>-<uuid> positions still sort by timestamp against new-format ones", () => {
887
+ let older = "1700000000000-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
888
+ let newer = "1700000000001-000000-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
889
+ let cmp = (a, b) => {
890
+ if (Primitive_object.lessthan(a, b)) {
891
+ return -1.0;
892
+ } else if (Primitive_object.greaterthan(a, b)) {
893
+ return 1.0;
894
+ } else {
895
+ return 0.0;
896
+ }
897
+ };
898
+ let sorted = [
899
+ newer,
900
+ older
901
+ ].toSorted(cmp);
902
+ globalThis.expect(sorted[0]).toBe(older);
903
+ globalThis.expect(sorted[1]).toBe(newer);
904
+ });
905
+ });
906
+
749
907
  let Runtime;
750
908
 
751
909
  export {
@@ -1,10 +1,9 @@
1
- // ⚠️ PENDING REWRITE (dcb-fence-event-type-granularity): this suite still encodes
2
- // the OLD scalar-`lastPosition` fence + `create#…` create-guard model. The adapter
3
- // now uses PER-TYPE `pos#<eventType>` fence attributes with the create guard folded
4
- // in, so `H.setFence(~lastPosition=…)` seeding, the `create#…`-row assertions, and
5
- // the tag-only (no `eventTypes`) queries below must be reworked and re-run against
6
- // DynamoDB Local before they pass. Tracked as Test plan #2 in
7
- // docs/plans/dcb-fence-event-type-granularity.md. Not run by the default unit suite
1
+ // Fence model: PER-TYPE `pos#<eventType>` attributes with the create guard folded
2
+ // into the fence Update (dcb-fence-event-type-granularity). Conflict-expecting
3
+ // scenarios name the consumed event type on each query clause (a tag-only clause
4
+ // produces no fence check) and advance the fence via `H.setFence(~eventTypes,
5
+ // ~position=…)`, which writes the matching `pos#<eventType>` attribute the same
6
+ // attribute a real conditional append checks. Not run by the default unit suite
8
7
  // (CI `pnpm test`), so it does not gate the build.
9
8
  //
10
9
  // DCB DynamoDB integration suite — exercises the real fence path against a
@@ -105,17 +104,23 @@ describe("DCB DynamoDb integration — fence-scope = read-scope (Issue 1 regress
105
104
  let table = await H.freshTable()
106
105
  let _ = await seed(table, event("CatalogProductSynced", [productTag]), ~partitionTag=simple("productId"))
107
106
 
108
- // Order reads its decision model (after = the sync's position)
109
- let query: Reventless.DcbTag.query = [{tags: [tag("orderId", "O1")]}, {tags: [productTag]}]
107
+ // Order reads its decision model (after = the sync's position). Each clause
108
+ // names the event type it reads the per-type fence model checks the
109
+ // consumed type's `pos#<type>`, so a tag-only clause would produce no fence
110
+ // check at all.
111
+ let query: Reventless.DcbTag.query = [
112
+ {tags: [tag("orderId", "O1")], eventTypes: ["OrderPlaced"]},
113
+ {tags: [productTag], eventTypes: ["CatalogProductSynced"]},
114
+ ]
110
115
  let after = (await readAfter(table, query))->Option.getOr("")
111
116
  expect(after == "")->toBe(false)
112
117
 
113
- // …a concurrent re-sync advances fence#productId:P5 past `after`. We set the
114
- // fence directly (rather than racing a second appendUnconditional) so the
115
- // advance is deterministically > after — same-millisecond writers can
116
- // otherwise tie on UUID order (analysis Issue 7). Appending "z" stays
117
- // lexically greater than any real position.
118
- let _ = await H.setFence(table, productTag, ~lastPosition=after ++ "z")
118
+ // …a concurrent re-sync advances fence#productId:P5's pos#CatalogProductSynced
119
+ // past `after`. We set the fence directly (rather than racing a second
120
+ // appendUnconditional) so the advance is deterministically > after —
121
+ // same-millisecond writers can otherwise tie on UUID order (analysis Issue 7).
122
+ // Appending "z" stays lexically greater than any real position.
123
+ let _ = await H.setFence(table, productTag, ~eventTypes=["CatalogProductSynced"], ~position=after ++ "z")
119
124
 
120
125
  // …so the now-stale order append must conflict on the productId fence.
121
126
  let r = await Runtime.appendConditional(
@@ -280,9 +285,11 @@ describe("DCB DynamoDb integration — optimistic concurrency primitives", () =>
280
285
 
281
286
  testAsync("a failed fence condition aborts the whole transaction (multi-tag atomicity)", async () => {
282
287
  let table = await H.freshTable()
283
- // Composite slice: one multi-tag clause → both tags get check+bump.
288
+ // Composite slice: one multi-tag clause → both tags get check+bump. The
289
+ // clause names the consumed type so the per-type fence check has a `pos#<type>`
290
+ // to assert against.
284
291
  let tags = [tag("productId", "p1"), tag("orderId", "o1")]
285
- let query: Reventless.DcbTag.query = [{tags: tags}]
292
+ let query: Reventless.DcbTag.query = [{tags: tags, eventTypes: ["ProductDemandRecorded"]}]
286
293
 
287
294
  // Seed one event so the next append rides after=Some.
288
295
  let seedRes = await Runtime.appendConditional(
@@ -296,9 +303,14 @@ describe("DCB DynamoDb integration — optimistic concurrency primitives", () =>
296
303
  let after = (await readAfter(table, query))->Option.getOr("")
297
304
  expect(after == "")->toBe(false)
298
305
 
299
- // A concurrent writer advances ONLY the productId fence past `after`.
300
- // Appending "z" keeps the position lexically greater than any real position.
301
- let _ = await H.setFence(table, tag("productId", "p1"), ~lastPosition=after ++ "z")
306
+ // A concurrent writer advances ONLY the productId fence's pos#ProductDemandRecorded
307
+ // past `after`. Appending "z" keeps the position lexically greater than any real one.
308
+ let _ = await H.setFence(
309
+ table,
310
+ tag("productId", "p1"),
311
+ ~eventTypes=["ProductDemandRecorded"],
312
+ ~position=after ++ "z",
313
+ )
302
314
 
303
315
  let r = await Runtime.appendConditional(
304
316
  table,
@@ -314,3 +326,135 @@ describe("DCB DynamoDb integration — optimistic concurrency primitives", () =>
314
326
  expect(readResult.events->Array.length)->toBe(1)
315
327
  })
316
328
  })
329
+
330
+ // A `@compositePartitionTag` slice (platform-inspector's SyncResource shape) fences
331
+ // on the WHOLE composite key, not on each member. Distinct entities sharing a
332
+ // low-cardinality prefix (environment/platformName/pluginName) must therefore NOT
333
+ // serialize on that prefix — the deploy-fan-out hot-fence regression. Plan:
334
+ // docs/plans/Backlog/dcb-hot-tag-fence-contention.md § "Root-cause correction".
335
+ describe("DCB DynamoDb integration — composite partition hot-fence fix", () => {
336
+ let composite = (keys, seps): Reventless.DcbTag.derivedPartitionTag => Composite({keys, seps})
337
+ let specKeys = ["environment", "platformName", "pluginName", "resourceName"]
338
+ let specSeps = ["/", "/", "/"]
339
+ let pt = composite(specKeys, specSeps)
340
+ let members = resourceName => [
341
+ tag("environment", "prod"),
342
+ tag("platformName", "plat"),
343
+ tag("pluginName", "plug"),
344
+ tag("resourceName", resourceName),
345
+ ]
346
+
347
+ testAsync("distinct composite entities sharing a prefix do NOT false-conflict", async () => {
348
+ let table = await H.freshTable()
349
+ let tagsA = members("resA")
350
+ let tagsB = members("resB")
351
+ // Both are first-writers (after=None). They share environment/platformName/
352
+ // pluginName but are DIFFERENT composite entities → different composite fences.
353
+ let rA = await Runtime.appendConditional(
354
+ table,
355
+ [event("ResourceAdded", tagsA)],
356
+ {query: [{tags: tagsA}], after: ?None},
357
+ ~partitionTag=pt,
358
+ )
359
+ let rB = await Runtime.appendConditional(
360
+ table,
361
+ [event("ResourceAdded", tagsB)],
362
+ {query: [{tags: tagsB}], after: ?None},
363
+ ~partitionTag=pt,
364
+ )
365
+ // Pre-fix: rB conflicts on the shared `fence#environment:prod` create guard.
366
+ expect(isOk(rA))->toBe(true)
367
+ expect(isOk(rB))->toBe(true)
368
+ })
369
+
370
+ testAsync("two first-writers of the SAME composite key still serialize (OCC preserved)", async () => {
371
+ let table = await H.freshTable()
372
+ let tags = members("resA")
373
+ let query: Reventless.DcbTag.query = [{tags: tags}]
374
+ let r1 = await Runtime.appendConditional(
375
+ table,
376
+ [event("ResourceAdded", tags)],
377
+ {query, after: ?None},
378
+ ~partitionTag=pt,
379
+ )
380
+ let r2 = await Runtime.appendConditional(
381
+ table,
382
+ [event("ResourceAdded", tags)],
383
+ {query, after: ?None},
384
+ ~partitionTag=pt,
385
+ )
386
+ expect(isOk(r1))->toBe(true)
387
+ // The folded create guard (attribute_not_exists on the composite fence) rejects
388
+ // the second first-write of the same entity.
389
+ expect(isConflict(r2))->toBe(true)
390
+ })
391
+ })
392
+
393
+ // The fence is PER event type (`pos#<eventType>` attributes), not a single scalar
394
+ // `lastPosition` per partition. So two slices consuming DIFFERENT types on the same
395
+ // entity (e.g. price vs name changes on one productId) never contend, and an entity
396
+ // never wedges into a permanent Conflict after one attribute changes. This is the
397
+ // live proof of the per-type granularity fix (dcb-fence-event-type-granularity,
398
+ // `a20646f31`); the unit suite only asserts the built transaction's shape.
399
+ describe("DCB DynamoDb integration — per-type fence granularity", () => {
400
+ let productTag = tag("productId", "P")
401
+
402
+ // Change one attribute of P: read only its own event type on the partition, then
403
+ // append that type. Each type carries its own `pos#<type>` fence, so distinct
404
+ // attributes advance independently.
405
+ let change = async (table, ~eventType) => {
406
+ let query: Reventless.DcbTag.query = [{tags: [productTag], eventTypes: [eventType]}]
407
+ let after = await readAfter(table, query)
408
+ await Runtime.appendConditional(
409
+ table,
410
+ [event(eventType, [productTag])],
411
+ {query, after: ?after},
412
+ ~partitionTag=simple("productId"),
413
+ )
414
+ }
415
+
416
+ testAsync("interleaved distinct-type changes on one product all succeed (never wedges)", async () => {
417
+ let table = await H.freshTable()
418
+ let _ = await seed(table, event("ProductAdded", [productTag]), ~partitionTag=simple("productId"))
419
+
420
+ // Two rounds of interleaved price/name/description changes. Pre-fix, the first
421
+ // PriceChanged bumped the single partition fence, so the following NameChanged
422
+ // read a stale head and conflicted PERMANENTLY — the entity wedged after one edit.
423
+ let results = [
424
+ await change(table, ~eventType="PriceChanged"),
425
+ await change(table, ~eventType="NameChanged"),
426
+ await change(table, ~eventType="DescriptionChanged"),
427
+ await change(table, ~eventType="PriceChanged"),
428
+ await change(table, ~eventType="NameChanged"),
429
+ ]
430
+ expect(results->Array.every(isOk))->toBe(true)
431
+ })
432
+
433
+ testAsync("two concurrent SAME-type changes still serialize (per-type OCC preserved)", async () => {
434
+ let table = await H.freshTable()
435
+ let _ = await seed(table, event("ProductAdded", [productTag]), ~partitionTag=simple("productId"))
436
+ // Seed one NameChanged so both racers read the same after=Some head.
437
+ let _ = await change(table, ~eventType="NameChanged")
438
+
439
+ let query: Reventless.DcbTag.query = [{tags: [productTag], eventTypes: ["NameChanged"]}]
440
+ let after = await readAfter(table, query)
441
+ let rename = () =>
442
+ Runtime.appendConditional(
443
+ table,
444
+ [event("NameChanged", [productTag])],
445
+ {query, after: ?after},
446
+ ~partitionTag=simple("productId"),
447
+ )
448
+
449
+ // Per-type granularity must NOT weaken same-type OCC: two concurrent NameChanged
450
+ // at the same `pos#NameChanged` head → at most one commits, every non-winner
451
+ // conflicts. (DynamoDB may cancel both with a mutual TransactionConflict; the
452
+ // slice callback's retry loop makes exactly one win in prod.)
453
+ let results = await Promise.all([rename(), rename()])
454
+ let oks = results->Array.filter(isOk)->Array.length
455
+ let conflicts = results->Array.filter(isConflict)->Array.length
456
+ expect(oks <= 1)->toBe(true)
457
+ expect(oks + conflicts)->toBe(2)
458
+ expect(conflicts >= 1)->toBe(true)
459
+ })
460
+ })
@@ -129,18 +129,20 @@ globalThis.describe("DCB DynamoDb integration — fence-scope = read-scope (Issu
129
129
  });
130
130
  let query = [
131
131
  {
132
+ eventTypes: ["OrderPlaced"],
132
133
  tags: [{
133
134
  key: "orderId",
134
135
  value: "O1"
135
136
  }]
136
137
  },
137
138
  {
139
+ eventTypes: ["CatalogProductSynced"],
138
140
  tags: [productTag]
139
141
  }
140
142
  ];
141
143
  let after = Stdlib_Option.getOr(await readAfter(table, query), "");
142
144
  globalThis.expect(after === "").toBe(false);
143
- await DcbIntegrationHarness$ReventlessAws.setFence(table, productTag, after + "z");
145
+ await DcbIntegrationHarness$ReventlessAws.setFence(table, productTag, ["CatalogProductSynced"], after + "z");
144
146
  let r = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("OrderPlaced", [
145
147
  {
146
148
  key: "orderId",
@@ -384,6 +386,7 @@ globalThis.describe("DCB DynamoDb integration — optimistic concurrency primiti
384
386
  }
385
387
  ];
386
388
  let query = [{
389
+ eventTypes: ["ProductDemandRecorded"],
387
390
  tags: tags
388
391
  }];
389
392
  let seedRes = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("ProductDemandRecorded", tags)], {
@@ -400,7 +403,7 @@ globalThis.describe("DCB DynamoDb integration — optimistic concurrency primiti
400
403
  await DcbIntegrationHarness$ReventlessAws.setFence(table, {
401
404
  key: "productId",
402
405
  value: "p1"
403
- }, after + "z");
406
+ }, ["ProductDemandRecorded"], after + "z");
404
407
  let r = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("ProductDemandRecorded", tags)], {
405
408
  query: query,
406
409
  after: after
@@ -416,6 +419,150 @@ globalThis.describe("DCB DynamoDb integration — optimistic concurrency primiti
416
419
  });
417
420
  });
418
421
 
422
+ globalThis.describe("DCB DynamoDb integration — composite partition hot-fence fix", () => {
423
+ let specKeys = [
424
+ "environment",
425
+ "platformName",
426
+ "pluginName",
427
+ "resourceName"
428
+ ];
429
+ let specSeps = [
430
+ "/",
431
+ "/",
432
+ "/"
433
+ ];
434
+ let pt = {
435
+ TAG: "Composite",
436
+ _0: {
437
+ keys: specKeys,
438
+ seps: specSeps
439
+ }
440
+ };
441
+ let members = resourceName => [
442
+ {
443
+ key: "environment",
444
+ value: "prod"
445
+ },
446
+ {
447
+ key: "platformName",
448
+ value: "plat"
449
+ },
450
+ {
451
+ key: "pluginName",
452
+ value: "plug"
453
+ },
454
+ {
455
+ key: "resourceName",
456
+ value: resourceName
457
+ }
458
+ ];
459
+ globalThis.test("distinct composite entities sharing a prefix do NOT false-conflict", async () => {
460
+ let table = await DcbIntegrationHarness$ReventlessAws.freshTable();
461
+ let tagsA = members("resA");
462
+ let tagsB = members("resB");
463
+ let rA = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("ResourceAdded", tagsA)], {
464
+ query: [{
465
+ tags: tagsA
466
+ }]
467
+ }, pt, undefined);
468
+ let rB = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("ResourceAdded", tagsB)], {
469
+ query: [{
470
+ tags: tagsB
471
+ }]
472
+ }, pt, undefined);
473
+ globalThis.expect(isOk(rA)).toBe(true);
474
+ globalThis.expect(isOk(rB)).toBe(true);
475
+ });
476
+ globalThis.test("two first-writers of the SAME composite key still serialize (OCC preserved)", async () => {
477
+ let table = await DcbIntegrationHarness$ReventlessAws.freshTable();
478
+ let tags = members("resA");
479
+ let query = [{
480
+ tags: tags
481
+ }];
482
+ let r1 = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("ResourceAdded", tags)], {
483
+ query: query
484
+ }, pt, undefined);
485
+ let r2 = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("ResourceAdded", tags)], {
486
+ query: query
487
+ }, pt, undefined);
488
+ globalThis.expect(isOk(r1)).toBe(true);
489
+ globalThis.expect(isConflict(r2)).toBe(true);
490
+ });
491
+ });
492
+
493
+ globalThis.describe("DCB DynamoDb integration — per-type fence granularity", () => {
494
+ let productTag = {
495
+ key: "productId",
496
+ value: "P"
497
+ };
498
+ let change = async (table, eventType) => {
499
+ let query = [{
500
+ eventTypes: [eventType],
501
+ tags: [productTag]
502
+ }];
503
+ let after = await readAfter(table, query);
504
+ return await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event(eventType, [productTag])], {
505
+ query: query,
506
+ after: after
507
+ }, {
508
+ TAG: "Simple",
509
+ _0: {
510
+ key: "productId"
511
+ }
512
+ }, undefined);
513
+ };
514
+ globalThis.test("interleaved distinct-type changes on one product all succeed (never wedges)", async () => {
515
+ let table = await DcbIntegrationHarness$ReventlessAws.freshTable();
516
+ await seed(table, event("ProductAdded", [productTag]), {
517
+ TAG: "Simple",
518
+ _0: {
519
+ key: "productId"
520
+ }
521
+ });
522
+ let results = [
523
+ await change(table, "PriceChanged"),
524
+ await change(table, "NameChanged"),
525
+ await change(table, "DescriptionChanged"),
526
+ await change(table, "PriceChanged"),
527
+ await change(table, "NameChanged")
528
+ ];
529
+ globalThis.expect(results.every(isOk)).toBe(true);
530
+ });
531
+ globalThis.test("two concurrent SAME-type changes still serialize (per-type OCC preserved)", async () => {
532
+ let table = await DcbIntegrationHarness$ReventlessAws.freshTable();
533
+ await seed(table, event("ProductAdded", [productTag]), {
534
+ TAG: "Simple",
535
+ _0: {
536
+ key: "productId"
537
+ }
538
+ });
539
+ await change(table, "NameChanged");
540
+ let query = [{
541
+ eventTypes: ["NameChanged"],
542
+ tags: [productTag]
543
+ }];
544
+ let after = await readAfter(table, query);
545
+ let rename = () => DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("NameChanged", [productTag])], {
546
+ query: query,
547
+ after: after
548
+ }, {
549
+ TAG: "Simple",
550
+ _0: {
551
+ key: "productId"
552
+ }
553
+ }, undefined);
554
+ let results = await Promise.all([
555
+ rename(),
556
+ rename()
557
+ ]);
558
+ let oks = results.filter(isOk).length;
559
+ let conflicts = results.filter(isConflict).length;
560
+ globalThis.expect(oks <= 1).toBe(true);
561
+ globalThis.expect(oks + conflicts | 0).toBe(2);
562
+ globalThis.expect(conflicts >= 1).toBe(true);
563
+ });
564
+ });
565
+
419
566
  let H;
420
567
 
421
568
  let Runtime;
@@ -85,18 +85,29 @@ let freshTable = async () => {
85
85
  await createDcbTable(`DcbItTest_${counter.contents->Int.toString}`)
86
86
  }
87
87
 
88
- // Directly overwrite a fence sentinel's `lastPosition` — used to simulate a
89
- // concurrent writer having advanced a fence between a slice's read and append.
88
+ // Directly advance a fence sentinel — used to simulate a concurrent writer having
89
+ // bumped a fence between a slice's read and append. The fence model is per event
90
+ // type (`pos#<eventType>` attributes, not a scalar `lastPosition`), so the caller
91
+ // names the event type(s) whose position to set; each `pos#<eventType>` is written
92
+ // to `position`, exactly as a real conditional append would advance them.
90
93
  let setFence = async (
91
94
  table: Util_DynamoDb_Runtime.resolvedTable,
92
95
  tag: Reventless.DcbTag.tag,
93
- ~lastPosition: string,
96
+ ~eventTypes: array<string>,
97
+ ~position: string,
94
98
  ) => {
95
99
  let item =
96
- Dict.fromArray([
97
- ("id", s(DcbEventLogStorage_DynamoDb_Runtime.fencePartitionKey(tag))),
98
- ("position", s("FENCE")),
99
- ("lastPosition", s(lastPosition)),
100
- ])->JSON.Encode.object
100
+ Array.concat(
101
+ [
102
+ ("id", s(DcbEventLogStorage_DynamoDb_Runtime.fencePartitionKey(tag))),
103
+ ("position", s("FENCE")),
104
+ ],
105
+ eventTypes->Array.map(et => (
106
+ DcbEventLogStorage_DynamoDb_Runtime.fenceTypeAttr(et),
107
+ s(position),
108
+ )),
109
+ )
110
+ ->Dict.fromArray
111
+ ->JSON.Encode.object
101
112
  await Util_DynamoDb_Runtime.put(table, item)
102
113
  }
@@ -129,7 +129,7 @@ async function freshTable() {
129
129
  return await createDcbTable(`DcbItTest_` + counter.contents.toString());
130
130
  }
131
131
 
132
- async function setFence(table, tag, lastPosition) {
132
+ async function setFence(table, tag, eventTypes, position) {
133
133
  let item = Object.fromEntries([
134
134
  [
135
135
  "id",
@@ -138,12 +138,11 @@ async function setFence(table, tag, lastPosition) {
138
138
  [
139
139
  "position",
140
140
  "FENCE"
141
- ],
142
- [
143
- "lastPosition",
144
- lastPosition
145
141
  ]
146
- ]);
142
+ ].concat(eventTypes.map(et => [
143
+ DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.fenceTypeAttr(et),
144
+ position
145
+ ])));
147
146
  return await Util_DynamoDb_Runtime$ReventlessAws.put(table, item);
148
147
  }
149
148