@reventlessdev/reventless-seed 1.0.0-alpha.2 → 1.0.0-alpha.21
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 +149 -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 +6 -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 +12 -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 +382 -21
- package/lib/bs/src/Seed_Client.res.mjs +332 -24
- 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 +281 -33
- package/lib/bs/src/Seed_Connect.res.mjs +162 -8
- 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 +140 -41
- package/lib/bs/src/Seed_Prompt.res.mjs +88 -13
- package/lib/bs/src/Seed_Random-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Random-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Random.ast +0 -0
- package/lib/bs/src/Seed_Random.res +4 -2
- 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 +45 -19
- 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 +43 -43
- 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 +92 -0
- package/lib/bs/src/Seed_Users.res.mjs +91 -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 +287 -0
- package/lib/bs/tests/Seed_ClientTest.res.mjs +268 -0
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_ConnectTest.ast +0 -0
- package/lib/bs/tests/Seed_ConnectTest.res +64 -0
- package/lib/bs/tests/Seed_ConnectTest.res.mjs +56 -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 +55 -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 +200 -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 +129 -0
- package/lib/bs/tests/Seed_UsersTest.res.mjs +113 -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 +6 -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 +12 -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 +382 -21
- 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 +287 -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 +281 -33
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_ConnectTest.ast +0 -0
- package/lib/ocaml/Seed_ConnectTest.res +64 -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 +140 -41
- 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 +55 -0
- package/lib/ocaml/Seed_Random-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Random-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Random.ast +0 -0
- package/lib/ocaml/Seed_Random.res +4 -2
- 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 +200 -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 +45 -19
- 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 +43 -43
- 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 +92 -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 +129 -0
- package/package.json +7 -2
- package/rescript.json +9 -1
- package/src/Seed.res +12 -3
- package/src/Seed.res.mjs +3 -0
- package/src/Seed_Client.res +382 -21
- package/src/Seed_Client.res.mjs +332 -24
- package/src/Seed_Connect.res +281 -33
- package/src/Seed_Connect.res.mjs +162 -8
- package/src/Seed_Prompt.res +140 -41
- package/src/Seed_Prompt.res.mjs +88 -13
- package/src/Seed_Random.res +4 -2
- package/src/Seed_Runner.res +45 -19
- package/src/Seed_Runner.res.mjs +19 -1
- package/src/Seed_Upload.res +43 -43
- package/src/Seed_Upload.res.mjs +65 -69
- package/src/Seed_Users.res +92 -0
- package/src/Seed_Users.res.mjs +91 -0
- package/tests/Seed_ClientTest.res +287 -0
- package/tests/Seed_ClientTest.res.mjs +268 -0
- package/tests/Seed_ConnectTest.res +64 -0
- package/tests/Seed_ConnectTest.res.mjs +56 -0
- package/tests/Seed_PromptTest.res +55 -0
- package/tests/Seed_PromptTest.res.mjs +63 -0
- package/tests/Seed_RandomTest.res +200 -0
- package/tests/Seed_RandomTest.res.mjs +208 -0
- package/tests/Seed_UsersTest.res +129 -0
- package/tests/Seed_UsersTest.res.mjs +113 -0
|
@@ -2,11 +2,8 @@
|
|
|
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
|
-
|
|
8
5
|
let envOr = (key: string, fallback: string): string =>
|
|
9
|
-
env->Dict.get(key)->Option.getOr(fallback)
|
|
6
|
+
NodeProcess.env->Dict.get(key)->Option.getOr(fallback)
|
|
10
7
|
|
|
11
8
|
/** One line of progress per phase, so a slow run never looks hung. */
|
|
12
9
|
let report = (line: string): unit => Console.log(` ${line}`)
|
|
@@ -60,14 +57,12 @@ let verifyViews = async (client: Seed_Client.t, ~views: array<view>): dict<int>
|
|
|
60
57
|
Console.log(` ${count->Int.toString->String.padStart(4, " ")} ${name}${note}`)
|
|
61
58
|
})
|
|
62
59
|
|
|
63
|
-
let empty =
|
|
64
|
-
|
|
65
|
-
->
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
)
|
|
60
|
+
let empty = views->Array.filterMap(v =>
|
|
61
|
+
switch v {
|
|
62
|
+
| Seeded(name) if counts->Dict.get(name)->Option.getOr(0) == 0 => Some(name)
|
|
63
|
+
| _ => None
|
|
64
|
+
}
|
|
65
|
+
)
|
|
71
66
|
if empty->Array.length > 0 {
|
|
72
67
|
throw(Failed(`these views are still empty after seeding: ${empty->Array.join(", ")}`))
|
|
73
68
|
}
|
|
@@ -90,6 +85,33 @@ let warn = (warnings: array<string>): unit =>
|
|
|
90
85
|
warnings->Array.forEach(w => Console.log(` - ${w}`))
|
|
91
86
|
}
|
|
92
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Pre-flight: refuse to seed onto a store that already holds data.
|
|
90
|
+
*
|
|
91
|
+
* The harness is a one-shot against a fresh store — a re-run would hit the
|
|
92
|
+
* domain's own duplicate rejections (`CategoryAlreadyExists`, …) and abort
|
|
93
|
+
* mid-phase. Catching it here, before any command is sent, turns that into an
|
|
94
|
+
* honest "nothing was written" startup abort instead of a misleading
|
|
95
|
+
* half-seeded one. It is the inverse of `verifyViews`: seeding refuses to start
|
|
96
|
+
* unless the store reads empty. Stops at the first non-empty probe view, so a
|
|
97
|
+
* populated store aborts after a single query.
|
|
98
|
+
*/
|
|
99
|
+
let assertStoreEmpty = async (client: Seed_Client.t, ~probeViews: array<string>): unit =>
|
|
100
|
+
for i in 0 to probeViews->Array.length - 1 {
|
|
101
|
+
switch probeViews->Array.get(i) {
|
|
102
|
+
| Some(name) =>
|
|
103
|
+
let count = await Seed_Client.countNodes(client, ~field=name)
|
|
104
|
+
if count > 0 {
|
|
105
|
+
throw(
|
|
106
|
+
Failed(
|
|
107
|
+
`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.`,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
| None => ()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
/**
|
|
94
116
|
* Wraps a seed run. On failure it prints the cause and exits non-zero, saying
|
|
95
117
|
* plainly that the store is now half-seeded — recovery is a reset, not a re-run.
|
|
@@ -100,7 +122,7 @@ let run = async (main: unit => promise<unit>): unit =>
|
|
|
100
122
|
// Exit cleanly: HTTP keep-alive sockets (and, on the interactive path, the
|
|
101
123
|
// readline/stdin handle) can otherwise keep the event loop alive so the CLI
|
|
102
124
|
// appears to hang after a successful run.
|
|
103
|
-
exit(0)
|
|
125
|
+
NodeProcess.exit(0)
|
|
104
126
|
} catch {
|
|
105
127
|
| Failed(message) =>
|
|
106
128
|
Console.error("")
|
|
@@ -108,15 +130,13 @@ let run = async (main: unit => promise<unit>): unit =>
|
|
|
108
130
|
Console.error("fresh store once the cause below is fixed.")
|
|
109
131
|
Console.error("")
|
|
110
132
|
Console.error(message)
|
|
111
|
-
exit(1)
|
|
133
|
+
NodeProcess.exit(1)
|
|
112
134
|
| exn =>
|
|
113
135
|
Console.error("")
|
|
114
136
|
Console.error("Seeding aborted with an unexpected error:")
|
|
115
137
|
Console.error("")
|
|
116
|
-
Console.error(
|
|
117
|
-
|
|
118
|
-
)
|
|
119
|
-
exit(1)
|
|
138
|
+
Console.error(exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown"))
|
|
139
|
+
NodeProcess.exit(1)
|
|
120
140
|
}
|
|
121
141
|
|
|
122
142
|
/**
|
|
@@ -128,6 +148,9 @@ type dataSet = {
|
|
|
128
148
|
name: string,
|
|
129
149
|
label: string,
|
|
130
150
|
seed: Seed_Connect.connection => promise<unit>,
|
|
151
|
+
// View names probed by the fresh-store guard before any command is sent; a
|
|
152
|
+
// non-empty one aborts the run at startup. Omit to skip the guard.
|
|
153
|
+
probeViews?: array<string>,
|
|
131
154
|
}
|
|
132
155
|
|
|
133
156
|
/**
|
|
@@ -142,7 +165,7 @@ let abortStartup = (message: string): unit => {
|
|
|
142
165
|
Console.error("Seeding did not start — nothing was written. Fix the cause below and re-run.")
|
|
143
166
|
Console.error("")
|
|
144
167
|
Console.error(message)
|
|
145
|
-
exit(1)
|
|
168
|
+
NodeProcess.exit(1)
|
|
146
169
|
}
|
|
147
170
|
|
|
148
171
|
/**
|
|
@@ -179,6 +202,9 @@ let seed = (~sets: array<dataSet>, ~connect: unit => promise<Seed_Connect.connec
|
|
|
179
202
|
}
|
|
180
203
|
let connection = await connect()
|
|
181
204
|
Seed_Prompt.close()
|
|
205
|
+
// Runs inside this pre-seed `try`, so refusing a non-empty store (or a
|
|
206
|
+
// failed probe query) is reported as "did not start", not "half-seeded".
|
|
207
|
+
await assertStoreEmpty(connection.client, ~probeViews=chosen.probeViews->Option.getOr([]))
|
|
182
208
|
Some((chosen, connection))
|
|
183
209
|
} catch {
|
|
184
210
|
| Failed(message) =>
|
|
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,52 @@ 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(
|
|
68
|
+
fileName,
|
|
69
|
+
)}, contentType: ${q(contentType)}) { uploadUrl storageRef } }`
|
|
70
|
+
|
|
71
|
+
let presign = try Ok(await Seed_Client.gql(client, ~query, ~label="Upload_Presign")) catch {
|
|
72
|
+
| Seed_Types.Failed(m) => Error(m)
|
|
73
|
+
| _ => Error("Upload_Presign: unexpected error")
|
|
74
|
+
}
|
|
72
75
|
|
|
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
|
|
76
|
+
switch presign {
|
|
77
|
+
| Error(m) => Error(`presign: ${m}`)
|
|
78
|
+
| Ok(data) =>
|
|
79
|
+
let r = data->field("r")->Option.getOr(JSON.Encode.null)
|
|
80
80
|
switch (
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
r->field("uploadUrl")->Option.flatMap(asString),
|
|
82
|
+
r->field("storageRef")->Option.flatMap(asString),
|
|
83
83
|
) {
|
|
84
84
|
| (Some(uploadUrl), Some(storageRef)) =>
|
|
85
|
-
let putUrl = urlHref(makeUrl(uploadUrl,
|
|
85
|
+
let putUrl = urlHref(makeUrl(uploadUrl, Seed_Client.endpoint(client)))
|
|
86
86
|
switch await tryFetch(
|
|
87
87
|
putUrl,
|
|
88
88
|
{method: "PUT", headers: Dict.fromArray([("content-type", contentType)]), body: bytes},
|
|
@@ -91,9 +91,9 @@ let uploadAsset = async (
|
|
|
91
91
|
| Ok(putRes) if putRes->responseOk => Ok(storageRef)
|
|
92
92
|
| Ok(putRes) =>
|
|
93
93
|
let detail = await putRes->responseText
|
|
94
|
-
Error(`upload PUT failed with HTTP ${
|
|
94
|
+
Error(`upload PUT failed with HTTP ${putRes->responseStatus->Int.toString}: ${detail}`)
|
|
95
95
|
}
|
|
96
|
-
| _ => Error("
|
|
96
|
+
| _ => Error("Upload_Presign response missing uploadUrl/storageRef")
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The dev accounts a seed run can log in as, read from `.reventless/users.yaml`.
|
|
2
|
+
//
|
|
3
|
+
// Both platforms keep that file beside the package the seed runs from: locally
|
|
4
|
+
// the in-memory auth adapter loads it at startup, and on AWS it is the record of
|
|
5
|
+
// what was created in the Cognito pool. Either way it already holds the
|
|
6
|
+
// credentials an operator would otherwise retype, so the credential prompt
|
|
7
|
+
// offers its entries instead of asking for a password nobody remembers.
|
|
8
|
+
//
|
|
9
|
+
// Absence is normal and silent — the caller falls back to asking. Nothing here
|
|
10
|
+
// throws: a missing, unreadable or malformed file yields no accounts.
|
|
11
|
+
|
|
12
|
+
@module("yaml") external parseYaml: string => JSON.t = "parse"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One account: what to log in as, what to send with it, and — when the file
|
|
16
|
+
* records one — the id the platform stamps on rows this account writes.
|
|
17
|
+
*
|
|
18
|
+
* `userId` is optional because absence is normal on both platforms: a local file
|
|
19
|
+
* may omit it and let the auth adapter default it to the username, and a
|
|
20
|
+
* hand-maintained AWS file records subs the pool minted, so it can lag.
|
|
21
|
+
*/
|
|
22
|
+
type user = {
|
|
23
|
+
username: string,
|
|
24
|
+
password: string,
|
|
25
|
+
groups: array<string>,
|
|
26
|
+
userId: option<string>,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let asString = (json: JSON.t): option<string> =>
|
|
30
|
+
switch json {
|
|
31
|
+
| String(s) => Some(s)
|
|
32
|
+
| _ => None
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let userOf = (json: JSON.t): option<user> =>
|
|
36
|
+
switch json {
|
|
37
|
+
| Object(obj) =>
|
|
38
|
+
switch (
|
|
39
|
+
obj->Dict.get("username")->Option.flatMap(asString),
|
|
40
|
+
obj->Dict.get("password")->Option.flatMap(asString),
|
|
41
|
+
) {
|
|
42
|
+
| (Some(username), Some(password)) =>
|
|
43
|
+
let groups = switch obj->Dict.get("groups") {
|
|
44
|
+
| Some(JSON.Array(items)) => items->Array.filterMap(asString)
|
|
45
|
+
| _ => []
|
|
46
|
+
}
|
|
47
|
+
Some({username, password, groups, userId: obj->Dict.get("userId")->Option.flatMap(asString)})
|
|
48
|
+
| _ => None
|
|
49
|
+
}
|
|
50
|
+
| _ => None
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The accounts a users.yaml document defines, in the order it defines them —
|
|
55
|
+
* the order is what makes "the first one" a stable default.
|
|
56
|
+
*
|
|
57
|
+
* An entry without both a username and a password is skipped rather than
|
|
58
|
+
* rejected: the file is hand-maintained, and one incomplete entry should not
|
|
59
|
+
* cost the operator the rest of the list.
|
|
60
|
+
*/
|
|
61
|
+
let parseString = (yamlText: string): array<user> => {
|
|
62
|
+
let doc = try parseYaml(yamlText) catch {
|
|
63
|
+
| _ => JSON.Null
|
|
64
|
+
}
|
|
65
|
+
switch doc {
|
|
66
|
+
| Array(items) => items->Array.filterMap(userOf)
|
|
67
|
+
| _ => []
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Where the file lives for the platform being seeded — the seed runs from that
|
|
72
|
+
package's directory, which is the same cwd the local auth adapter reads. */
|
|
73
|
+
let defaultPath = (): string => NodePath.join([NodeProcess.cwd(), ".reventless", "users.yaml"])
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The accounts on disk, with the path they came from. `None` when there is no
|
|
77
|
+
* readable file with at least one usable account, which the caller reads as
|
|
78
|
+
* "ask for credentials instead".
|
|
79
|
+
*/
|
|
80
|
+
let load = (~path: option<string>=?): option<(string, array<user>)> => {
|
|
81
|
+
let file = path->Option.getOr(defaultPath())
|
|
82
|
+
let users = try {
|
|
83
|
+
NodeFs.existsSync(file) ? parseString(NodeFs.readFileSync(file)) : []
|
|
84
|
+
} catch {
|
|
85
|
+
| _ => []
|
|
86
|
+
}
|
|
87
|
+
users->Array.length == 0 ? None : Some((file, users))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Menu label: the username, plus whatever groups the file records for it. */
|
|
91
|
+
let label = (u: user): string =>
|
|
92
|
+
u.groups->Array.length == 0 ? u.username : `${u.username} [${u.groups->Array.join(", ")}]`
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// `.reventless/users.yaml` is hand-maintained and shared with the auth adapter,
|
|
4
|
+
// so the seed reads it defensively: it must survive the comment blocks both
|
|
5
|
+
// copies carry, must not reorder the accounts (the first one is the prompt's
|
|
6
|
+
// default, so order IS the contract), and must never throw — a broken file costs
|
|
7
|
+
// the menu, not the run.
|
|
8
|
+
|
|
9
|
+
let document = `
|
|
10
|
+
# Local dev users — a comment block like the real file carries.
|
|
11
|
+
|
|
12
|
+
- username: admin
|
|
13
|
+
password: admin
|
|
14
|
+
groups: [Admin, Shopper]
|
|
15
|
+
userId: local-admin
|
|
16
|
+
|
|
17
|
+
- username: shopper
|
|
18
|
+
password: Shopper-2026!
|
|
19
|
+
groups: [Shopper]
|
|
20
|
+
|
|
21
|
+
- username: noGroups
|
|
22
|
+
password: pw
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
describe("Seed_Users.parseString:", () => {
|
|
26
|
+
testSync("reads every account, in the order the file defines them", () => {
|
|
27
|
+
let users = Seed_Users.parseString(document)
|
|
28
|
+
expect(users->Array.map(u => u.Seed_Users.username))->Expect.toEqual([
|
|
29
|
+
"admin",
|
|
30
|
+
"shopper",
|
|
31
|
+
"noGroups",
|
|
32
|
+
])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
testSync("carries the password beside the username", () => {
|
|
36
|
+
let users = Seed_Users.parseString(document)
|
|
37
|
+
expect(users->Array.map(u => u.Seed_Users.password))->Expect.toEqual([
|
|
38
|
+
"admin",
|
|
39
|
+
"Shopper-2026!",
|
|
40
|
+
"pw",
|
|
41
|
+
])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
testSync("keeps the groups, and reads a missing groups key as none", () => {
|
|
45
|
+
let users = Seed_Users.parseString(document)
|
|
46
|
+
expect(users->Array.map(u => u.Seed_Users.groups))->Expect.toEqual([
|
|
47
|
+
["Admin", "Shopper"],
|
|
48
|
+
["Shopper"],
|
|
49
|
+
[],
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// The half that was dropped, and the reason owner-scoped demo rows were keyed
|
|
54
|
+
// to ids nobody on a Cognito deployment holds: the file records the id the
|
|
55
|
+
// platform stamps, and reading it only for a password threw that away.
|
|
56
|
+
//
|
|
57
|
+
// Absent stays absent rather than defaulting to the username — a local file
|
|
58
|
+
// may omit it and let the auth adapter default it, and an AWS file records a
|
|
59
|
+
// sub no default could invent. Guessing here would look like an answer.
|
|
60
|
+
testSync("keeps the userId an entry declares, and reads a missing one as absent", () => {
|
|
61
|
+
let users = Seed_Users.parseString(document)
|
|
62
|
+
expect(users->Array.map(u => u.Seed_Users.userId))->Expect.toEqual([
|
|
63
|
+
Some("local-admin"),
|
|
64
|
+
None,
|
|
65
|
+
None,
|
|
66
|
+
])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// An entry half-written by hand is the likely flaw in this file. Dropping it
|
|
70
|
+
// keeps the other accounts selectable; failing the parse would not.
|
|
71
|
+
testSync("skips an entry that names no password", () => {
|
|
72
|
+
let users = Seed_Users.parseString(`
|
|
73
|
+
- username: admin
|
|
74
|
+
password: admin
|
|
75
|
+
- username: halfWritten
|
|
76
|
+
groups: [Shopper]
|
|
77
|
+
`)
|
|
78
|
+
expect(users->Array.map(u => u.Seed_Users.username))->Expect.toEqual(["admin"])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
testSync("yields no accounts for a document that is not a list", () => {
|
|
82
|
+
expect(Seed_Users.parseString("users: {}")->Array.length)->Expect.toBe(0)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// The whole point of returning accounts rather than raising: the caller falls
|
|
86
|
+
// back to prompting, and it can only do that if nothing here escapes.
|
|
87
|
+
testSync("yields no accounts for malformed YAML instead of throwing", () => {
|
|
88
|
+
expect(Seed_Users.parseString("- username: [unclosed")->Array.length)->Expect.toBe(0)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe("Seed_Users.load:", () => {
|
|
93
|
+
testSync("is silent about a file that is not there", () => {
|
|
94
|
+
expect(Seed_Users.load(~path="/nonexistent/.reventless/users.yaml"))->Expect.toBe(None)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe("Seed_Users.label:", () => {
|
|
99
|
+
testSync("names the groups an account carries", () => {
|
|
100
|
+
expect(
|
|
101
|
+
Seed_Users.label({
|
|
102
|
+
username: "admin",
|
|
103
|
+
password: "x",
|
|
104
|
+
groups: ["Admin", "Shopper"],
|
|
105
|
+
userId: None,
|
|
106
|
+
}),
|
|
107
|
+
)->Expect.toBe("admin [Admin, Shopper]")
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
testSync("is the bare username when the file records no groups", () => {
|
|
111
|
+
expect(
|
|
112
|
+
Seed_Users.label({username: "admin", password: "x", groups: [], userId: None}),
|
|
113
|
+
)->Expect.toBe("admin")
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// The menu is unchanged by the id: an operator picks an account by name and
|
|
117
|
+
// membership, and a Cognito sub in the list would be noise at the one moment
|
|
118
|
+
// they are choosing a login.
|
|
119
|
+
testSync("says nothing about the userId", () => {
|
|
120
|
+
expect(
|
|
121
|
+
Seed_Users.label({
|
|
122
|
+
username: "admin",
|
|
123
|
+
password: "x",
|
|
124
|
+
groups: ["Admin"],
|
|
125
|
+
userId: Some("4275e4a4-00c1-70e1-1cb5-363b06b992b5"),
|
|
126
|
+
}),
|
|
127
|
+
)->Expect.toBe("admin [Admin]")
|
|
128
|
+
})
|
|
129
|
+
})
|
package/package.json
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-seed",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.21",
|
|
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
|
+
"yaml": "^2.8.3",
|
|
8
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.11"
|
|
9
|
+
},
|
|
6
10
|
"devDependencies": {
|
|
7
|
-
"rescript": "12.3.0"
|
|
11
|
+
"rescript": "12.3.0",
|
|
12
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10"
|
|
8
13
|
},
|
|
9
14
|
"peerDependencies": {
|
|
10
15
|
"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
|
@@ -10,16 +10,24 @@ 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,
|
|
18
|
-
//
|
|
18
|
+
// A live target a data set seeds against: an authenticated client, whether uploads
|
|
19
|
+
// are skipped this run, a label, and who the run is acting as. Uploads mint through
|
|
20
|
+
// the domain API's `Upload_Presign` mutation on the client (route B), so no upload
|
|
21
|
+
// endpoint is carried.
|
|
19
22
|
type connection = Seed_Connect.connection = {
|
|
20
23
|
client: Seed_Client.t,
|
|
21
|
-
|
|
24
|
+
uploadsSkipped: bool,
|
|
22
25
|
label: string,
|
|
26
|
+
accounts: array<Seed_Users.user>,
|
|
27
|
+
caller: Seed_Users.user,
|
|
28
|
+
callerId: option<string>,
|
|
29
|
+
login: (~username: string, ~password: string) => promise<string>,
|
|
30
|
+
roleSwitch: option<Seed_Connect.roleSwitch>,
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
// A named, seedable data set. `seed` owns everything domain-specific.
|
|
@@ -27,6 +35,7 @@ type dataSet = Seed_Runner.dataSet = {
|
|
|
27
35
|
name: string,
|
|
28
36
|
label: string,
|
|
29
37
|
seed: connection => promise<unit>,
|
|
38
|
+
probeViews?: array<string>,
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
type rec value = Seed_Types.value =
|
package/src/Seed.res.mjs
CHANGED