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

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.
@@ -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
+ })