@reventlessdev/reventless-seed 1.0.0-alpha.2 → 1.0.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/lib/bs/.compiler.log +2 -2
  3. package/lib/bs/.sourcedirs.json +1 -0
  4. package/lib/bs/ReventlessSeed.cmi +0 -0
  5. package/lib/bs/ReventlessSeed.cmt +0 -0
  6. package/lib/bs/ReventlessSeed.mlmap +5 -0
  7. package/lib/bs/compiler-info.json +2 -2
  8. package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
  9. package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
  10. package/lib/bs/src/Seed.ast +0 -0
  11. package/lib/bs/src/Seed.res +6 -3
  12. package/lib/bs/src/Seed.res.mjs +3 -0
  13. package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
  14. package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
  15. package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
  16. package/lib/bs/src/Seed_Client.ast +0 -0
  17. package/lib/bs/src/Seed_Client.res +228 -15
  18. package/lib/bs/src/Seed_Client.res.mjs +196 -23
  19. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmi +0 -0
  20. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmj +0 -0
  21. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmt +0 -0
  22. package/lib/bs/src/Seed_Connect.ast +0 -0
  23. package/lib/bs/src/Seed_Connect.res +27 -18
  24. package/lib/bs/src/Seed_Connect.res.mjs +10 -6
  25. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmi +0 -0
  26. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmj +0 -0
  27. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmt +0 -0
  28. package/lib/bs/src/Seed_Prompt.ast +0 -0
  29. package/lib/bs/src/Seed_Prompt.res +110 -35
  30. package/lib/bs/src/Seed_Prompt.res.mjs +73 -8
  31. package/lib/bs/src/Seed_Random-ReventlessSeed.cmt +0 -0
  32. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
  33. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmj +0 -0
  34. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
  35. package/lib/bs/src/Seed_Runner.ast +0 -0
  36. package/lib/bs/src/Seed_Runner.res +38 -7
  37. package/lib/bs/src/Seed_Runner.res.mjs +19 -1
  38. package/lib/bs/src/Seed_Types-ReventlessSeed.cmt +0 -0
  39. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
  40. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmj +0 -0
  41. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
  42. package/lib/bs/src/Seed_Upload.ast +0 -0
  43. package/lib/bs/src/Seed_Upload.res +40 -42
  44. package/lib/bs/src/Seed_Upload.res.mjs +65 -69
  45. package/lib/bs/src/Seed_Users-ReventlessSeed.cmi +0 -0
  46. package/lib/bs/src/Seed_Users-ReventlessSeed.cmj +0 -0
  47. package/lib/bs/src/Seed_Users-ReventlessSeed.cmt +0 -0
  48. package/lib/bs/src/Seed_Users.ast +0 -0
  49. package/lib/bs/src/Seed_Users.res +80 -0
  50. package/lib/bs/src/Seed_Users.res.mjs +90 -0
  51. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  52. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  53. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  54. package/lib/bs/tests/Seed_ClientTest.ast +0 -0
  55. package/lib/bs/tests/Seed_ClientTest.res +157 -0
  56. package/lib/bs/tests/Seed_ClientTest.res.mjs +167 -0
  57. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmi +0 -0
  58. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmj +0 -0
  59. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmt +0 -0
  60. package/lib/bs/tests/Seed_PromptTest.ast +0 -0
  61. package/lib/bs/tests/Seed_PromptTest.res +59 -0
  62. package/lib/bs/tests/Seed_PromptTest.res.mjs +63 -0
  63. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmi +0 -0
  64. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmj +0 -0
  65. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  66. package/lib/bs/tests/Seed_RandomTest.ast +0 -0
  67. package/lib/bs/tests/Seed_RandomTest.res +160 -0
  68. package/lib/bs/tests/Seed_RandomTest.res.mjs +208 -0
  69. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmi +0 -0
  70. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmj +0 -0
  71. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmt +0 -0
  72. package/lib/bs/tests/Seed_UsersTest.ast +0 -0
  73. package/lib/bs/tests/Seed_UsersTest.res +92 -0
  74. package/lib/bs/tests/Seed_UsersTest.res.mjs +95 -0
  75. package/lib/ocaml/.compiler.log +2 -2
  76. package/lib/ocaml/ReventlessSeed.cmi +0 -0
  77. package/lib/ocaml/ReventlessSeed.cmt +0 -0
  78. package/lib/ocaml/ReventlessSeed.mlmap +5 -0
  79. package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
  80. package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
  81. package/lib/ocaml/Seed.ast +0 -0
  82. package/lib/ocaml/Seed.res +6 -3
  83. package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
  84. package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
  85. package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
  86. package/lib/ocaml/Seed_Client.ast +0 -0
  87. package/lib/ocaml/Seed_Client.res +228 -15
  88. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  89. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  90. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  91. package/lib/ocaml/Seed_ClientTest.ast +0 -0
  92. package/lib/ocaml/Seed_ClientTest.res +157 -0
  93. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
  94. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmj +0 -0
  95. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
  96. package/lib/ocaml/Seed_Connect.ast +0 -0
  97. package/lib/ocaml/Seed_Connect.res +27 -18
  98. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmi +0 -0
  99. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmj +0 -0
  100. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmt +0 -0
  101. package/lib/ocaml/Seed_Prompt.ast +0 -0
  102. package/lib/ocaml/Seed_Prompt.res +110 -35
  103. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmi +0 -0
  104. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmj +0 -0
  105. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmt +0 -0
  106. package/lib/ocaml/Seed_PromptTest.ast +0 -0
  107. package/lib/ocaml/Seed_PromptTest.res +59 -0
  108. package/lib/ocaml/Seed_Random-ReventlessSeed.cmt +0 -0
  109. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmi +0 -0
  110. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmj +0 -0
  111. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  112. package/lib/ocaml/Seed_RandomTest.ast +0 -0
  113. package/lib/ocaml/Seed_RandomTest.res +160 -0
  114. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
  115. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmj +0 -0
  116. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
  117. package/lib/ocaml/Seed_Runner.ast +0 -0
  118. package/lib/ocaml/Seed_Runner.res +38 -7
  119. package/lib/ocaml/Seed_Types-ReventlessSeed.cmt +0 -0
  120. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
  121. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmj +0 -0
  122. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
  123. package/lib/ocaml/Seed_Upload.ast +0 -0
  124. package/lib/ocaml/Seed_Upload.res +40 -42
  125. package/lib/ocaml/Seed_Users-ReventlessSeed.cmi +0 -0
  126. package/lib/ocaml/Seed_Users-ReventlessSeed.cmj +0 -0
  127. package/lib/ocaml/Seed_Users-ReventlessSeed.cmt +0 -0
  128. package/lib/ocaml/Seed_Users.ast +0 -0
  129. package/lib/ocaml/Seed_Users.res +80 -0
  130. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmi +0 -0
  131. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmj +0 -0
  132. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmt +0 -0
  133. package/lib/ocaml/Seed_UsersTest.ast +0 -0
  134. package/lib/ocaml/Seed_UsersTest.res +92 -0
  135. package/package.json +7 -2
  136. package/rescript.json +9 -1
  137. package/src/Seed.res +6 -3
  138. package/src/Seed.res.mjs +3 -0
  139. package/src/Seed_Client.res +228 -15
  140. package/src/Seed_Client.res.mjs +196 -23
  141. package/src/Seed_Connect.res +27 -18
  142. package/src/Seed_Connect.res.mjs +10 -6
  143. package/src/Seed_Prompt.res +110 -35
  144. package/src/Seed_Prompt.res.mjs +73 -8
  145. package/src/Seed_Runner.res +38 -7
  146. package/src/Seed_Runner.res.mjs +19 -1
  147. package/src/Seed_Upload.res +40 -42
  148. package/src/Seed_Upload.res.mjs +65 -69
  149. package/src/Seed_Users.res +80 -0
  150. package/src/Seed_Users.res.mjs +90 -0
  151. package/tests/Seed_ClientTest.res +157 -0
  152. package/tests/Seed_ClientTest.res.mjs +167 -0
  153. package/tests/Seed_PromptTest.res +59 -0
  154. package/tests/Seed_PromptTest.res.mjs +63 -0
  155. package/tests/Seed_RandomTest.res +160 -0
  156. package/tests/Seed_RandomTest.res.mjs +208 -0
  157. package/tests/Seed_UsersTest.res +92 -0
  158. package/tests/Seed_UsersTest.res.mjs +95 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,143 @@
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
+ # 1.0.0-alpha.20 (2026-09-08)
7
+
8
+ ### Features
9
+
10
+ * **seed:** a run says which identity its token carries, not which one it asked for ([063da61](https://github.com/ReventlessDev/reventless-core/commit/063da6110a2d89333bd3d99f4c7cb1bc579d78f0))
11
+
12
+
13
+ # 1.0.0-alpha.19 (2026-09-04)
14
+
15
+ **Note:** Version bump only for package @reventlessdev/reventless-seed
16
+
17
+
18
+
19
+
20
+
21
+ # 1.0.0-alpha.18 (2026-08-27)
22
+
23
+ ### Bug Fixes
24
+
25
+ * **seed:** one endpoint-side fault throws away every command that landed ([6126dca](https://github.com/ReventlessDev/reventless-core/commit/6126dca7c62d193cf3a4efe379160af8207faa86))
26
+
27
+
28
+ # 1.0.0-alpha.17 (2026-08-20)
29
+
30
+ ### Features
31
+
32
+ * **seed:** choose the login from the platform's users.yaml ([74e61de](https://github.com/ReventlessDev/reventless-core/commit/74e61de8fba3ee4cc62b368ff12010f162d49db7))
33
+
34
+
35
+ # 1.0.0-alpha.16 (2026-08-18)
36
+
37
+ **Note:** Version bump only for package @reventlessdev/reventless-seed
38
+
39
+
40
+
41
+
42
+
43
+ # 1.0.0-alpha.15 (2026-08-16)
44
+
45
+ ### Bug Fixes
46
+
47
+ * **seed:** let seed:reset take the yes it asks for ([1f646f6](https://github.com/ReventlessDev/reventless-core/commit/1f646f6ce13a4238e92b7047ee31650ccc4de4f3))
48
+ * **seed:** report the retirements the projection has actually applied ([91bc877](https://github.com/ReventlessDev/reventless-core/commit/91bc877d90fc69dab76ff0fb9aae9ea989107aed))
49
+
50
+
51
+ # 1.0.0-alpha.14 (2026-08-15)
52
+
53
+ **Note:** Version bump only for package @reventlessdev/reventless-seed
54
+
55
+
56
+
57
+
58
+
59
+ # 1.0.0-alpha.13 (2026-08-14)
60
+
61
+ **Note:** Version bump only for package @reventlessdev/reventless-seed
62
+
63
+
64
+
65
+
66
+
67
+ # 1.0.0-alpha.12 (2026-08-13)
68
+
69
+ **Note:** Version bump only for package @reventlessdev/reventless-seed
70
+
71
+
72
+
73
+
74
+
75
+ # 1.0.0-alpha.11 (2026-08-11)
76
+
77
+ **Note:** Version bump only for package @reventlessdev/reventless-seed
78
+
79
+
80
+
81
+
82
+
83
+ # 1.0.0-alpha.10 (2026-08-09)
84
+
85
+ **Note:** Version bump only for package @reventlessdev/reventless-seed
86
+
87
+
88
+
89
+
90
+
91
+ # 1.0.0-alpha.9 (2026-08-05)
92
+
93
+ **Note:** Version bump only for package @reventlessdev/reventless-seed
94
+
95
+
96
+
97
+
98
+
99
+ # 1.0.0-alpha.8 (2026-08-02)
100
+
101
+ **Note:** Version bump only for package @reventlessdev/reventless-seed
102
+
103
+
104
+
105
+
106
+
107
+ # 1.0.0-alpha.7 (2026-08-01)
108
+
109
+ ### Features
110
+
111
+ * **upload:** add the release half of the upload contract via the domain API ([448f887](https://github.com/ReventlessDev/reventless-core/commit/448f88714a5875be420f794a08d482fcf4ba8404))
112
+
113
+
114
+ # 1.0.0-alpha.6 (2026-07-31)
115
+
116
+ **Note:** Version bump only for package @reventlessdev/reventless-seed
117
+
118
+
119
+
120
+
121
+
122
+ # 1.0.0-alpha.5 (2026-07-31)
123
+
124
+ ### Bug Fixes
125
+
126
+ * **seed:** address a deployment's per-store upload endpoints ([45b226e](https://github.com/ReventlessDev/reventless-core/commit/45b226e00604af9fd28b6fb0b75ba7d3414791f2))
127
+
128
+
129
+ # 1.0.0-alpha.4 (2026-07-27)
130
+
131
+ ### Features
132
+
133
+ * **seed:** guarded AWS store reset (seed:reset) with per-project scope selection ([8446172](https://github.com/ReventlessDev/reventless-core/commit/8446172cbb53776a58f53220d19e96d96ce94508))
134
+
135
+
136
+ # 1.0.0-alpha.3 (2026-07-27)
137
+
138
+ ### Features
139
+
140
+ * **seed:** refuse to seed onto a non-empty store (fresh-store guard) ([987613f](https://github.com/ReventlessDev/reventless-core/commit/987613f0767fc4391297dfeed3a6b19cd76d8157))
141
+
142
+
6
143
  # 1.0.0-alpha.2 (2026-07-26)
7
144
 
8
145
  ### Features
@@ -1,2 +1,2 @@
1
- #Start(1785092483117)
2
- #Done(1785092488019)
1
+ #Start(1788869322834)
2
+ #Done(1788869322875)
@@ -0,0 +1 @@
1
+ {"cmt_scan":[{"also_scan_build_root":true,"build_root":"lib/bs","scan_dirs":["src","tests"]}],"dirs":["src","tests"],"generated":[],"pkgs":[["@reventlessdev/rescript-jest","/home/runner/work/reventless-core/reventless-core/rescript/jest"],["@reventlessdev/rescript-node","/home/runner/work/reventless-core/reventless-core/rescript/node"]],"version":2}
Binary file
Binary file
@@ -1,9 +1,14 @@
1
1
  randjbuildsystem
2
2
  Seed
3
3
  Seed_Client
4
+ Seed_ClientTest
4
5
  Seed_Connect
5
6
  Seed_Prompt
7
+ Seed_PromptTest
6
8
  Seed_Random
9
+ Seed_RandomTest
7
10
  Seed_Runner
8
11
  Seed_Types
9
12
  Seed_Upload
13
+ Seed_Users
14
+ Seed_UsersTest
@@ -2,7 +2,7 @@
2
2
  "version": "12.3.0",
3
3
  "bsc_path": "/home/runner/work/reventless-core/reventless-core/node_modules/@rescript/linux-x64/bin/bsc.exe",
4
4
  "bsc_hash": "75e3a59c95cc953608fd3dd6ea6ed68141bba4414a20a52f114261b8a48385fa",
5
- "rescript_config_hash": "ad3a9d479584c1d6e4fe10cd993d9e290f74732e9b0cb3dd5d7429e88b4bba38",
5
+ "rescript_config_hash": "25cfff836d200084949fdfdfec2f6875901bfee1f04d84634eca252ef1ca1ef1",
6
6
  "runtime_path": "/home/runner/work/reventless-core/reventless-core/node_modules/@rescript/runtime",
7
- "generated_at": "1785092488035"
7
+ "generated_at": "1788869322878"
8
8
  }
Binary file
Binary file
Binary file
@@ -10,15 +10,17 @@ module Client = Seed_Client
10
10
  module Runner = Seed_Runner
11
11
  module Upload = Seed_Upload
12
12
  module Prompt = Seed_Prompt
13
+ module Users = Seed_Users
13
14
  module Connect = Seed_Connect
14
15
 
15
16
  exception Failed = Seed_Types.Failed
16
17
 
17
- // A live target a data set seeds against: an authenticated client, the upload
18
- // endpoint (empty when the deployment serves no uploads), and a label.
18
+ // A live target a data set seeds against: an authenticated client, whether uploads
19
+ // are skipped this run, and a label. Uploads mint through the domain API's
20
+ // `Upload_Presign` mutation on the client (route B), so no upload endpoint is carried.
19
21
  type connection = Seed_Connect.connection = {
20
22
  client: Seed_Client.t,
21
- uploadEndpoint: string,
23
+ uploadsSkipped: bool,
22
24
  label: string,
23
25
  }
24
26
 
@@ -27,6 +29,7 @@ type dataSet = Seed_Runner.dataSet = {
27
29
  name: string,
28
30
  label: string,
29
31
  seed: connection => promise<unit>,
32
+ probeViews?: array<string>,
30
33
  }
31
34
 
32
35
  type rec value = Seed_Types.value =
@@ -12,6 +12,8 @@ let Upload;
12
12
 
13
13
  let Prompt;
14
14
 
15
+ let Users;
16
+
15
17
  let Connect;
16
18
 
17
19
  let Failed = Seed_Types$ReventlessSeed.Failed;
@@ -30,6 +32,7 @@ export {
30
32
  Runner,
31
33
  Upload,
32
34
  Prompt,
35
+ Users,
33
36
  Connect,
34
37
  Failed,
35
38
  mutation,
Binary file
@@ -43,11 +43,96 @@ let make = (~config: config): t => {config, token: None}
43
43
  Seed.Upload) can authenticate side-channel requests with the same identity. */
44
44
  let currentToken = (t: t): option<string> => t.token
45
45
 
46
+ /** The GraphQL endpoint this client targets — so `Seed.Upload` can resolve a
47
+ relative presigned `uploadUrl` against the same origin. */
48
+ let endpoint = (t: t): string => t.config.endpoint
49
+
46
50
  /** Injects a bearer obtained out-of-band (e.g. a Cognito id token), instead of
47
51
  minting one via `login`. Provider-agnostic: the harness only cares that the
48
52
  token is a valid bearer for `endpoint`. */
49
53
  let useToken = (t: t, token: string): unit => t.token = Some(token)
50
54
 
55
+ // ── What the bearer actually grants ─────────────────────────────────────────
56
+ //
57
+ // An account's listed groups are not necessarily the ones its token carries:
58
+ // both platforms narrow a token to the single role the caller last chose to act
59
+ // as. So a seed can log in as an account that looks eligible and be refused, and
60
+ // the account list cannot explain it — only the token can.
61
+
62
+ /** Claim names a narrowed token carries. These mirror the cross-provider
63
+ contract in `ReventlessCore.Auth_ActiveRole`; spelled out rather than
64
+ imported because this harness deliberately depends on no framework package. */
65
+ let activeRoleClaim = "activeRole"
66
+ let availableRolesClaim = "availableRoles"
67
+
68
+ /** Group claim per provider, tried in order. */
69
+ let groupClaimNames = ["cognito:groups", "groups"]
70
+
71
+ let decodeSegment = (segment: string): option<dict<JSON.t>> =>
72
+ try {
73
+ segment
74
+ ->NodeBuffer.fromStringBase64Url
75
+ ->NodeBuffer.toStringUtf8
76
+ ->JSON.parseOrThrow
77
+ ->JSON.Decode.object
78
+ } catch {
79
+ | _ => None
80
+ }
81
+
82
+ /** The bearer's payload, from whichever segment carries it — a Cognito JWT puts
83
+ it second and a local dev token first, so trying each in turn saves this from
84
+ having to know which platform signed the token. A segment that is not a JSON
85
+ object (a signature) simply does not decode. */
86
+ let claims = (t: t): option<dict<JSON.t>> =>
87
+ t.token->Option.flatMap(token =>
88
+ token
89
+ ->String.split(".")
90
+ ->Array.reduce(None, (found, segment) =>
91
+ switch found {
92
+ | Some(_) => found
93
+ | None => decodeSegment(segment)
94
+ }
95
+ )
96
+ )
97
+
98
+ // Arrays on the group claim, comma-joined on `availableRoles`.
99
+ let asStrings = (value: JSON.t): option<array<string>> =>
100
+ switch value {
101
+ | Array(entries) => Some(entries->Array.filterMap(JSON.Decode.string))
102
+ | String(joined) =>
103
+ Some(joined->String.split(",")->Array.map(String.trim)->Array.filter(s => s != ""))
104
+ | _ => None
105
+ }
106
+
107
+ let claimStrings = (t: t, name: string): option<array<string>> =>
108
+ t->claims->Option.flatMap(c => c->Dict.get(name))->Option.flatMap(asStrings)
109
+
110
+ /** The groups the bearer actually presents — what every enforcement point reads.
111
+ `None` when the token is opaque to us, which is not an error: it just means
112
+ this cannot add anything. */
113
+ let effectiveGroups = (t: t): option<array<string>> =>
114
+ groupClaimNames->Array.reduce(None, (found, name) =>
115
+ switch found {
116
+ | Some(_) => found
117
+ | None => t->claimStrings(name)
118
+ }
119
+ )
120
+
121
+ /** One line naming the identity the bearer carries, and — when it was narrowed —
122
+ the membership it was narrowed from. The second half is the point: without it
123
+ a refusal looks like a misconfigured grant rather than a role switch. */
124
+ let identitySummary = (t: t): option<string> =>
125
+ t
126
+ ->effectiveGroups
127
+ ->Option.map(groups => {
128
+ let held = groups->Array.length == 0 ? "no groups" : groups->Array.join(", ")
129
+ switch t->claimStrings(availableRolesClaim) {
130
+ | Some(available) if available->Array.length > 0 =>
131
+ `${held} — narrowed to this role from ${available->Array.join(", ")}`
132
+ | _ => held
133
+ }
134
+ })
135
+
51
136
  // ── JSON helpers ────────────────────────────────────────────────────────────
52
137
 
53
138
  let field = (json: JSON.t, key: string): option<JSON.t> =>
@@ -102,10 +187,72 @@ let login = async (t: t): unit => {
102
187
  t.token = json->field("token")->Option.flatMap(asString)
103
188
  }
104
189
 
190
+ /** Error types the endpoint returns for a fault on its own side rather than a
191
+ problem with the document. They say nothing about the request, so the same
192
+ request sent again can succeed — and across the few hundred commands a seed
193
+ issues, one of these is ordinary rather than exceptional.
194
+
195
+ Everything else — a validation error, an unknown field, a refused token — is
196
+ an answer, and the same answer comes back however many times it is asked. */
197
+ let transientErrorTypes = [
198
+ "InternalFailure",
199
+ "ServiceUnavailable",
200
+ "ServiceUnavailableError",
201
+ "Throttling",
202
+ "ThrottlingException",
203
+ "TooManyRequestsException",
204
+ "RequestTimeout",
205
+ ]
206
+
207
+ /** Every entry must be transient for the document to be worth resending: one
208
+ real error alongside a transient one still means the request was wrong. An
209
+ empty `errors` array is not a reason to retry either. */
210
+ let isTransient = (errors: JSON.t): bool =>
211
+ switch errors->JSON.Decode.array {
212
+ | None | Some([]) => false
213
+ | Some(entries) =>
214
+ entries->Array.every(e =>
215
+ switch e->nodeString("errorType") {
216
+ | Some(errorType) => transientErrorTypes->Array.includes(errorType)
217
+ | None => false
218
+ }
219
+ )
220
+ }
221
+
222
+ /** An authorization refusal, in either platform's vocabulary — AppSync answers
223
+ `errorType: "Unauthorized"`, the local server carries it in the message. */
224
+ let isDenied = (errors: JSON.t): bool =>
225
+ switch errors->JSON.Decode.array {
226
+ | None => false
227
+ | Some(entries) =>
228
+ entries->Array.some(e =>
229
+ switch (e->nodeString("errorType"), e->nodeString("message")) {
230
+ | (Some(errorType), _) if errorType->String.includes("Unauthorized") => true
231
+ | (_, Some(message)) => message->String.includes("Not Authorized")
232
+ | _ => false
233
+ }
234
+ )
235
+ }
236
+
237
+ let attempts = 4
238
+
105
239
  /**
106
240
  * Sends one GraphQL document and returns its `data`. A GraphQL-level error
107
241
  * aborts the run: a half-seeded store is worse than an empty one, because it
108
242
  * looks like a working dataset.
243
+ *
244
+ * Which is exactly why a fault on the endpoint's own side must not abort it. A
245
+ * seed issues hundreds of commands, many of them concurrently and all of them
246
+ * against runtimes that a preceding reset left cold, so a single
247
+ * `InternalFailure` somewhere in the batch is a normal event. Treating it as
248
+ * fatal threw away every command that had already landed and demanded a full
249
+ * wipe and re-run — for a request that would have succeeded on being asked
250
+ * again. So resend those, backing off between tries, and keep failing fast on
251
+ * everything that is an answer rather than a fault.
252
+ *
253
+ * The backoff is jittered because the commands go out in concurrent batches: a
254
+ * fixed delay would have the whole batch retry in lockstep and rebuild the same
255
+ * burst that provoked the fault.
109
256
  */
110
257
  let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
111
258
  let headers = Dict.fromArray([("content-type", "application/json")])
@@ -116,18 +263,38 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
116
263
  let body = JSON.stringify(
117
264
  JSON.Encode.object(Dict.fromArray([("query", JSON.Encode.string(query))])),
118
265
  )
119
- let res = try await fetch(t.config.endpoint, {method: "POST", headers, body}) catch {
120
- | _ =>
121
- throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
122
- }
123
- let json = await res->responseJson
124
- switch json->field("errors") {
125
- | Some(errors) =>
126
- throw(
127
- Failed(`${label} failed\n query: ${query}\n response: ${JSON.stringify(errors)}`),
128
- )
129
- | None => json->field("data")->Option.getOr(JSON.Encode.null)
266
+ let rec attempt = async (n: int): JSON.t => {
267
+ let res = try await fetch(t.config.endpoint, {method: "POST", headers, body}) catch {
268
+ | _ =>
269
+ throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
270
+ }
271
+ let json = await res->responseJson
272
+ switch json->field("errors") {
273
+ | Some(errors) =>
274
+ if errors->isTransient && n < attempts {
275
+ await sleep(250 * Int.shiftLeft(1, n - 1) + Int.fromFloat(Math.random() *. 250.))
276
+ await attempt(n + 1)
277
+ } else {
278
+ let tried = n > 1 ? `\n gave up after ${n->Int.toString} attempts` : ""
279
+ // A refusal is the one failure the response cannot explain on its own:
280
+ // it names the field, never the caller. The account list can't either —
281
+ // it holds membership, and a narrowed token carries less.
282
+ let identity = switch isDenied(errors) ? t->identitySummary : None {
283
+ | Some(summary) => `\n identity: ${summary}`
284
+ | None => ""
285
+ }
286
+ throw(
287
+ Failed(
288
+ `${label} failed\n query: ${query}\n response: ${JSON.stringify(
289
+ errors,
290
+ )}${identity}${tried}`,
291
+ ),
292
+ )
293
+ }
294
+ | None => json->field("data")->Option.getOr(JSON.Encode.null)
295
+ }
130
296
  }
297
+ await attempt(1)
131
298
  }
132
299
 
133
300
  // ── Commands ────────────────────────────────────────────────────────────────
@@ -175,18 +342,27 @@ let sendAll = async (t: t, mutations: array<mutation>): unit =>
175
342
  * Walks a Relay-style connection to the end. Connections page at 50 by default,
176
343
  * so any count taken from a single request silently truncates.
177
344
  */
178
- let queryAllNodes = async (t: t, ~field as fieldName: string, ~selection: string): array<
179
- JSON.t,
180
- > => {
345
+ // `~args` is spliced into the connection's argument list — `"includeRetired: true"`
346
+ // is the case it exists for, and the only way to read rows the resolvers withhold
347
+ // by default. Literal GraphQL rather than a typed argument list because a seed
348
+ // writes the query it means; the alternative is a builder that has to grow a case
349
+ // per argument the platform adds.
350
+ let queryAllNodes = async (
351
+ t: t,
352
+ ~field as fieldName: string,
353
+ ~selection: string,
354
+ ~args: string="",
355
+ ): array<JSON.t> => {
181
356
  let nodes = []
182
357
  let after = ref(None)
183
358
  let more = ref(true)
359
+ let extra = args == "" ? "" : `, ${args}`
184
360
  while more.contents {
185
361
  let cursor = switch after.contents {
186
362
  | Some(c) => `, after: ${quote(c)}`
187
363
  | None => ""
188
364
  }
189
- let query = `{ ${fieldName}(first: 100${cursor}) { edges { node { ${selection} } } pageInfo { hasNextPage endCursor } } }`
365
+ let query = `{ ${fieldName}(first: 100${extra}${cursor}) { edges { node { ${selection} } } pageInfo { hasNextPage endCursor } } }`
190
366
  let data = await gql(t, ~query, ~label=fieldName)
191
367
  let connection = data->field(fieldName)->Option.getOr(JSON.Encode.null)
192
368
  switch connection->field("edges") {
@@ -216,6 +392,43 @@ let queryAllNodes = async (t: t, ~field as fieldName: string, ~selection: string
216
392
  let countNodes = async (t: t, ~field: string): int =>
217
393
  (await queryAllNodes(t, ~field, ~selection="id"))->Array.length
218
394
 
395
+ /**
396
+ * Polls a connection field until the collected nodes satisfy `satisfied`, or the
397
+ * timeout elapses. Read models are eventually consistent: a view queried the
398
+ * instant after the writes that feed it can still read empty or partial (a
399
+ * DynamoDB connection scan is eventually consistent by default, and the write and
400
+ * read-back can land on different replicas), so a cross-check that asserts on a
401
+ * single immediate read races the projection. Same rationale as `waitForIds`,
402
+ * for the "observe the whole connection" case rather than a fixed id set.
403
+ *
404
+ * On timeout, throws `Failed(onTimeout(finalNodes))` so the caller can report
405
+ * what the view actually settled on — distinguishing a still-empty view from a
406
+ * genuine count mismatch — instead of a bare assertion.
407
+ */
408
+ let queryAllNodesUntil = async (
409
+ t: t,
410
+ ~field: string,
411
+ ~selection: string,
412
+ ~args: string="",
413
+ ~satisfied: array<JSON.t> => bool,
414
+ ~onTimeout: array<JSON.t> => string,
415
+ ~timeoutMs: int=60000,
416
+ ): array<JSON.t> => {
417
+ let deadline = nowMs() +. Int.toFloat(timeoutMs)
418
+ let result = ref(None)
419
+ while result.contents == None {
420
+ let nodes = await queryAllNodes(t, ~field, ~selection, ~args)
421
+ if satisfied(nodes) {
422
+ result := Some(nodes)
423
+ } else if nowMs() > deadline {
424
+ throw(Failed(onTimeout(nodes)))
425
+ } else {
426
+ await sleep(500)
427
+ }
428
+ }
429
+ result.contents->Option.getOr([])
430
+ }
431
+
219
432
  /**
220
433
  * Polls a `<View>ByIds` field until every id is present.
221
434
  *