@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.
- package/CHANGELOG.md +137 -0
- package/lib/bs/.compiler.log +2 -2
- package/lib/bs/.sourcedirs.json +1 -0
- package/lib/bs/ReventlessSeed.cmi +0 -0
- package/lib/bs/ReventlessSeed.cmt +0 -0
- package/lib/bs/ReventlessSeed.mlmap +5 -0
- package/lib/bs/compiler-info.json +2 -2
- package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed.ast +0 -0
- package/lib/bs/src/Seed.res +6 -3
- package/lib/bs/src/Seed.res.mjs +3 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Client.ast +0 -0
- package/lib/bs/src/Seed_Client.res +228 -15
- package/lib/bs/src/Seed_Client.res.mjs +196 -23
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Connect.ast +0 -0
- package/lib/bs/src/Seed_Connect.res +27 -18
- package/lib/bs/src/Seed_Connect.res.mjs +10 -6
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Prompt.ast +0 -0
- package/lib/bs/src/Seed_Prompt.res +110 -35
- package/lib/bs/src/Seed_Prompt.res.mjs +73 -8
- package/lib/bs/src/Seed_Random-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Runner.ast +0 -0
- package/lib/bs/src/Seed_Runner.res +38 -7
- package/lib/bs/src/Seed_Runner.res.mjs +19 -1
- package/lib/bs/src/Seed_Types-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Upload.ast +0 -0
- package/lib/bs/src/Seed_Upload.res +40 -42
- package/lib/bs/src/Seed_Upload.res.mjs +65 -69
- package/lib/bs/src/Seed_Users-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Users-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Users-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Users.ast +0 -0
- package/lib/bs/src/Seed_Users.res +80 -0
- package/lib/bs/src/Seed_Users.res.mjs +90 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_ClientTest.ast +0 -0
- package/lib/bs/tests/Seed_ClientTest.res +157 -0
- package/lib/bs/tests/Seed_ClientTest.res.mjs +167 -0
- package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_PromptTest.ast +0 -0
- package/lib/bs/tests/Seed_PromptTest.res +59 -0
- package/lib/bs/tests/Seed_PromptTest.res.mjs +63 -0
- package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_RandomTest.ast +0 -0
- package/lib/bs/tests/Seed_RandomTest.res +160 -0
- package/lib/bs/tests/Seed_RandomTest.res.mjs +208 -0
- package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_UsersTest.ast +0 -0
- package/lib/bs/tests/Seed_UsersTest.res +92 -0
- package/lib/bs/tests/Seed_UsersTest.res.mjs +95 -0
- package/lib/ocaml/.compiler.log +2 -2
- package/lib/ocaml/ReventlessSeed.cmi +0 -0
- package/lib/ocaml/ReventlessSeed.cmt +0 -0
- package/lib/ocaml/ReventlessSeed.mlmap +5 -0
- package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed.ast +0 -0
- package/lib/ocaml/Seed.res +6 -3
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Client.ast +0 -0
- package/lib/ocaml/Seed_Client.res +228 -15
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_ClientTest.ast +0 -0
- package/lib/ocaml/Seed_ClientTest.res +157 -0
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Connect.ast +0 -0
- package/lib/ocaml/Seed_Connect.res +27 -18
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Prompt.ast +0 -0
- package/lib/ocaml/Seed_Prompt.res +110 -35
- package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_PromptTest.ast +0 -0
- package/lib/ocaml/Seed_PromptTest.res +59 -0
- package/lib/ocaml/Seed_Random-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_RandomTest.ast +0 -0
- package/lib/ocaml/Seed_RandomTest.res +160 -0
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Runner.ast +0 -0
- package/lib/ocaml/Seed_Runner.res +38 -7
- package/lib/ocaml/Seed_Types-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Upload.ast +0 -0
- package/lib/ocaml/Seed_Upload.res +40 -42
- package/lib/ocaml/Seed_Users-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Users-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Users-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Users.ast +0 -0
- package/lib/ocaml/Seed_Users.res +80 -0
- package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_UsersTest.ast +0 -0
- package/lib/ocaml/Seed_UsersTest.res +92 -0
- package/package.json +7 -2
- package/rescript.json +9 -1
- package/src/Seed.res +6 -3
- package/src/Seed.res.mjs +3 -0
- package/src/Seed_Client.res +228 -15
- package/src/Seed_Client.res.mjs +196 -23
- package/src/Seed_Connect.res +27 -18
- package/src/Seed_Connect.res.mjs +10 -6
- package/src/Seed_Prompt.res +110 -35
- package/src/Seed_Prompt.res.mjs +73 -8
- package/src/Seed_Runner.res +38 -7
- package/src/Seed_Runner.res.mjs +19 -1
- package/src/Seed_Upload.res +40 -42
- package/src/Seed_Upload.res.mjs +65 -69
- package/src/Seed_Users.res +80 -0
- package/src/Seed_Users.res.mjs +90 -0
- package/tests/Seed_ClientTest.res +157 -0
- package/tests/Seed_ClientTest.res.mjs +167 -0
- package/tests/Seed_PromptTest.res +59 -0
- package/tests/Seed_PromptTest.res.mjs +63 -0
- package/tests/Seed_RandomTest.res +160 -0
- package/tests/Seed_RandomTest.res.mjs +208 -0
- package/tests/Seed_UsersTest.res +92 -0
- package/tests/Seed_UsersTest.res.mjs +95 -0
package/src/Seed_Client.res
CHANGED
|
@@ -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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
*
|
package/src/Seed_Client.res.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
3
4
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
6
|
import * as Seed_Types$ReventlessSeed from "./Seed_Types.res.mjs";
|
|
@@ -21,10 +22,77 @@ function currentToken(t) {
|
|
|
21
22
|
return t.token;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
function endpoint(t) {
|
|
26
|
+
return t.config.endpoint;
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
function useToken(t, token) {
|
|
25
30
|
t.token = token;
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
let availableRolesClaim = "availableRoles";
|
|
34
|
+
|
|
35
|
+
let groupClaimNames = [
|
|
36
|
+
"cognito:groups",
|
|
37
|
+
"groups"
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function decodeSegment(segment) {
|
|
41
|
+
try {
|
|
42
|
+
return Stdlib_JSON.Decode.object(JSON.parse(Buffer.from(segment, "base64url").toString("utf8")));
|
|
43
|
+
} catch (exn) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function claims(t) {
|
|
49
|
+
return Stdlib_Option.flatMap(t.token, token => Stdlib_Array.reduce(token.split("."), undefined, (found, segment) => {
|
|
50
|
+
if (found !== undefined) {
|
|
51
|
+
return found;
|
|
52
|
+
} else {
|
|
53
|
+
return decodeSegment(segment);
|
|
54
|
+
}
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function asStrings(value) {
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
return Stdlib_Array.filterMap(value, Stdlib_JSON.Decode.string);
|
|
61
|
+
}
|
|
62
|
+
switch (typeof value) {
|
|
63
|
+
case "string" :
|
|
64
|
+
return value.split(",").map(prim => prim.trim()).filter(s => s !== "");
|
|
65
|
+
default:
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function claimStrings(t, name) {
|
|
71
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(claims(t), c => c[name]), asStrings);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function effectiveGroups(t) {
|
|
75
|
+
return Stdlib_Array.reduce(groupClaimNames, undefined, (found, name) => {
|
|
76
|
+
if (found !== undefined) {
|
|
77
|
+
return found;
|
|
78
|
+
} else {
|
|
79
|
+
return claimStrings(t, name);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function identitySummary(t) {
|
|
85
|
+
return Stdlib_Option.map(effectiveGroups(t), groups => {
|
|
86
|
+
let held = groups.length === 0 ? "no groups" : groups.join(", ");
|
|
87
|
+
let available = claimStrings(t, availableRolesClaim);
|
|
88
|
+
if (available !== undefined && available.length !== 0) {
|
|
89
|
+
return held + ` — narrowed to this role from ` + available.join(", ");
|
|
90
|
+
} else {
|
|
91
|
+
return held;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
28
96
|
function field(json, key) {
|
|
29
97
|
if (typeof json === "object" && json !== null && !Array.isArray(json)) {
|
|
30
98
|
return json[key];
|
|
@@ -94,6 +162,57 @@ async function login(t) {
|
|
|
94
162
|
t.token = Stdlib_Option.flatMap(field(json, "token"), asString);
|
|
95
163
|
}
|
|
96
164
|
|
|
165
|
+
let transientErrorTypes = [
|
|
166
|
+
"InternalFailure",
|
|
167
|
+
"ServiceUnavailable",
|
|
168
|
+
"ServiceUnavailableError",
|
|
169
|
+
"Throttling",
|
|
170
|
+
"ThrottlingException",
|
|
171
|
+
"TooManyRequestsException",
|
|
172
|
+
"RequestTimeout"
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
function isTransient(errors) {
|
|
176
|
+
let entries = Stdlib_JSON.Decode.array(errors);
|
|
177
|
+
if (entries !== undefined && entries.length !== 0) {
|
|
178
|
+
return entries.every(e => {
|
|
179
|
+
let errorType = nodeString(e, "errorType");
|
|
180
|
+
if (errorType !== undefined) {
|
|
181
|
+
return transientErrorTypes.includes(errorType);
|
|
182
|
+
} else {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
} else {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isDenied(errors) {
|
|
192
|
+
let entries = Stdlib_JSON.Decode.array(errors);
|
|
193
|
+
if (entries !== undefined) {
|
|
194
|
+
return entries.some(e => {
|
|
195
|
+
let match = nodeString(e, "errorType");
|
|
196
|
+
let match$1 = nodeString(e, "message");
|
|
197
|
+
if (match !== undefined) {
|
|
198
|
+
if (match.includes("Unauthorized")) {
|
|
199
|
+
return true;
|
|
200
|
+
} else if (match$1 !== undefined) {
|
|
201
|
+
return match$1.includes("Not Authorized");
|
|
202
|
+
} else {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
} else if (match$1 !== undefined) {
|
|
206
|
+
return match$1.includes("Not Authorized");
|
|
207
|
+
} else {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
} else {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
97
216
|
async function gql(t, query, label) {
|
|
98
217
|
let headers = Object.fromEntries([[
|
|
99
218
|
"content-type",
|
|
@@ -107,30 +226,40 @@ async function gql(t, query, label) {
|
|
|
107
226
|
"query",
|
|
108
227
|
query
|
|
109
228
|
]]));
|
|
110
|
-
let
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
229
|
+
let attempt = async n => {
|
|
230
|
+
let res;
|
|
231
|
+
try {
|
|
232
|
+
res = await fetch(t.config.endpoint, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: headers,
|
|
235
|
+
body: body
|
|
236
|
+
});
|
|
237
|
+
} catch (exn) {
|
|
238
|
+
throw {
|
|
239
|
+
RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
|
|
240
|
+
_1: label + `: cannot reach ` + t.config.endpoint + ` — is the platform running?`,
|
|
241
|
+
Error: new Error()
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
let json = await res.json();
|
|
245
|
+
let errors = field(json, "errors");
|
|
246
|
+
if (errors === undefined) {
|
|
247
|
+
return Stdlib_Option.getOr(field(json, "data"), null);
|
|
248
|
+
}
|
|
249
|
+
if (isTransient(errors) && n < 4) {
|
|
250
|
+
await sleep((250 * (1 << (n - 1 | 0)) | 0) + (Math.random() * 250 | 0) | 0);
|
|
251
|
+
return await attempt(n + 1 | 0);
|
|
252
|
+
}
|
|
253
|
+
let tried = n > 1 ? `\n gave up after ` + n.toString() + ` attempts` : "";
|
|
254
|
+
let summary = isDenied(errors) ? identitySummary(t) : undefined;
|
|
255
|
+
let identity = summary !== undefined ? `\n identity: ` + summary : "";
|
|
127
256
|
throw {
|
|
128
257
|
RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
|
|
129
|
-
_1: label + ` failed\n query: ` + query + `\n response: ` + JSON.stringify(errors),
|
|
258
|
+
_1: label + ` failed\n query: ` + query + `\n response: ` + JSON.stringify(errors) + identity + tried,
|
|
130
259
|
Error: new Error()
|
|
131
260
|
};
|
|
132
|
-
}
|
|
133
|
-
return
|
|
261
|
+
};
|
|
262
|
+
return await attempt(1);
|
|
134
263
|
}
|
|
135
264
|
|
|
136
265
|
let commandResultSelection = `__typename
|
|
@@ -171,14 +300,16 @@ async function sendAll(t, mutations) {
|
|
|
171
300
|
}
|
|
172
301
|
}
|
|
173
302
|
|
|
174
|
-
async function queryAllNodes(t, fieldName, selection) {
|
|
303
|
+
async function queryAllNodes(t, fieldName, selection, argsOpt) {
|
|
304
|
+
let args = argsOpt !== undefined ? argsOpt : "";
|
|
175
305
|
let nodes = [];
|
|
176
306
|
let after;
|
|
177
307
|
let more = true;
|
|
308
|
+
let extra = args === "" ? "" : `, ` + args;
|
|
178
309
|
while (more) {
|
|
179
310
|
let c = after;
|
|
180
311
|
let cursor = c !== undefined ? `, after: ` + Seed_Types$ReventlessSeed.quote(c) : "";
|
|
181
|
-
let query = `{ ` + fieldName + `(first: 100` + cursor + `) { edges { node { ` + selection + ` } } pageInfo { hasNextPage endCursor } } }`;
|
|
312
|
+
let query = `{ ` + fieldName + `(first: 100` + extra + cursor + `) { edges { node { ` + selection + ` } } pageInfo { hasNextPage endCursor } } }`;
|
|
182
313
|
let data = await gql(t, query, fieldName);
|
|
183
314
|
let connection = Stdlib_Option.getOr(field(data, fieldName), null);
|
|
184
315
|
let match = field(connection, "edges");
|
|
@@ -205,7 +336,30 @@ async function queryAllNodes(t, fieldName, selection) {
|
|
|
205
336
|
}
|
|
206
337
|
|
|
207
338
|
async function countNodes(t, field) {
|
|
208
|
-
return (await queryAllNodes(t, field, "id")).length;
|
|
339
|
+
return (await queryAllNodes(t, field, "id", undefined)).length;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function queryAllNodesUntil(t, field, selection, argsOpt, satisfied, onTimeout, timeoutMsOpt) {
|
|
343
|
+
let args = argsOpt !== undefined ? argsOpt : "";
|
|
344
|
+
let timeoutMs = timeoutMsOpt !== undefined ? timeoutMsOpt : 60000;
|
|
345
|
+
let deadline = Date.now() + timeoutMs;
|
|
346
|
+
let result;
|
|
347
|
+
while (result === undefined) {
|
|
348
|
+
let nodes = await queryAllNodes(t, field, selection, args);
|
|
349
|
+
if (satisfied(nodes)) {
|
|
350
|
+
result = nodes;
|
|
351
|
+
} else {
|
|
352
|
+
if (Date.now() > deadline) {
|
|
353
|
+
throw {
|
|
354
|
+
RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
|
|
355
|
+
_1: onTimeout(nodes),
|
|
356
|
+
Error: new Error()
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
await sleep(500);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
return Stdlib_Option.getOr(result, []);
|
|
209
363
|
}
|
|
210
364
|
|
|
211
365
|
async function waitForIds(t, fieldName, ids, timeoutMsOpt) {
|
|
@@ -240,21 +394,40 @@ async function waitForIds(t, fieldName, ids, timeoutMsOpt) {
|
|
|
240
394
|
return Stdlib_Option.getOr(result, 0);
|
|
241
395
|
}
|
|
242
396
|
|
|
397
|
+
let activeRoleClaim = "activeRole";
|
|
398
|
+
|
|
399
|
+
let attempts = 4;
|
|
400
|
+
|
|
243
401
|
export {
|
|
244
402
|
sleep,
|
|
245
403
|
make,
|
|
246
404
|
currentToken,
|
|
405
|
+
endpoint,
|
|
247
406
|
useToken,
|
|
407
|
+
activeRoleClaim,
|
|
408
|
+
availableRolesClaim,
|
|
409
|
+
groupClaimNames,
|
|
410
|
+
decodeSegment,
|
|
411
|
+
claims,
|
|
412
|
+
asStrings,
|
|
413
|
+
claimStrings,
|
|
414
|
+
effectiveGroups,
|
|
415
|
+
identitySummary,
|
|
248
416
|
field,
|
|
249
417
|
asString,
|
|
250
418
|
nodeString,
|
|
251
419
|
login,
|
|
420
|
+
transientErrorTypes,
|
|
421
|
+
isTransient,
|
|
422
|
+
isDenied,
|
|
423
|
+
attempts,
|
|
252
424
|
gql,
|
|
253
425
|
commandResultSelection,
|
|
254
426
|
send,
|
|
255
427
|
sendAll,
|
|
256
428
|
queryAllNodes,
|
|
257
429
|
countNodes,
|
|
430
|
+
queryAllNodesUntil,
|
|
258
431
|
waitForIds,
|
|
259
432
|
}
|
|
260
433
|
/* No side effect */
|
package/src/Seed_Connect.res
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
// Turns endpoints plus a login function into a ready `connection`.
|
|
2
2
|
//
|
|
3
3
|
// A connection is what a data set seeds against: an authenticated GraphQL
|
|
4
|
-
// client, the upload
|
|
5
|
-
// a human label for the target. `make` is provider-agnostic — it prompts for
|
|
4
|
+
// client, the deployment's upload endpoints (empty when it serves no uploads),
|
|
5
|
+
// and a human label for the target. `make` is provider-agnostic — it prompts for
|
|
6
6
|
// credentials, calls the supplied `login` to obtain a bearer, and wires it onto
|
|
7
7
|
// the client via `useToken`. Where the bearer comes from (a local `/login`
|
|
8
8
|
// round-trip, a Cognito id token) is the caller's concern.
|
|
9
9
|
|
|
10
10
|
open Seed_Types
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// Under route B the client mints uploads through the domain API's `Upload_Presign`
|
|
13
|
+
// mutation (authenticated by the same bearer as commands), so a connection needs no
|
|
14
|
+
// upload endpoint — just the authenticated client and whether uploads are skipped this
|
|
15
|
+
// run (`SEED_SKIP_UPLOADS`). Which store an asset uploads into is the caller's `~store`.
|
|
16
|
+
type connection = {
|
|
17
|
+
client: Seed_Client.t,
|
|
18
|
+
uploadsSkipped: bool,
|
|
19
|
+
label: string,
|
|
20
|
+
}
|
|
13
21
|
|
|
14
22
|
/**
|
|
15
23
|
* Prompts for credentials, calls `login` to mint a bearer, and returns a
|
|
@@ -19,22 +27,25 @@ type connection = {client: Seed_Client.t, uploadEndpoint: string, label: string}
|
|
|
19
27
|
let make = async (
|
|
20
28
|
~label: string,
|
|
21
29
|
~endpoint: string,
|
|
22
|
-
~uploadEndpoint: string,
|
|
23
30
|
~login: (~username: string, ~password: string) => promise<string>,
|
|
24
31
|
~localDefaults: bool=false,
|
|
25
32
|
): connection => {
|
|
26
|
-
// `SEED_SKIP_UPLOADS` forces the
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
// `REVENTLESS_UPLOAD_ENDPOINT` env reads as "unset" (falls back to discovery),
|
|
31
|
-
// so this is the reliable way to disable uploads.
|
|
32
|
-
let uploadEndpoint = Seed_Prompt.envValue("SEED_SKIP_UPLOADS")->Option.isSome ? "" : uploadEndpoint
|
|
33
|
+
// `SEED_SKIP_UPLOADS` forces the upload phase to no-op — seed domain data fast, or
|
|
34
|
+
// skip a broken/absent upload path without editing the data set. The data set reads
|
|
35
|
+
// `connection.uploadsSkipped` and reports the skip.
|
|
36
|
+
let uploadsSkipped = Seed_Upload.uploadsSkipped()
|
|
33
37
|
let (username, password) = await Seed_Prompt.credentials(~localDefaults)
|
|
34
38
|
let token = await login(~username, ~password)
|
|
35
39
|
let client = Seed_Client.make(~config={endpoint: endpoint})
|
|
36
40
|
client->Seed_Client.useToken(token)
|
|
37
|
-
|
|
41
|
+
// What the bearer grants, which is not always what the account list showed: a
|
|
42
|
+
// token narrowed to one role presents that role alone. Said at login so the
|
|
43
|
+
// run starts from the identity it will actually be refused or served under.
|
|
44
|
+
switch Seed_Client.identitySummary(client) {
|
|
45
|
+
| Some(summary) => Console.log(`Acting as: ${summary}`)
|
|
46
|
+
| None => ()
|
|
47
|
+
}
|
|
48
|
+
{client, uploadsSkipped, label}
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
/**
|
|
@@ -63,21 +74,19 @@ let envOr = (key: string, fallback: string): string =>
|
|
|
63
74
|
* A ready `connect` thunk for the local dev platform, with the localhost
|
|
64
75
|
* defaults baked in and overridable per endpoint (arg, then env var, then
|
|
65
76
|
* default). Empty input at the credential prompt falls back to `admin`/`admin`.
|
|
77
|
+
*
|
|
78
|
+
* No upload endpoint: uploads mint through the domain API's `Upload_Presign` mutation
|
|
79
|
+
* on the same `graphql` endpoint, with the store passed per asset.
|
|
66
80
|
*/
|
|
67
|
-
let local = (~graphql=?, ~
|
|
81
|
+
let local = (~graphql=?, ~login=?, ()): (unit => promise<connection>) => {
|
|
68
82
|
let endpoint =
|
|
69
83
|
graphql->Option.getOr(envOr("REVENTLESS_GRAPHQL_ENDPOINT", "http://localhost:4000/graphql"))
|
|
70
|
-
let uploadEndpoint =
|
|
71
|
-
upload->Option.getOr(
|
|
72
|
-
envOr("REVENTLESS_UPLOAD_ENDPOINT", "http://localhost:4000/__inmemory/upload"),
|
|
73
|
-
)
|
|
74
84
|
let loginEndpoint =
|
|
75
85
|
login->Option.getOr(envOr("REVENTLESS_LOGIN_ENDPOINT", "http://localhost:4000/__inmemory/login"))
|
|
76
86
|
() =>
|
|
77
87
|
make(
|
|
78
88
|
~label="local",
|
|
79
89
|
~endpoint,
|
|
80
|
-
~uploadEndpoint,
|
|
81
90
|
~login=viaLoginEndpoint(~loginEndpoint),
|
|
82
91
|
~localDefaults=true,
|
|
83
92
|
)
|