@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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
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.183 (2026-07-08)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **reventless-aws:** stop admin-base schema clobber of split-mode DomainApi ([afce85f](https://github.com/ReventlessDev/reventless-core/commit/afce85fa57474735cea3dcb1c2a151c2d8804f0e))
11
+
12
+
13
+ # 3.0.0-alpha.182 (2026-07-07)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **reventless-aws:** fence composite-partition DCB slices on one composite key ([e5f2d95](https://github.com/ReventlessDev/reventless-core/commit/e5f2d95652d795e4dea60e28548f96100a997e78))
18
+
19
+
6
20
  # 3.0.0-alpha.181 (2026-07-07)
7
21
 
8
22
  ### 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.183",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -9,16 +9,16 @@
9
9
  "sury": "11.0.0-alpha.4",
10
10
  "uuid": "^13.0.0",
11
11
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.20",
12
- "@reventlessdev/rescript-jest": "1.0.0-alpha.6",
13
12
  "@reventlessdev/rescript-effect": "0.1.0-alpha.25",
13
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.6",
14
14
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.46",
15
15
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
16
16
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.14",
17
- "@reventlessdev/reventless-core": "3.0.0-alpha.144",
17
+ "@reventlessdev/reventless-core": "3.0.0-alpha.145",
18
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.90",
18
19
  "@reventlessdev/reventless-interop": "3.0.0-alpha.24",
19
- "@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"
20
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.9",
21
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.68"
22
22
  },
23
23
  "devDependencies": {
24
24
  "rescript": "^12.3.0",
package/src/Platform.res CHANGED
@@ -689,37 +689,56 @@ module MakeWithConfig = (
689
689
  // - split mode: platformApi from splitApiOutputsRef
690
690
  // - unified mode / not-yet-populated: domainApi
691
691
  preAdminResolversSchemaHook: (~adminBarrier) => {
692
- let targetApi = switch splitApiOutputsRef.contents {
693
- | Some({platformApi}) => platformApi
694
- | None => domainApi
692
+ // The admin-base SDL stitches AdminApi.baseFragment with an EMPTY plugin
693
+ // list, and startSchemaCreation REPLACES the whole schema. In split mode
694
+ // the admin schema belongs on the PlatformApi ONLY — the DomainApi carries
695
+ // plugin fields (emptyBaseFragment). If we pushed the admin-base-only SDL
696
+ // to the DomainApi it would wipe every plugin field, leaving exactly the
697
+ // admin-base set (the alpha 2026-07-08 clobber). So in split mode we push
698
+ // ONLY when the PlatformApi is known; if the ref is not yet populated we
699
+ // SKIP (never fall back to domainApi). Unified mode legitimately shares one
700
+ // API, so an unpopulated ref there means domainApi.
701
+ let targetApiOpt = switch (Config.splitApi, splitApiOutputsRef.contents) {
702
+ | (_, Some({platformApi})) => Some(platformApi)
703
+ | (false, None) => Some(domainApi)
704
+ | (true, None) => None
695
705
  }
696
- let adminBaseFragment = AppSync_Adapter.injectAwsAuthAll(
697
- ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
698
- ~group="Admin",
699
- )
700
- let sdl = ReventlessCore.GraphQL_Stitcher.stitch(
701
- ~baseFragment=adminBaseFragment,
702
- ~pluginFragments=[],
703
- )->AppSync_Adapter.stampSharedIamTypes
704
- (targetApi, adminBarrier)
705
- ->Pulumi.Output.all2
706
- ->Pulumi.Output.flatMap(((api, _)) =>
707
- api.id->Pulumi.Output.flatMap(apiId => {
708
- log.info(~comp="preAdminResolversSchemaHook", `Pushing admin schema to ${apiId}`)
709
- let client = AppSync_Adapter.getClient()
710
- client
711
- ->AppSync_Adapter.startSchemaCreationRetrying({apiId, definition: sdl})
712
- ->Promise.then(async _ => {
713
- log.info(
714
- ~comp="preAdminResolversSchemaHook",
715
- "startSchemaCreation called, waiting for ACTIVE",
716
- )
717
- await AppSync_Adapter.waitForSchemaActive(client, apiId)
718
- log.info(~comp="preAdminResolversSchemaHook", "schema is ACTIVE")
706
+ switch targetApiOpt {
707
+ | None =>
708
+ log.error(
709
+ ~comp="preAdminResolversSchemaHook",
710
+ "split mode but the PlatformApi is not available at hook time — SKIPPING the admin schema push to avoid clobbering the DomainApi (pushing admin-base here would wipe every plugin field)",
711
+ )
712
+ adminBarrier
713
+ | Some(targetApi) =>
714
+ let adminBaseFragment = AppSync_Adapter.injectAwsAuthAll(
715
+ ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
716
+ ~group="Admin",
717
+ )
718
+ let sdl = ReventlessCore.GraphQL_Stitcher.stitch(
719
+ ~baseFragment=adminBaseFragment,
720
+ ~pluginFragments=[],
721
+ )->AppSync_Adapter.stampSharedIamTypes
722
+ (targetApi, adminBarrier)
723
+ ->Pulumi.Output.all2
724
+ ->Pulumi.Output.flatMap(((api, _)) =>
725
+ api.id->Pulumi.Output.flatMap(apiId => {
726
+ log.info(~comp="preAdminResolversSchemaHook", `Pushing admin schema to ${apiId}`)
727
+ let client = AppSync_Adapter.getClient()
728
+ client
729
+ ->AppSync_Adapter.startSchemaCreationRetrying({apiId, definition: sdl})
730
+ ->Promise.then(async _ => {
731
+ log.info(
732
+ ~comp="preAdminResolversSchemaHook",
733
+ "startSchemaCreation called, waiting for ACTIVE",
734
+ )
735
+ await AppSync_Adapter.waitForSchemaActive(client, apiId)
736
+ log.info(~comp="preAdminResolversSchemaHook", "schema is ACTIVE")
737
+ })
738
+ ->Pulumi.Output.fromPromise
719
739
  })
720
- ->Pulumi.Output.fromPromise
721
- })
722
- )
740
+ )
741
+ }
723
742
  },
724
743
 
725
744
  // Accumulate fragments across independent plugin deployments: each plugin
@@ -957,26 +976,44 @@ module MakeWithConfig = (
957
976
  ~sdl=s,
958
977
  ~typeName="Subscription",
959
978
  )
979
+ // Identity-aware drift check (not a bare count): the live schema is
980
+ // "intact" only when it is a SUPERSET of every root field we would
981
+ // push. Comparing name SETS heals equal-cardinality drift and field
982
+ // *swaps* — an admin-base clobber that leaves the DomainApi with the
983
+ // SAME number of root fields but the WRONG ones (admin-base instead
984
+ // of plugin fields) has a matching count yet is missing every
985
+ // expected plugin field, so a count test would wrongly skip.
986
+ let missingFields = ReventlessCore.GraphQL_Stitcher.missingRootFields(
987
+ ~expectedSdl=sdl,
988
+ ~liveSdl,
989
+ )
960
990
  let skipPush = switch storedHash {
961
991
  | Some(prev) if prev == currentHash =>
962
- let expected = countRoots(sdl)
963
- let live = countRoots(liveSdl)
964
992
  if liveSdl == "" {
965
993
  log.info(
966
994
  ~comp="preResolversSchemaHook",
967
995
  `hash matches but live schema could not be introspected — forcing repair push (check appsync:GetIntrospectionSchema permission)`,
968
996
  )
969
997
  false
970
- } else if live < expected {
998
+ } else if missingFields->Array.length > 0 {
971
999
  log.info(
972
1000
  ~comp="preResolversSchemaHook",
973
- `hash matches but live schema drifted (${live->Int.toString} root field(s) live vs ${expected->Int.toString} expected) — forcing repair push`,
1001
+ `hash matches but live schema is missing ${missingFields
1002
+ ->Array.length
1003
+ ->Int.toString} expected root field(s) (e.g. ${missingFields
1004
+ ->Array.slice(~start=0, ~end=5)
1005
+ ->Array.join(", ")}) — forcing repair push`,
974
1006
  )
975
1007
  false
976
1008
  } else {
977
1009
  log.info(
978
1010
  ~comp="preResolversSchemaHook",
979
- `SDL unchanged (hash ${currentHash->String.slice(~start=0, ~end=12)}…) and live schema intact (${live->Int.toString} root fields); skipping push`,
1011
+ `SDL unchanged (hash ${currentHash->String.slice(
1012
+ ~start=0,
1013
+ ~end=12,
1014
+ )}…) and live schema is a superset of the expected root fields (${countRoots(
1015
+ liveSdl,
1016
+ )->Int.toString} live); skipping push`,
980
1017
  )
981
1018
  true
982
1019
  }
@@ -645,23 +645,12 @@ function MakeWithConfig(Config) {
645
645
  let client = AppSync_Adapter$ReventlessAws.getClient();
646
646
  let liveSdl = await AppSync_Adapter$ReventlessAws.getIntrospectionSdl(client, apiId);
647
647
  let countRoots = s => (GraphQL_Stitcher$ReventlessCore.countRootTypeFields(s, "Mutation") + GraphQL_Stitcher$ReventlessCore.countRootTypeFields(s, "Query") | 0) + GraphQL_Stitcher$ReventlessCore.countRootTypeFields(s, "Subscription") | 0;
648
- let skipPush;
649
- if (storedHash !== undefined && storedHash === currentHash) {
650
- let expected = countRoots(sdl);
651
- let live = countRoots(liveSdl);
652
- if (liveSdl === "") {
653
- log.info("preResolversSchemaHook", undefined, `hash matches but live schema could not be introspected — forcing repair push (check appsync:GetIntrospectionSchema permission)`);
654
- skipPush = false;
655
- } else if (live < expected) {
656
- log.info("preResolversSchemaHook", undefined, `hash matches but live schema drifted (` + live.toString() + ` root field(s) live vs ` + expected.toString() + ` expected) — forcing repair push`);
657
- skipPush = false;
658
- } else {
659
- log.info("preResolversSchemaHook", undefined, `SDL unchanged (hash ` + currentHash.slice(0, 12) + `…) and live schema intact (` + live.toString() + ` root fields); skipping push`);
660
- skipPush = true;
661
- }
662
- } else {
663
- skipPush = false;
664
- }
648
+ let missingFields = GraphQL_Stitcher$ReventlessCore.missingRootFields(sdl, liveSdl);
649
+ let skipPush = storedHash !== undefined && storedHash === currentHash ? (
650
+ liveSdl === "" ? (log.info("preResolversSchemaHook", undefined, `hash matches but live schema could not be introspected — forcing repair push (check appsync:GetIntrospectionSchema permission)`), false) : (
651
+ missingFields.length !== 0 ? (log.info("preResolversSchemaHook", undefined, `hash matches but live schema is missing ` + missingFields.length.toString() + ` expected root field(s) (e.g. ` + missingFields.slice(0, 5).join(", ") + `) — forcing repair push`), false) : (log.info("preResolversSchemaHook", undefined, `SDL unchanged (hash ` + currentHash.slice(0, 12) + `…) and live schema is a superset of the expected root fields (` + countRoots(liveSdl).toString() + ` live); skipping push`), true)
652
+ )
653
+ ) : false;
665
654
  if (!skipPush) {
666
655
  if (GraphQL_Stitcher$ReventlessCore.isCatastrophicSchemaShrink(liveSdl, sdl, deploySchemaShrinkThreshold)) {
667
656
  return log.error("preResolversSchemaHook", undefined, `ABORTED schema push for ` + apiId + `: stitched SDL (` + countRoots(sdl).toString() + ` root field(s)) would catastrophically shrink the live schema (` + countRoots(liveSdl).toString() + ` root field(s), threshold ` + deploySchemaShrinkThreshold.toString() + `) — refusing to clobber resolvers (likely a stale concurrent-deploy scan)`);
@@ -692,24 +681,30 @@ function MakeWithConfig(Config) {
692
681
  };
693
682
  let hooks_preAdminResolversSchemaHook = adminBarrier => {
694
683
  let match = splitApiOutputsRef.contents;
695
- let targetApi = match !== undefined ? match.platformApi : domainApi;
696
- let adminBaseFragment = AppSync_Adapter$ReventlessAws.injectAwsAuthAll(AdminApi$ReventlessCore.baseFragment(Config.cloner), "Admin", undefined);
697
- let sdl = AppSync_Adapter$ReventlessAws.stampSharedIamTypes(GraphQL_Stitcher$ReventlessCore.stitch(adminBaseFragment, []));
698
- return Output$Pulumi.flatMap(Pulumi.all([
699
- targetApi,
700
- adminBarrier
701
- ]), param => Output$Pulumi.flatMap(param[0].id, apiId => {
702
- log.info("preAdminResolversSchemaHook", undefined, `Pushing admin schema to ` + apiId);
703
- let client = AppSync_Adapter$ReventlessAws.getClient();
704
- return AppSync_Adapter$ReventlessAws.startSchemaCreationRetrying(client, {
705
- apiId: apiId,
706
- definition: sdl
707
- }).then(async () => {
708
- log.info("preAdminResolversSchemaHook", undefined, "startSchemaCreation called, waiting for ACTIVE");
709
- await AppSync_Adapter$ReventlessAws.waitForSchemaActive(client, apiId, undefined, undefined);
710
- return log.info("preAdminResolversSchemaHook", undefined, "schema is ACTIVE");
711
- });
712
- }));
684
+ let targetApiOpt = match !== undefined ? match.platformApi : (
685
+ Config.splitApi ? undefined : domainApi
686
+ );
687
+ if (targetApiOpt !== undefined) {
688
+ let adminBaseFragment = AppSync_Adapter$ReventlessAws.injectAwsAuthAll(AdminApi$ReventlessCore.baseFragment(Config.cloner), "Admin", undefined);
689
+ let sdl = AppSync_Adapter$ReventlessAws.stampSharedIamTypes(GraphQL_Stitcher$ReventlessCore.stitch(adminBaseFragment, []));
690
+ return Output$Pulumi.flatMap(Pulumi.all([
691
+ targetApiOpt,
692
+ adminBarrier
693
+ ]), param => Output$Pulumi.flatMap(param[0].id, apiId => {
694
+ log.info("preAdminResolversSchemaHook", undefined, `Pushing admin schema to ` + apiId);
695
+ let client = AppSync_Adapter$ReventlessAws.getClient();
696
+ return AppSync_Adapter$ReventlessAws.startSchemaCreationRetrying(client, {
697
+ apiId: apiId,
698
+ definition: sdl
699
+ }).then(async () => {
700
+ log.info("preAdminResolversSchemaHook", undefined, "startSchemaCreation called, waiting for ACTIVE");
701
+ await AppSync_Adapter$ReventlessAws.waitForSchemaActive(client, apiId, undefined, undefined);
702
+ return log.info("preAdminResolversSchemaHook", undefined, "schema is ACTIVE");
703
+ });
704
+ }));
705
+ }
706
+ log.error("preAdminResolversSchemaHook", undefined, "split mode but the PlatformApi is not available at hook time — SKIPPING the admin schema push to avoid clobbering the DomainApi (pushing admin-base here would wipe every plugin field)");
707
+ return adminBarrier;
713
708
  };
714
709
  let hooks_inboundAppSyncResolverHook = param => InboundTranslationResolvers_AppSync$ReventlessAws.make(resolveHookedApi(), param.runtime, param.fieldNames, param.opts);
715
710
  let hooks_dcbAppSyncResolverHook = param => CommandGeneratorResolvers_AppSync$ReventlessAws.makeDcb(resolveHookedApi(), param.runtime, param.fieldNames, param.tags, param.opts);
@@ -1876,23 +1871,12 @@ function Make($star) {
1876
1871
  let client = AppSync_Adapter$ReventlessAws.getClient();
1877
1872
  let liveSdl = await AppSync_Adapter$ReventlessAws.getIntrospectionSdl(client, apiId);
1878
1873
  let countRoots = s => (GraphQL_Stitcher$ReventlessCore.countRootTypeFields(s, "Mutation") + GraphQL_Stitcher$ReventlessCore.countRootTypeFields(s, "Query") | 0) + GraphQL_Stitcher$ReventlessCore.countRootTypeFields(s, "Subscription") | 0;
1879
- let skipPush;
1880
- if (storedHash !== undefined && storedHash === currentHash) {
1881
- let expected = countRoots(sdl);
1882
- let live = countRoots(liveSdl);
1883
- if (liveSdl === "") {
1884
- log.info("preResolversSchemaHook", undefined, `hash matches but live schema could not be introspected — forcing repair push (check appsync:GetIntrospectionSchema permission)`);
1885
- skipPush = false;
1886
- } else if (live < expected) {
1887
- log.info("preResolversSchemaHook", undefined, `hash matches but live schema drifted (` + live.toString() + ` root field(s) live vs ` + expected.toString() + ` expected) — forcing repair push`);
1888
- skipPush = false;
1889
- } else {
1890
- log.info("preResolversSchemaHook", undefined, `SDL unchanged (hash ` + currentHash.slice(0, 12) + `…) and live schema intact (` + live.toString() + ` root fields); skipping push`);
1891
- skipPush = true;
1892
- }
1893
- } else {
1894
- skipPush = false;
1895
- }
1874
+ let missingFields = GraphQL_Stitcher$ReventlessCore.missingRootFields(sdl, liveSdl);
1875
+ let skipPush = storedHash !== undefined && storedHash === currentHash ? (
1876
+ liveSdl === "" ? (log.info("preResolversSchemaHook", undefined, `hash matches but live schema could not be introspected — forcing repair push (check appsync:GetIntrospectionSchema permission)`), false) : (
1877
+ missingFields.length !== 0 ? (log.info("preResolversSchemaHook", undefined, `hash matches but live schema is missing ` + missingFields.length.toString() + ` expected root field(s) (e.g. ` + missingFields.slice(0, 5).join(", ") + `) — forcing repair push`), false) : (log.info("preResolversSchemaHook", undefined, `SDL unchanged (hash ` + currentHash.slice(0, 12) + `…) and live schema is a superset of the expected root fields (` + countRoots(liveSdl).toString() + ` live); skipping push`), true)
1878
+ )
1879
+ ) : false;
1896
1880
  if (!skipPush) {
1897
1881
  if (GraphQL_Stitcher$ReventlessCore.isCatastrophicSchemaShrink(liveSdl, sdl, deploySchemaShrinkThreshold)) {
1898
1882
  return log.error("preResolversSchemaHook", undefined, `ABORTED schema push for ` + apiId + `: stitched SDL (` + countRoots(sdl).toString() + ` root field(s)) would catastrophically shrink the live schema (` + countRoots(liveSdl).toString() + ` root field(s), threshold ` + deploySchemaShrinkThreshold.toString() + `) — refusing to clobber resolvers (likely a stale concurrent-deploy scan)`);
@@ -1923,24 +1907,28 @@ function Make($star) {
1923
1907
  };
1924
1908
  let hooks_preAdminResolversSchemaHook = adminBarrier => {
1925
1909
  let match = splitApiOutputsRef.contents;
1926
- let targetApi = match !== undefined ? match.platformApi : domainApi;
1927
- let adminBaseFragment = AppSync_Adapter$ReventlessAws.injectAwsAuthAll(AdminApi$ReventlessCore.baseFragment(false), "Admin", undefined);
1928
- let sdl = AppSync_Adapter$ReventlessAws.stampSharedIamTypes(GraphQL_Stitcher$ReventlessCore.stitch(adminBaseFragment, []));
1929
- return Output$Pulumi.flatMap(Pulumi.all([
1930
- targetApi,
1931
- adminBarrier
1932
- ]), param => Output$Pulumi.flatMap(param[0].id, apiId => {
1933
- log.info("preAdminResolversSchemaHook", undefined, `Pushing admin schema to ` + apiId);
1934
- let client = AppSync_Adapter$ReventlessAws.getClient();
1935
- return AppSync_Adapter$ReventlessAws.startSchemaCreationRetrying(client, {
1936
- apiId: apiId,
1937
- definition: sdl
1938
- }).then(async () => {
1939
- log.info("preAdminResolversSchemaHook", undefined, "startSchemaCreation called, waiting for ACTIVE");
1940
- await AppSync_Adapter$ReventlessAws.waitForSchemaActive(client, apiId, undefined, undefined);
1941
- return log.info("preAdminResolversSchemaHook", undefined, "schema is ACTIVE");
1942
- });
1943
- }));
1910
+ let targetApiOpt = match !== undefined ? match.platformApi : undefined;
1911
+ if (targetApiOpt !== undefined) {
1912
+ let adminBaseFragment = AppSync_Adapter$ReventlessAws.injectAwsAuthAll(AdminApi$ReventlessCore.baseFragment(false), "Admin", undefined);
1913
+ let sdl = AppSync_Adapter$ReventlessAws.stampSharedIamTypes(GraphQL_Stitcher$ReventlessCore.stitch(adminBaseFragment, []));
1914
+ return Output$Pulumi.flatMap(Pulumi.all([
1915
+ targetApiOpt,
1916
+ adminBarrier
1917
+ ]), param => Output$Pulumi.flatMap(param[0].id, apiId => {
1918
+ log.info("preAdminResolversSchemaHook", undefined, `Pushing admin schema to ` + apiId);
1919
+ let client = AppSync_Adapter$ReventlessAws.getClient();
1920
+ return AppSync_Adapter$ReventlessAws.startSchemaCreationRetrying(client, {
1921
+ apiId: apiId,
1922
+ definition: sdl
1923
+ }).then(async () => {
1924
+ log.info("preAdminResolversSchemaHook", undefined, "startSchemaCreation called, waiting for ACTIVE");
1925
+ await AppSync_Adapter$ReventlessAws.waitForSchemaActive(client, apiId, undefined, undefined);
1926
+ return log.info("preAdminResolversSchemaHook", undefined, "schema is ACTIVE");
1927
+ });
1928
+ }));
1929
+ }
1930
+ log.error("preAdminResolversSchemaHook", undefined, "split mode but the PlatformApi is not available at hook time — SKIPPING the admin schema push to avoid clobbering the DomainApi (pushing admin-base here would wipe every plugin field)");
1931
+ return adminBarrier;
1944
1932
  };
1945
1933
  let hooks_inboundAppSyncResolverHook = param => InboundTranslationResolvers_AppSync$ReventlessAws.make(resolveHookedApi(), param.runtime, param.fieldNames, param.opts);
1946
1934
  let hooks_dcbAppSyncResolverHook = param => CommandGeneratorResolvers_AppSync$ReventlessAws.makeDcb(resolveHookedApi(), param.runtime, param.fieldNames, param.tags, param.opts);
@@ -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,