@reventlessdev/reventless-seed 1.0.0-alpha.1 → 1.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/lib/bs/.compiler.log +2 -2
  3. package/lib/bs/.sourcedirs.json +1 -0
  4. package/lib/bs/ReventlessSeed.cmi +0 -0
  5. package/lib/bs/ReventlessSeed.cmt +0 -0
  6. package/lib/bs/ReventlessSeed.mlmap +3 -0
  7. package/lib/bs/compiler-info.json +2 -2
  8. package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
  9. package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
  10. package/lib/bs/src/Seed.ast +0 -0
  11. package/lib/bs/src/Seed.res +19 -0
  12. package/lib/bs/src/Seed.res.mjs +6 -0
  13. package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
  14. package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
  15. package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
  16. package/lib/bs/src/Seed_Client.ast +0 -0
  17. package/lib/bs/src/Seed_Client.res +62 -11
  18. package/lib/bs/src/Seed_Client.res.mjs +51 -5
  19. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmi +0 -0
  20. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmj +0 -0
  21. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmt +0 -0
  22. package/lib/bs/src/Seed_Connect.ast +0 -0
  23. package/lib/bs/src/Seed_Connect.res +86 -0
  24. package/lib/bs/src/Seed_Connect.res.mjs +62 -0
  25. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmi +0 -0
  26. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmj +0 -0
  27. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmt +0 -0
  28. package/lib/bs/src/Seed_Prompt.ast +0 -0
  29. package/lib/bs/src/Seed_Prompt.res +178 -0
  30. package/lib/bs/src/Seed_Prompt.res.mjs +172 -0
  31. package/lib/bs/src/Seed_Random-ReventlessSeed.cmt +0 -0
  32. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
  33. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmj +0 -0
  34. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
  35. package/lib/bs/src/Seed_Runner.ast +0 -0
  36. package/lib/bs/src/Seed_Runner.res +122 -6
  37. package/lib/bs/src/Seed_Runner.res.mjs +87 -1
  38. package/lib/bs/src/Seed_Types-ReventlessSeed.cmt +0 -0
  39. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
  40. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmj +0 -0
  41. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
  42. package/lib/bs/src/Seed_Upload.ast +0 -0
  43. package/lib/bs/src/Seed_Upload.res +40 -42
  44. package/lib/bs/src/Seed_Upload.res.mjs +65 -69
  45. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmi +0 -0
  46. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmj +0 -0
  47. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  48. package/lib/bs/tests/Seed_RandomTest.ast +0 -0
  49. package/lib/bs/tests/Seed_RandomTest.res +160 -0
  50. package/lib/bs/tests/Seed_RandomTest.res.mjs +208 -0
  51. package/lib/ocaml/.compiler.log +2 -2
  52. package/lib/ocaml/ReventlessSeed.cmi +0 -0
  53. package/lib/ocaml/ReventlessSeed.cmt +0 -0
  54. package/lib/ocaml/ReventlessSeed.mlmap +3 -0
  55. package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
  56. package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
  57. package/lib/ocaml/Seed.ast +0 -0
  58. package/lib/ocaml/Seed.res +19 -0
  59. package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
  60. package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
  61. package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
  62. package/lib/ocaml/Seed_Client.ast +0 -0
  63. package/lib/ocaml/Seed_Client.res +62 -11
  64. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
  65. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmj +0 -0
  66. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
  67. package/lib/ocaml/Seed_Connect.ast +0 -0
  68. package/lib/ocaml/Seed_Connect.res +86 -0
  69. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmi +0 -0
  70. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmj +0 -0
  71. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmt +0 -0
  72. package/lib/ocaml/Seed_Prompt.ast +0 -0
  73. package/lib/ocaml/Seed_Prompt.res +178 -0
  74. package/lib/ocaml/Seed_Random-ReventlessSeed.cmt +0 -0
  75. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmi +0 -0
  76. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmj +0 -0
  77. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  78. package/lib/ocaml/Seed_RandomTest.ast +0 -0
  79. package/lib/ocaml/Seed_RandomTest.res +160 -0
  80. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
  81. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmj +0 -0
  82. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
  83. package/lib/ocaml/Seed_Runner.ast +0 -0
  84. package/lib/ocaml/Seed_Runner.res +122 -6
  85. package/lib/ocaml/Seed_Types-ReventlessSeed.cmt +0 -0
  86. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
  87. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmj +0 -0
  88. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
  89. package/lib/ocaml/Seed_Upload.ast +0 -0
  90. package/lib/ocaml/Seed_Upload.res +40 -42
  91. package/package.json +6 -2
  92. package/rescript.json +9 -1
  93. package/src/Seed.res +19 -0
  94. package/src/Seed.res.mjs +6 -0
  95. package/src/Seed_Client.res +62 -11
  96. package/src/Seed_Client.res.mjs +51 -5
  97. package/src/Seed_Connect.res +86 -0
  98. package/src/Seed_Connect.res.mjs +62 -0
  99. package/src/Seed_Prompt.res +178 -0
  100. package/src/Seed_Prompt.res.mjs +172 -0
  101. package/src/Seed_Runner.res +122 -6
  102. package/src/Seed_Runner.res.mjs +87 -1
  103. package/src/Seed_Upload.res +40 -42
  104. package/src/Seed_Upload.res.mjs +65 -69
  105. package/tests/Seed_RandomTest.res +160 -0
  106. 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 await main() catch {
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
@@ -1,27 +1,26 @@
1
- // Seed-time asset upload against the framework's presign-shaped upload contract.
1
+ // Seed-time asset upload against the framework's upload contract (route B).
2
2
  //
3
- // One code path for both providers: the AWS `Upload_Presign_S3` Function URL and
4
- // the local dev upload route expose the SAME shape — POST `{fileName,contentType}`
5
- // → `{uploadUrl, storageRef}`, then PUT the bytes to `uploadUrl`. `uploadAsset`
6
- // resolves `Ok(storageRef)` — the same same-origin `/{prefix}/{key}` ref a command
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 the returned `uploadUrl` is resolved to an
10
- // absolute URL against the endpoint's origin: AWS returns an absolute presigned
11
- // S3 URL (unchanged), while the local route returns a same-origin relative
12
- // `/{prefix}/{key}` that Node's `fetch` cannot PUT to until it is made absolute.
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
- // absolute `input` ignores `base`, a relative one is resolved against it).
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
- // A `fetch` that reports an unreachable endpoint as `Error` instead of throwing,
42
- // so the two-step upload can thread failures without exceptions.
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` through `uploadEndpoint` and
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. `authToken`, when supplied, is sent as a
52
- * Bearer on the presign request (the endpoint decides whether it is required;
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
- ~uploadEndpoint: string,
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
- let presignHeaders = Dict.fromArray([("content-type", "application/json")])
63
- authToken->Option.forEach(t => presignHeaders->Dict.set("authorization", `Bearer ${t}`))
64
- let presignBody = JSON.stringify(
65
- JSON.Encode.object(
66
- Dict.fromArray([
67
- ("fileName", JSON.Encode.string(fileName)),
68
- ("contentType", JSON.Encode.string(contentType)),
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 await tryFetch(uploadEndpoint, {method: "POST", headers: presignHeaders, body: presignBody}) {
74
- | Error(m) => Error(`presign: ${m} — is the platform running?`)
75
- | Ok(presignRes) if !(presignRes->responseOk) =>
76
- let detail = await presignRes->responseText
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
- json->field("uploadUrl")->Option.flatMap(asString),
82
- json->field("storageRef")->Option.flatMap(asString),
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, uploadEndpoint))
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("presign response missing uploadUrl/storageRef")
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.1",
3
+ "version": "1.0.0-alpha.10",
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.3"
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,
@@ -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
- loginEndpoint: string,
31
- username: string,
32
- password: string,
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(t.config.username)),
68
- ("password", JSON.Encode.string(t.config.password)),
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
- t.config.loginEndpoint,
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 "${t.config.username}" failed with HTTP ${(res->responseStatus)
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
  *
@@ -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
- t.config.username
65
+ username
45
66
  ],
46
67
  [
47
68
  "password",
48
- t.config.password
69
+ password
49
70
  ]
50
71
  ]));
51
72
  let res;
52
73
  try {
53
- res = await fetch(t.config.loginEndpoint, {
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 ` + t.config.loginEndpoint + ` — is the platform running?`,
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 "` + t.config.username + `" failed with HTTP ` + res.status.toString() + `: ` + detail,
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 */