@reventlessdev/reventless-seed 1.0.0-alpha.1 → 1.0.0-alpha.11
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 +75 -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 +3 -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 +19 -0
- package/lib/bs/src/Seed.res.mjs +6 -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 +62 -11
- package/lib/bs/src/Seed_Client.res.mjs +51 -5
- 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 +86 -0
- package/lib/bs/src/Seed_Connect.res.mjs +62 -0
- 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 +178 -0
- package/lib/bs/src/Seed_Prompt.res.mjs +172 -0
- 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 +122 -6
- package/lib/bs/src/Seed_Runner.res.mjs +87 -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/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/ocaml/.compiler.log +2 -2
- package/lib/ocaml/ReventlessSeed.cmi +0 -0
- package/lib/ocaml/ReventlessSeed.cmt +0 -0
- package/lib/ocaml/ReventlessSeed.mlmap +3 -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 +19 -0
- 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 +62 -11
- 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 +86 -0
- 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 +178 -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 +122 -6
- 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/package.json +6 -2
- package/rescript.json +9 -1
- package/src/Seed.res +19 -0
- package/src/Seed.res.mjs +6 -0
- package/src/Seed_Client.res +62 -11
- package/src/Seed_Client.res.mjs +51 -5
- package/src/Seed_Connect.res +86 -0
- package/src/Seed_Connect.res.mjs +62 -0
- package/src/Seed_Prompt.res +178 -0
- package/src/Seed_Prompt.res.mjs +172 -0
- package/src/Seed_Runner.res +122 -6
- package/src/Seed_Runner.res.mjs +87 -1
- package/src/Seed_Upload.res +40 -42
- package/src/Seed_Upload.res.mjs +65 -69
- package/tests/Seed_RandomTest.res +160 -0
- package/tests/Seed_RandomTest.res.mjs +208 -0
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
open Seed_Types
|
|
4
4
|
|
|
5
|
-
@scope("process") @val external exit: int => unit = "exit"
|
|
6
|
-
@scope("process") @val external env: dict<string> = "env"
|
|
7
5
|
|
|
8
6
|
let envOr = (key: string, fallback: string): string =>
|
|
9
|
-
env->Dict.get(key)->Option.getOr(fallback)
|
|
7
|
+
NodeProcess.env->Dict.get(key)->Option.getOr(fallback)
|
|
10
8
|
|
|
11
9
|
/** One line of progress per phase, so a slow run never looks hung. */
|
|
12
10
|
let report = (line: string): unit => Console.log(` ${line}`)
|
|
@@ -90,19 +88,52 @@ let warn = (warnings: array<string>): unit =>
|
|
|
90
88
|
warnings->Array.forEach(w => Console.log(` - ${w}`))
|
|
91
89
|
}
|
|
92
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Pre-flight: refuse to seed onto a store that already holds data.
|
|
93
|
+
*
|
|
94
|
+
* The harness is a one-shot against a fresh store — a re-run would hit the
|
|
95
|
+
* domain's own duplicate rejections (`CategoryAlreadyExists`, …) and abort
|
|
96
|
+
* mid-phase. Catching it here, before any command is sent, turns that into an
|
|
97
|
+
* honest "nothing was written" startup abort instead of a misleading
|
|
98
|
+
* half-seeded one. It is the inverse of `verifyViews`: seeding refuses to start
|
|
99
|
+
* unless the store reads empty. Stops at the first non-empty probe view, so a
|
|
100
|
+
* populated store aborts after a single query.
|
|
101
|
+
*/
|
|
102
|
+
let assertStoreEmpty = async (client: Seed_Client.t, ~probeViews: array<string>): unit =>
|
|
103
|
+
for i in 0 to probeViews->Array.length - 1 {
|
|
104
|
+
switch probeViews->Array.get(i) {
|
|
105
|
+
| Some(name) =>
|
|
106
|
+
let count = await Seed_Client.countNodes(client, ~field=name)
|
|
107
|
+
if count > 0 {
|
|
108
|
+
throw(
|
|
109
|
+
Failed(
|
|
110
|
+
`the target store is not empty — "${name}" already holds ${count->Int.toString} row(s). Seeding is a one-shot against a fresh store; reset the store before re-running.`,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
| None => ()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
93
118
|
/**
|
|
94
119
|
* Wraps a seed run. On failure it prints the cause and exits non-zero, saying
|
|
95
120
|
* plainly that the store is now half-seeded — recovery is a reset, not a re-run.
|
|
96
121
|
*/
|
|
97
122
|
let run = async (main: unit => promise<unit>): unit =>
|
|
98
|
-
try
|
|
123
|
+
try {
|
|
124
|
+
await main()
|
|
125
|
+
// Exit cleanly: HTTP keep-alive sockets (and, on the interactive path, the
|
|
126
|
+
// readline/stdin handle) can otherwise keep the event loop alive so the CLI
|
|
127
|
+
// appears to hang after a successful run.
|
|
128
|
+
NodeProcess.exit(0)
|
|
129
|
+
} catch {
|
|
99
130
|
| Failed(message) =>
|
|
100
131
|
Console.error("")
|
|
101
132
|
Console.error("Seeding aborted — the store is now half-seeded; re-run against a")
|
|
102
133
|
Console.error("fresh store once the cause below is fixed.")
|
|
103
134
|
Console.error("")
|
|
104
135
|
Console.error(message)
|
|
105
|
-
exit(1)
|
|
136
|
+
NodeProcess.exit(1)
|
|
106
137
|
| exn =>
|
|
107
138
|
Console.error("")
|
|
108
139
|
Console.error("Seeding aborted with an unexpected error:")
|
|
@@ -110,5 +141,90 @@ let run = async (main: unit => promise<unit>): unit =>
|
|
|
110
141
|
Console.error(
|
|
111
142
|
exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown"),
|
|
112
143
|
)
|
|
113
|
-
exit(1)
|
|
144
|
+
NodeProcess.exit(1)
|
|
114
145
|
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A named, seedable data set. `seed` owns everything domain-specific — the
|
|
149
|
+
* phases, `verifyViews`, the summary board — so the generic runner never sees
|
|
150
|
+
* domain detail. `label` is what the selection menu shows.
|
|
151
|
+
*/
|
|
152
|
+
type dataSet = {
|
|
153
|
+
name: string,
|
|
154
|
+
label: string,
|
|
155
|
+
seed: Seed_Connect.connection => promise<unit>,
|
|
156
|
+
// View names probed by the fresh-store guard before any command is sent; a
|
|
157
|
+
// non-empty one aborts the run at startup. Omit to skip the guard.
|
|
158
|
+
probeViews?: array<string>,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A failure before any command is sent — bad data-set selection, an
|
|
163
|
+
* unreachable/undeployed target, a login failure. Nothing was written, so this
|
|
164
|
+
* says so plainly rather than warning about a half-seeded store (which would
|
|
165
|
+
* send the user resetting a store the run never touched).
|
|
166
|
+
*/
|
|
167
|
+
let abortStartup = (message: string): unit => {
|
|
168
|
+
Seed_Prompt.close()
|
|
169
|
+
Console.error("")
|
|
170
|
+
Console.error("Seeding did not start — nothing was written. Fix the cause below and re-run.")
|
|
171
|
+
Console.error("")
|
|
172
|
+
Console.error(message)
|
|
173
|
+
NodeProcess.exit(1)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Top-level seed entry: choose a data set (auto when there is one, `SEED_SET`
|
|
178
|
+
* or a menu otherwise), connect, then hand the connection to the chosen set.
|
|
179
|
+
* Prompts are closed before seeding so the shared readline never lingers.
|
|
180
|
+
*
|
|
181
|
+
* Selection and connect run before any command is sent, so a failure there is
|
|
182
|
+
* reported as "did not start"; only failures inside the data set's own `seed`
|
|
183
|
+
* carry the half-seeded warning (via `run`).
|
|
184
|
+
*/
|
|
185
|
+
let seed = (~sets: array<dataSet>, ~connect: unit => promise<Seed_Connect.connection>): unit => {
|
|
186
|
+
let go = async () => {
|
|
187
|
+
let started = try {
|
|
188
|
+
let chosen = switch sets {
|
|
189
|
+
| [only] => only
|
|
190
|
+
| _ =>
|
|
191
|
+
switch Seed_Prompt.envValue("SEED_SET") {
|
|
192
|
+
| Some(wanted) =>
|
|
193
|
+
switch sets->Array.find(s => s.name == wanted) {
|
|
194
|
+
| Some(s) => s
|
|
195
|
+
| None =>
|
|
196
|
+
throw(
|
|
197
|
+
Failed(
|
|
198
|
+
`SEED_SET="${wanted}" matches no data set (have: ${sets
|
|
199
|
+
->Array.map(s => s.name)
|
|
200
|
+
->Array.join(", ")})`,
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
| None =>
|
|
205
|
+
await Seed_Prompt.select(~title="Data set:", ~options=sets->Array.map(s => (s.label, s)))
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
let connection = await connect()
|
|
209
|
+
Seed_Prompt.close()
|
|
210
|
+
// Runs inside this pre-seed `try`, so refusing a non-empty store (or a
|
|
211
|
+
// failed probe query) is reported as "did not start", not "half-seeded".
|
|
212
|
+
await assertStoreEmpty(connection.client, ~probeViews=chosen.probeViews->Option.getOr([]))
|
|
213
|
+
Some((chosen, connection))
|
|
214
|
+
} catch {
|
|
215
|
+
| Failed(message) =>
|
|
216
|
+
abortStartup(message)
|
|
217
|
+
None
|
|
218
|
+
| exn =>
|
|
219
|
+
abortStartup(exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown"))
|
|
220
|
+
None
|
|
221
|
+
}
|
|
222
|
+
switch started {
|
|
223
|
+
| None => () // unreachable: abortStartup has already exited
|
|
224
|
+
| Some((chosen, connection)) =>
|
|
225
|
+
heading(`Seeding "${chosen.label}" into ${connection.label}`)
|
|
226
|
+
await run(() => chosen.seed(connection))
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
go()->ignore
|
|
230
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,27 +1,26 @@
|
|
|
1
|
-
// Seed-time asset upload against the framework's
|
|
1
|
+
// Seed-time asset upload against the framework's upload contract (route B).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// stores and the UI renders.
|
|
3
|
+
// Minting is a GraphQL mutation on the domain API — `Upload_Presign(store, fileName,
|
|
4
|
+
// contentType)` — authenticated by the same bearer the seed already uses for commands
|
|
5
|
+
// (no separate endpoint, no anonymous surface). `uploadAsset` issues it through the
|
|
6
|
+
// shared `Seed_Client`, then PUTs the bytes to the returned `uploadUrl` and resolves
|
|
7
|
+
// `Ok(storageRef)` — the same `/{prefix}/{key}` ref a command stores and the UI renders.
|
|
8
8
|
//
|
|
9
|
-
// The seed runs under Node, so
|
|
10
|
-
// absolute URL against the
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// The seed runs under Node, so a relative `uploadUrl` (the local dev server returns a
|
|
10
|
+
// same-origin `/{prefix}/{key}`) is resolved to an absolute URL against the client's
|
|
11
|
+
// endpoint origin before the PUT; AWS returns an absolute presigned S3 URL (used as-is).
|
|
12
|
+
// The PUT itself is unauthenticated — presigned on AWS, open on the local route.
|
|
13
13
|
|
|
14
14
|
type fetchInit = {method: string, headers: dict<string>, body: string}
|
|
15
15
|
type response
|
|
16
16
|
|
|
17
17
|
@val external fetch: (string, fetchInit) => promise<response> = "fetch"
|
|
18
|
-
@send external responseJson: response => promise<JSON.t> = "json"
|
|
19
18
|
@send external responseText: response => promise<string> = "text"
|
|
20
19
|
@get external responseOk: response => bool = "ok"
|
|
21
20
|
@get external responseStatus: response => int = "status"
|
|
22
21
|
|
|
23
|
-
// Resolve a possibly-relative URL against a base (WHATWG URL semantics: an
|
|
24
|
-
//
|
|
22
|
+
// Resolve a possibly-relative URL against a base (WHATWG URL semantics: an absolute
|
|
23
|
+
// `input` ignores `base`, a relative one is resolved against it).
|
|
25
24
|
type url
|
|
26
25
|
@new external makeUrl: (string, string) => url = "URL"
|
|
27
26
|
@get external urlHref: url => string = "href"
|
|
@@ -38,51 +37,50 @@ let asString = (json: JSON.t): option<string> =>
|
|
|
38
37
|
| _ => None
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
//
|
|
42
|
-
//
|
|
40
|
+
// Whether this run should skip uploads entirely. `SEED_SKIP_UPLOADS` seeds domain data
|
|
41
|
+
// fast, or skips a broken/absent upload path without editing the data set.
|
|
42
|
+
let uploadsSkipped = (): bool => Seed_Prompt.envValue("SEED_SKIP_UPLOADS")->Option.isSome
|
|
43
|
+
|
|
44
|
+
// A `fetch` that reports an unreachable endpoint as `Error` instead of throwing.
|
|
43
45
|
let tryFetch = async (url: string, init: fetchInit): result<response, string> =>
|
|
44
46
|
try Ok(await fetch(url, init)) catch {
|
|
45
47
|
| _ => Error(`cannot reach ${url}`)
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
/**
|
|
49
|
-
* Uploads `bytes` under `fileName`/`contentType`
|
|
51
|
+
* Uploads `bytes` under `fileName`/`contentType` into `store` (the qualified
|
|
52
|
+
* `{plugin}.{store}` the asset's field declares, e.g. `"Catalog.productImages"`) and
|
|
50
53
|
* resolves `Ok(storageRef)` (the stored `/{prefix}/{key}` ref), or `Error(msg)`
|
|
51
|
-
* describing the step that failed.
|
|
52
|
-
*
|
|
53
|
-
* the PUT itself is unauthenticated — presigned on AWS, open locally).
|
|
54
|
+
* describing the step that failed. Mints through the domain API's `Upload_Presign`
|
|
55
|
+
* mutation on `client`, then PUTs the bytes to the returned `uploadUrl`.
|
|
54
56
|
*/
|
|
55
57
|
let uploadAsset = async (
|
|
56
|
-
~
|
|
58
|
+
~client: Seed_Client.t,
|
|
59
|
+
~store: string,
|
|
57
60
|
~bytes: string,
|
|
58
61
|
~fileName: string,
|
|
59
62
|
~contentType: string,
|
|
60
|
-
~authToken: option<string>=?,
|
|
61
63
|
): result<string, string> => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
let
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
)
|
|
64
|
+
// GraphQL string literals: JSON-quote the argument values (JSON string escaping is a
|
|
65
|
+
// superset-safe subset of GraphQL's for these ASCII-ish inputs).
|
|
66
|
+
let q = s => JSON.stringify(JSON.Encode.string(s))
|
|
67
|
+
let query = `mutation { r: Upload_Presign(store: ${q(store)}, fileName: ${q(fileName)}, contentType: ${q(contentType)}) { uploadUrl storageRef } }`
|
|
68
|
+
|
|
69
|
+
let presign = try Ok(await Seed_Client.gql(client, ~query, ~label="Upload_Presign")) catch {
|
|
70
|
+
| Seed_Types.Failed(m) => Error(m)
|
|
71
|
+
| _ => Error("Upload_Presign: unexpected error")
|
|
72
|
+
}
|
|
72
73
|
|
|
73
|
-
switch
|
|
74
|
-
| Error(m) => Error(`presign: ${m}
|
|
75
|
-
| Ok(
|
|
76
|
-
let
|
|
77
|
-
Error(`presign failed with HTTP ${(presignRes->responseStatus)->Int.toString}: ${detail}`)
|
|
78
|
-
| Ok(presignRes) =>
|
|
79
|
-
let json = await presignRes->responseJson
|
|
74
|
+
switch presign {
|
|
75
|
+
| Error(m) => Error(`presign: ${m}`)
|
|
76
|
+
| Ok(data) =>
|
|
77
|
+
let r = data->field("r")->Option.getOr(JSON.Encode.null)
|
|
80
78
|
switch (
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
r->field("uploadUrl")->Option.flatMap(asString),
|
|
80
|
+
r->field("storageRef")->Option.flatMap(asString),
|
|
83
81
|
) {
|
|
84
82
|
| (Some(uploadUrl), Some(storageRef)) =>
|
|
85
|
-
let putUrl = urlHref(makeUrl(uploadUrl,
|
|
83
|
+
let putUrl = urlHref(makeUrl(uploadUrl, Seed_Client.endpoint(client)))
|
|
86
84
|
switch await tryFetch(
|
|
87
85
|
putUrl,
|
|
88
86
|
{method: "PUT", headers: Dict.fromArray([("content-type", contentType)]), body: bytes},
|
|
@@ -93,7 +91,7 @@ let uploadAsset = async (
|
|
|
93
91
|
let detail = await putRes->responseText
|
|
94
92
|
Error(`upload PUT failed with HTTP ${(putRes->responseStatus)->Int.toString}: ${detail}`)
|
|
95
93
|
}
|
|
96
|
-
| _ => Error("
|
|
94
|
+
| _ => Error("Upload_Presign response missing uploadUrl/storageRef")
|
|
97
95
|
}
|
|
98
96
|
}
|
|
99
97
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-seed",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.11",
|
|
4
4
|
"description": "Reusable harness for seeding a Reventless platform through its public GraphQL command API",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.4"
|
|
8
|
+
},
|
|
6
9
|
"devDependencies": {
|
|
7
|
-
"rescript": "12.3.0"
|
|
10
|
+
"rescript": "12.3.0",
|
|
11
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10"
|
|
8
12
|
},
|
|
9
13
|
"peerDependencies": {
|
|
10
14
|
"rescript": "12.3.0"
|
package/rescript.json
CHANGED
|
@@ -12,9 +12,17 @@
|
|
|
12
12
|
{
|
|
13
13
|
"dir": "src",
|
|
14
14
|
"subdirs": true
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"dir": "tests",
|
|
18
|
+
"subdirs": true,
|
|
19
|
+
"type": "dev"
|
|
15
20
|
}
|
|
16
21
|
],
|
|
17
|
-
"dependencies": [
|
|
22
|
+
"dependencies": [
|
|
23
|
+
"@reventlessdev/rescript-node",
|
|
24
|
+
"@reventlessdev/rescript-jest"
|
|
25
|
+
],
|
|
18
26
|
"compiler-flags": [],
|
|
19
27
|
"suffix": ".res.mjs"
|
|
20
28
|
}
|
package/src/Seed.res
CHANGED
|
@@ -9,9 +9,28 @@ module Random = Seed_Random
|
|
|
9
9
|
module Client = Seed_Client
|
|
10
10
|
module Runner = Seed_Runner
|
|
11
11
|
module Upload = Seed_Upload
|
|
12
|
+
module Prompt = Seed_Prompt
|
|
13
|
+
module Connect = Seed_Connect
|
|
12
14
|
|
|
13
15
|
exception Failed = Seed_Types.Failed
|
|
14
16
|
|
|
17
|
+
// A live target a data set seeds against: an authenticated client, whether uploads
|
|
18
|
+
// are skipped this run, and a label. Uploads mint through the domain API's
|
|
19
|
+
// `Upload_Presign` mutation on the client (route B), so no upload endpoint is carried.
|
|
20
|
+
type connection = Seed_Connect.connection = {
|
|
21
|
+
client: Seed_Client.t,
|
|
22
|
+
uploadsSkipped: bool,
|
|
23
|
+
label: string,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A named, seedable data set. `seed` owns everything domain-specific.
|
|
27
|
+
type dataSet = Seed_Runner.dataSet = {
|
|
28
|
+
name: string,
|
|
29
|
+
label: string,
|
|
30
|
+
seed: connection => promise<unit>,
|
|
31
|
+
probeViews?: array<string>,
|
|
32
|
+
}
|
|
33
|
+
|
|
15
34
|
type rec value = Seed_Types.value =
|
|
16
35
|
| String(string)
|
|
17
36
|
| Id(string)
|
package/src/Seed.res.mjs
CHANGED
|
@@ -10,6 +10,10 @@ let Runner;
|
|
|
10
10
|
|
|
11
11
|
let Upload;
|
|
12
12
|
|
|
13
|
+
let Prompt;
|
|
14
|
+
|
|
15
|
+
let Connect;
|
|
16
|
+
|
|
13
17
|
let Failed = Seed_Types$ReventlessSeed.Failed;
|
|
14
18
|
|
|
15
19
|
let mutation = Seed_Types$ReventlessSeed.mutation;
|
|
@@ -25,6 +29,8 @@ export {
|
|
|
25
29
|
Client,
|
|
26
30
|
Runner,
|
|
27
31
|
Upload,
|
|
32
|
+
Prompt,
|
|
33
|
+
Connect,
|
|
28
34
|
Failed,
|
|
29
35
|
mutation,
|
|
30
36
|
ids,
|
package/src/Seed_Client.res
CHANGED
|
@@ -27,9 +27,12 @@ let sleep = (ms: int): promise<unit> => Promise.make((resolve, _) => setTimeout(
|
|
|
27
27
|
|
|
28
28
|
type config = {
|
|
29
29
|
endpoint: string,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
// Present only for the `login` flow (a platform that mints tokens over HTTP,
|
|
31
|
+
// e.g. the local dev `/__inmemory/login`). Omit when the bearer is supplied
|
|
32
|
+
// out-of-band via `useToken` (e.g. a Cognito id token minted by the caller).
|
|
33
|
+
loginEndpoint?: string,
|
|
34
|
+
username?: string,
|
|
35
|
+
password?: string,
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
type t = {config: config, mutable token: option<string>}
|
|
@@ -40,6 +43,15 @@ let make = (~config: config): t => {config, token: None}
|
|
|
40
43
|
Seed.Upload) can authenticate side-channel requests with the same identity. */
|
|
41
44
|
let currentToken = (t: t): option<string> => t.token
|
|
42
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
|
+
|
|
50
|
+
/** Injects a bearer obtained out-of-band (e.g. a Cognito id token), instead of
|
|
51
|
+
minting one via `login`. Provider-agnostic: the harness only cares that the
|
|
52
|
+
token is a valid bearer for `endpoint`. */
|
|
53
|
+
let useToken = (t: t, token: string): unit => t.token = Some(token)
|
|
54
|
+
|
|
43
55
|
// ── JSON helpers ────────────────────────────────────────────────────────────
|
|
44
56
|
|
|
45
57
|
let field = (json: JSON.t, key: string): option<JSON.t> =>
|
|
@@ -61,28 +73,31 @@ let nodeString = (node: JSON.t, key: string): option<string> =>
|
|
|
61
73
|
// ── Requests ────────────────────────────────────────────────────────────────
|
|
62
74
|
|
|
63
75
|
let login = async (t: t): unit => {
|
|
76
|
+
let loginEndpoint = switch t.config.loginEndpoint {
|
|
77
|
+
| Some(e) => e
|
|
78
|
+
| None => throw(Failed("login: no loginEndpoint configured — supply one or call useToken"))
|
|
79
|
+
}
|
|
80
|
+
let username = t.config.username->Option.getOr("")
|
|
81
|
+
let password = t.config.password->Option.getOr("")
|
|
64
82
|
let body = JSON.stringify(
|
|
65
83
|
JSON.Encode.object(
|
|
66
84
|
Dict.fromArray([
|
|
67
|
-
("username", JSON.Encode.string(
|
|
68
|
-
("password", JSON.Encode.string(
|
|
85
|
+
("username", JSON.Encode.string(username)),
|
|
86
|
+
("password", JSON.Encode.string(password)),
|
|
69
87
|
]),
|
|
70
88
|
),
|
|
71
89
|
)
|
|
72
90
|
let res = try await fetch(
|
|
73
|
-
|
|
91
|
+
loginEndpoint,
|
|
74
92
|
{method: "POST", headers: Dict.fromArray([("content-type", "application/json")]), body},
|
|
75
93
|
) catch {
|
|
76
|
-
| _ =>
|
|
77
|
-
throw(
|
|
78
|
-
Failed(`login: cannot reach ${t.config.loginEndpoint} — is the platform running?`),
|
|
79
|
-
)
|
|
94
|
+
| _ => throw(Failed(`login: cannot reach ${loginEndpoint} — is the platform running?`))
|
|
80
95
|
}
|
|
81
96
|
if !(res->responseOk) {
|
|
82
97
|
let detail = await res->responseText
|
|
83
98
|
throw(
|
|
84
99
|
Failed(
|
|
85
|
-
`login as "${
|
|
100
|
+
`login as "${username}" failed with HTTP ${(res->responseStatus)
|
|
86
101
|
->Int.toString}: ${detail}`,
|
|
87
102
|
),
|
|
88
103
|
)
|
|
@@ -205,6 +220,42 @@ let queryAllNodes = async (t: t, ~field as fieldName: string, ~selection: string
|
|
|
205
220
|
let countNodes = async (t: t, ~field: string): int =>
|
|
206
221
|
(await queryAllNodes(t, ~field, ~selection="id"))->Array.length
|
|
207
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Polls a connection field until the collected nodes satisfy `satisfied`, or the
|
|
225
|
+
* timeout elapses. Read models are eventually consistent: a view queried the
|
|
226
|
+
* instant after the writes that feed it can still read empty or partial (a
|
|
227
|
+
* DynamoDB connection scan is eventually consistent by default, and the write and
|
|
228
|
+
* read-back can land on different replicas), so a cross-check that asserts on a
|
|
229
|
+
* single immediate read races the projection. Same rationale as `waitForIds`,
|
|
230
|
+
* for the "observe the whole connection" case rather than a fixed id set.
|
|
231
|
+
*
|
|
232
|
+
* On timeout, throws `Failed(onTimeout(finalNodes))` so the caller can report
|
|
233
|
+
* what the view actually settled on — distinguishing a still-empty view from a
|
|
234
|
+
* genuine count mismatch — instead of a bare assertion.
|
|
235
|
+
*/
|
|
236
|
+
let queryAllNodesUntil = async (
|
|
237
|
+
t: t,
|
|
238
|
+
~field: string,
|
|
239
|
+
~selection: string,
|
|
240
|
+
~satisfied: array<JSON.t> => bool,
|
|
241
|
+
~onTimeout: array<JSON.t> => string,
|
|
242
|
+
~timeoutMs: int=60000,
|
|
243
|
+
): array<JSON.t> => {
|
|
244
|
+
let deadline = nowMs() +. Int.toFloat(timeoutMs)
|
|
245
|
+
let result = ref(None)
|
|
246
|
+
while result.contents == None {
|
|
247
|
+
let nodes = await queryAllNodes(t, ~field, ~selection)
|
|
248
|
+
if satisfied(nodes) {
|
|
249
|
+
result := Some(nodes)
|
|
250
|
+
} else if nowMs() > deadline {
|
|
251
|
+
throw(Failed(onTimeout(nodes)))
|
|
252
|
+
} else {
|
|
253
|
+
await sleep(500)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
result.contents->Option.getOr([])
|
|
257
|
+
}
|
|
258
|
+
|
|
208
259
|
/**
|
|
209
260
|
* Polls a `<View>ByIds` field until every id is present.
|
|
210
261
|
*
|
package/src/Seed_Client.res.mjs
CHANGED
|
@@ -21,6 +21,14 @@ function currentToken(t) {
|
|
|
21
21
|
return t.token;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function endpoint(t) {
|
|
25
|
+
return t.config.endpoint;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function useToken(t, token) {
|
|
29
|
+
t.token = token;
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
function field(json, key) {
|
|
25
33
|
if (typeof json === "object" && json !== null && !Array.isArray(json)) {
|
|
26
34
|
return json[key];
|
|
@@ -38,19 +46,32 @@ function nodeString(node, key) {
|
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
async function login(t) {
|
|
49
|
+
let e = t.config.loginEndpoint;
|
|
50
|
+
let loginEndpoint;
|
|
51
|
+
if (e !== undefined) {
|
|
52
|
+
loginEndpoint = e;
|
|
53
|
+
} else {
|
|
54
|
+
throw {
|
|
55
|
+
RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
|
|
56
|
+
_1: "login: no loginEndpoint configured — supply one or call useToken",
|
|
57
|
+
Error: new Error()
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
let username = Stdlib_Option.getOr(t.config.username, "");
|
|
61
|
+
let password = Stdlib_Option.getOr(t.config.password, "");
|
|
41
62
|
let body = JSON.stringify(Object.fromEntries([
|
|
42
63
|
[
|
|
43
64
|
"username",
|
|
44
|
-
|
|
65
|
+
username
|
|
45
66
|
],
|
|
46
67
|
[
|
|
47
68
|
"password",
|
|
48
|
-
|
|
69
|
+
password
|
|
49
70
|
]
|
|
50
71
|
]));
|
|
51
72
|
let res;
|
|
52
73
|
try {
|
|
53
|
-
res = await fetch(
|
|
74
|
+
res = await fetch(loginEndpoint, {
|
|
54
75
|
method: "POST",
|
|
55
76
|
headers: Object.fromEntries([[
|
|
56
77
|
"content-type",
|
|
@@ -61,7 +82,7 @@ async function login(t) {
|
|
|
61
82
|
} catch (exn) {
|
|
62
83
|
throw {
|
|
63
84
|
RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
|
|
64
|
-
_1: `login: cannot reach ` +
|
|
85
|
+
_1: `login: cannot reach ` + loginEndpoint + ` — is the platform running?`,
|
|
65
86
|
Error: new Error()
|
|
66
87
|
};
|
|
67
88
|
}
|
|
@@ -69,7 +90,7 @@ async function login(t) {
|
|
|
69
90
|
let detail = await res.text();
|
|
70
91
|
throw {
|
|
71
92
|
RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
|
|
72
|
-
_1: `login as "` +
|
|
93
|
+
_1: `login as "` + username + `" failed with HTTP ` + res.status.toString() + `: ` + detail,
|
|
73
94
|
Error: new Error()
|
|
74
95
|
};
|
|
75
96
|
}
|
|
@@ -191,6 +212,28 @@ async function countNodes(t, field) {
|
|
|
191
212
|
return (await queryAllNodes(t, field, "id")).length;
|
|
192
213
|
}
|
|
193
214
|
|
|
215
|
+
async function queryAllNodesUntil(t, field, selection, satisfied, onTimeout, timeoutMsOpt) {
|
|
216
|
+
let timeoutMs = timeoutMsOpt !== undefined ? timeoutMsOpt : 60000;
|
|
217
|
+
let deadline = Date.now() + timeoutMs;
|
|
218
|
+
let result;
|
|
219
|
+
while (result === undefined) {
|
|
220
|
+
let nodes = await queryAllNodes(t, field, selection);
|
|
221
|
+
if (satisfied(nodes)) {
|
|
222
|
+
result = nodes;
|
|
223
|
+
} else {
|
|
224
|
+
if (Date.now() > deadline) {
|
|
225
|
+
throw {
|
|
226
|
+
RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
|
|
227
|
+
_1: onTimeout(nodes),
|
|
228
|
+
Error: new Error()
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
await sleep(500);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
return Stdlib_Option.getOr(result, []);
|
|
235
|
+
}
|
|
236
|
+
|
|
194
237
|
async function waitForIds(t, fieldName, ids, timeoutMsOpt) {
|
|
195
238
|
let timeoutMs = timeoutMsOpt !== undefined ? timeoutMsOpt : 60000;
|
|
196
239
|
let deadline = Date.now() + timeoutMs;
|
|
@@ -227,6 +270,8 @@ export {
|
|
|
227
270
|
sleep,
|
|
228
271
|
make,
|
|
229
272
|
currentToken,
|
|
273
|
+
endpoint,
|
|
274
|
+
useToken,
|
|
230
275
|
field,
|
|
231
276
|
asString,
|
|
232
277
|
nodeString,
|
|
@@ -237,6 +282,7 @@ export {
|
|
|
237
282
|
sendAll,
|
|
238
283
|
queryAllNodes,
|
|
239
284
|
countNodes,
|
|
285
|
+
queryAllNodesUntil,
|
|
240
286
|
waitForIds,
|
|
241
287
|
}
|
|
242
288
|
/* No side effect */
|