@reventlessdev/reventless-seed 1.0.0-alpha.1

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 (95) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +202 -0
  3. package/README.md +167 -0
  4. package/lib/bs/.compiler.log +2 -0
  5. package/lib/bs/ReventlessSeed.cmi +0 -0
  6. package/lib/bs/ReventlessSeed.cmj +0 -0
  7. package/lib/bs/ReventlessSeed.cmt +0 -0
  8. package/lib/bs/ReventlessSeed.mlmap +7 -0
  9. package/lib/bs/build.ninja +0 -0
  10. package/lib/bs/compiler-info.json +8 -0
  11. package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
  12. package/lib/bs/src/Seed-ReventlessSeed.cmj +0 -0
  13. package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
  14. package/lib/bs/src/Seed.ast +0 -0
  15. package/lib/bs/src/Seed.res +30 -0
  16. package/lib/bs/src/Seed.res.mjs +34 -0
  17. package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
  18. package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
  19. package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
  20. package/lib/bs/src/Seed_Client.ast +0 -0
  21. package/lib/bs/src/Seed_Client.res +251 -0
  22. package/lib/bs/src/Seed_Client.res.mjs +242 -0
  23. package/lib/bs/src/Seed_Random-ReventlessSeed.cmi +0 -0
  24. package/lib/bs/src/Seed_Random-ReventlessSeed.cmj +0 -0
  25. package/lib/bs/src/Seed_Random-ReventlessSeed.cmt +0 -0
  26. package/lib/bs/src/Seed_Random.ast +0 -0
  27. package/lib/bs/src/Seed_Random.res +80 -0
  28. package/lib/bs/src/Seed_Random.res.mjs +86 -0
  29. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
  30. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmj +0 -0
  31. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
  32. package/lib/bs/src/Seed_Runner.ast +0 -0
  33. package/lib/bs/src/Seed_Runner.res +114 -0
  34. package/lib/bs/src/Seed_Runner.res.mjs +118 -0
  35. package/lib/bs/src/Seed_Types-ReventlessSeed.cmi +0 -0
  36. package/lib/bs/src/Seed_Types-ReventlessSeed.cmj +0 -0
  37. package/lib/bs/src/Seed_Types-ReventlessSeed.cmt +0 -0
  38. package/lib/bs/src/Seed_Types.ast +0 -0
  39. package/lib/bs/src/Seed_Types.res +49 -0
  40. package/lib/bs/src/Seed_Types.res.mjs +79 -0
  41. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
  42. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmj +0 -0
  43. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
  44. package/lib/bs/src/Seed_Upload.ast +0 -0
  45. package/lib/bs/src/Seed_Upload.res +99 -0
  46. package/lib/bs/src/Seed_Upload.res.mjs +118 -0
  47. package/lib/ocaml/.compiler.log +2 -0
  48. package/lib/ocaml/ReventlessSeed.cmi +0 -0
  49. package/lib/ocaml/ReventlessSeed.cmj +0 -0
  50. package/lib/ocaml/ReventlessSeed.cmt +0 -0
  51. package/lib/ocaml/ReventlessSeed.mlmap +7 -0
  52. package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
  53. package/lib/ocaml/Seed-ReventlessSeed.cmj +0 -0
  54. package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
  55. package/lib/ocaml/Seed.ast +0 -0
  56. package/lib/ocaml/Seed.res +30 -0
  57. package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
  58. package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
  59. package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
  60. package/lib/ocaml/Seed_Client.ast +0 -0
  61. package/lib/ocaml/Seed_Client.res +251 -0
  62. package/lib/ocaml/Seed_Random-ReventlessSeed.cmi +0 -0
  63. package/lib/ocaml/Seed_Random-ReventlessSeed.cmj +0 -0
  64. package/lib/ocaml/Seed_Random-ReventlessSeed.cmt +0 -0
  65. package/lib/ocaml/Seed_Random.ast +0 -0
  66. package/lib/ocaml/Seed_Random.res +80 -0
  67. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
  68. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmj +0 -0
  69. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
  70. package/lib/ocaml/Seed_Runner.ast +0 -0
  71. package/lib/ocaml/Seed_Runner.res +114 -0
  72. package/lib/ocaml/Seed_Types-ReventlessSeed.cmi +0 -0
  73. package/lib/ocaml/Seed_Types-ReventlessSeed.cmj +0 -0
  74. package/lib/ocaml/Seed_Types-ReventlessSeed.cmt +0 -0
  75. package/lib/ocaml/Seed_Types.ast +0 -0
  76. package/lib/ocaml/Seed_Types.res +49 -0
  77. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
  78. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmj +0 -0
  79. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
  80. package/lib/ocaml/Seed_Upload.ast +0 -0
  81. package/lib/ocaml/Seed_Upload.res +99 -0
  82. package/package.json +26 -0
  83. package/rescript.json +20 -0
  84. package/src/Seed.res +30 -0
  85. package/src/Seed.res.mjs +34 -0
  86. package/src/Seed_Client.res +251 -0
  87. package/src/Seed_Client.res.mjs +242 -0
  88. package/src/Seed_Random.res +80 -0
  89. package/src/Seed_Random.res.mjs +86 -0
  90. package/src/Seed_Runner.res +114 -0
  91. package/src/Seed_Runner.res.mjs +118 -0
  92. package/src/Seed_Types.res +49 -0
  93. package/src/Seed_Types.res.mjs +79 -0
  94. package/src/Seed_Upload.res +99 -0
  95. package/src/Seed_Upload.res.mjs +118 -0
@@ -0,0 +1,251 @@
1
+ // GraphQL transport for seeding: authentication, mutation dispatch, and the
2
+ // query helpers a seed run needs to observe what it produced.
3
+ //
4
+ // Everything here is domain-agnostic — it knows about the shapes the framework
5
+ // generates (the CommandResult union, Relay-style connections) but nothing about
6
+ // any particular plugin.
7
+
8
+ open Seed_Types
9
+
10
+ // ── Node 18+ globals ────────────────────────────────────────────────────────
11
+
12
+ type fetchInit = {method: string, headers: dict<string>, body: string}
13
+ type response
14
+
15
+ @val external fetch: (string, fetchInit) => promise<response> = "fetch"
16
+ @send external responseJson: response => promise<JSON.t> = "json"
17
+ @send external responseText: response => promise<string> = "text"
18
+ @get external responseOk: response => bool = "ok"
19
+ @get external responseStatus: response => int = "status"
20
+
21
+ @val external setTimeout: (unit => unit, int) => unit = "setTimeout"
22
+ @scope("Date") @val external nowMs: unit => float = "now"
23
+
24
+ let sleep = (ms: int): promise<unit> => Promise.make((resolve, _) => setTimeout(() => resolve(), ms))
25
+
26
+ // ── Configuration ───────────────────────────────────────────────────────────
27
+
28
+ type config = {
29
+ endpoint: string,
30
+ loginEndpoint: string,
31
+ username: string,
32
+ password: string,
33
+ }
34
+
35
+ type t = {config: config, mutable token: option<string>}
36
+
37
+ let make = (~config: config): t => {config, token: None}
38
+
39
+ /** The bearer token from the last successful `login`, if any — so callers (e.g.
40
+ Seed.Upload) can authenticate side-channel requests with the same identity. */
41
+ let currentToken = (t: t): option<string> => t.token
42
+
43
+ // ── JSON helpers ────────────────────────────────────────────────────────────
44
+
45
+ let field = (json: JSON.t, key: string): option<JSON.t> =>
46
+ switch json {
47
+ | Object(obj) => obj->Dict.get(key)
48
+ | _ => None
49
+ }
50
+
51
+ let asString = (json: JSON.t): option<string> =>
52
+ switch json {
53
+ | String(s) => Some(s)
54
+ | _ => None
55
+ }
56
+
57
+ /** Reads a string property off a node returned by `queryAllNodes`. */
58
+ let nodeString = (node: JSON.t, key: string): option<string> =>
59
+ node->field(key)->Option.flatMap(asString)
60
+
61
+ // ── Requests ────────────────────────────────────────────────────────────────
62
+
63
+ let login = async (t: t): unit => {
64
+ let body = JSON.stringify(
65
+ JSON.Encode.object(
66
+ Dict.fromArray([
67
+ ("username", JSON.Encode.string(t.config.username)),
68
+ ("password", JSON.Encode.string(t.config.password)),
69
+ ]),
70
+ ),
71
+ )
72
+ let res = try await fetch(
73
+ t.config.loginEndpoint,
74
+ {method: "POST", headers: Dict.fromArray([("content-type", "application/json")]), body},
75
+ ) catch {
76
+ | _ =>
77
+ throw(
78
+ Failed(`login: cannot reach ${t.config.loginEndpoint} — is the platform running?`),
79
+ )
80
+ }
81
+ if !(res->responseOk) {
82
+ let detail = await res->responseText
83
+ throw(
84
+ Failed(
85
+ `login as "${t.config.username}" failed with HTTP ${(res->responseStatus)
86
+ ->Int.toString}: ${detail}`,
87
+ ),
88
+ )
89
+ }
90
+ let json = await res->responseJson
91
+ t.token = json->field("token")->Option.flatMap(asString)
92
+ }
93
+
94
+ /**
95
+ * Sends one GraphQL document and returns its `data`. A GraphQL-level error
96
+ * aborts the run: a half-seeded store is worse than an empty one, because it
97
+ * looks like a working dataset.
98
+ */
99
+ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
100
+ let headers = Dict.fromArray([("content-type", "application/json")])
101
+ switch t.token {
102
+ | Some(token) => headers->Dict.set("authorization", `Bearer ${token}`)
103
+ | None => ()
104
+ }
105
+ let body = JSON.stringify(
106
+ JSON.Encode.object(Dict.fromArray([("query", JSON.Encode.string(query))])),
107
+ )
108
+ let res = try await fetch(t.config.endpoint, {method: "POST", headers, body}) catch {
109
+ | _ =>
110
+ throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
111
+ }
112
+ let json = await res->responseJson
113
+ switch json->field("errors") {
114
+ | Some(errors) =>
115
+ throw(
116
+ Failed(`${label} failed\n query: ${query}\n response: ${JSON.stringify(errors)}`),
117
+ )
118
+ | None => json->field("data")->Option.getOr(JSON.Encode.null)
119
+ }
120
+ }
121
+
122
+ // ── Commands ────────────────────────────────────────────────────────────────
123
+
124
+ let commandResultSelection = `__typename
125
+ ... on CommandAccepted { eventCount }
126
+ ... on CommandRejected { errorCode errorDetail }`
127
+
128
+ /**
129
+ * Issues one command and insists it was accepted.
130
+ *
131
+ * `tolerate` names error codes that are an expected outcome of the domain
132
+ * rather than a broken seed; a tolerated rejection is returned to the caller so
133
+ * it can report on them instead of failing.
134
+ */
135
+ let send = async (t: t, m: mutation, ~tolerate: array<string>=[]): option<string> => {
136
+ let label = m->describe
137
+ let query = `mutation { r: ${m.field}(${m.args->renderArgs}) { ${commandResultSelection} } }`
138
+ let data = await gql(t, ~query, ~label)
139
+ let result = data->field("r")->Option.getOr(JSON.Encode.null)
140
+ switch result->nodeString("__typename") {
141
+ | Some("CommandRejected") =>
142
+ let code = result->nodeString("errorCode")->Option.getOr("unknown")
143
+ let detail = result->nodeString("errorDetail")->Option.getOr("none")
144
+ if tolerate->Array.includes(code) {
145
+ Some(code)
146
+ } else {
147
+ throw(Failed(`${label} was rejected\n errorCode: ${code}\n errorDetail: ${detail}`))
148
+ }
149
+ | _ => None
150
+ }
151
+ }
152
+
153
+ let sendAll = async (t: t, mutations: array<mutation>): unit =>
154
+ for i in 0 to mutations->Array.length - 1 {
155
+ switch mutations->Array.get(i) {
156
+ | Some(m) => (await send(t, m))->ignore
157
+ | None => ()
158
+ }
159
+ }
160
+
161
+ // ── Queries ─────────────────────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Walks a Relay-style connection to the end. Connections page at 50 by default,
165
+ * so any count taken from a single request silently truncates.
166
+ */
167
+ let queryAllNodes = async (t: t, ~field as fieldName: string, ~selection: string): array<
168
+ JSON.t,
169
+ > => {
170
+ let nodes = []
171
+ let after = ref(None)
172
+ let more = ref(true)
173
+ while more.contents {
174
+ let cursor = switch after.contents {
175
+ | Some(c) => `, after: ${quote(c)}`
176
+ | None => ""
177
+ }
178
+ let query = `{ ${fieldName}(first: 100${cursor}) { edges { node { ${selection} } } pageInfo { hasNextPage endCursor } } }`
179
+ let data = await gql(t, ~query, ~label=fieldName)
180
+ let connection = data->field(fieldName)->Option.getOr(JSON.Encode.null)
181
+ switch connection->field("edges") {
182
+ | Some(Array(edges)) =>
183
+ edges->Array.forEach(edge =>
184
+ switch edge->field("node") {
185
+ | Some(node) => nodes->Array.push(node)
186
+ | None => ()
187
+ }
188
+ )
189
+ | _ => ()
190
+ }
191
+ let pageInfo = connection->field("pageInfo")->Option.getOr(JSON.Encode.null)
192
+ let hasNext = switch pageInfo->field("hasNextPage") {
193
+ | Some(Boolean(b)) => b
194
+ | _ => false
195
+ }
196
+ let endCursor = pageInfo->nodeString("endCursor")
197
+ switch (hasNext, endCursor) {
198
+ | (true, Some(c)) => after := Some(c)
199
+ | _ => more := false
200
+ }
201
+ }
202
+ nodes
203
+ }
204
+
205
+ let countNodes = async (t: t, ~field: string): int =>
206
+ (await queryAllNodes(t, ~field, ~selection="id"))->Array.length
207
+
208
+ /**
209
+ * Polls a `<View>ByIds` field until every id is present.
210
+ *
211
+ * Cross-plugin propagation (extension point → extension → command) is
212
+ * asynchronous, so a seed that places orders immediately after creating
213
+ * products would race the shadow copy. Waiting on the observable result beats
214
+ * sleeping and hoping.
215
+ */
216
+ let waitForIds = async (
217
+ t: t,
218
+ ~field as fieldName: string,
219
+ ~ids: array<string>,
220
+ ~timeoutMs: int=60000,
221
+ ): int => {
222
+ let deadline = nowMs() +. Int.toFloat(timeoutMs)
223
+ let idList = ids->Array.map(id => quote(id))->Array.join(", ")
224
+ let result = ref(None)
225
+ while result.contents == None {
226
+ let query = `{ ${fieldName}(ids: [${idList}]) { id } }`
227
+ let data = await gql(t, ~query, ~label=fieldName)
228
+ let found = switch data->field(fieldName) {
229
+ | Some(Array(rows)) => rows
230
+ | _ => []
231
+ }
232
+ if found->Array.length >= ids->Array.length {
233
+ result := Some(found->Array.length)
234
+ } else if nowMs() > deadline {
235
+ let present = found->Array.filterMap(row => row->nodeString("id"))
236
+ let missing = ids->Array.filter(id => !(present->Array.includes(id)))
237
+ let shown = missing->Array.slice(~start=0, ~end=10)->Array.join(", ")
238
+ let extra =
239
+ missing->Array.length > 10 ? ` (+${(missing->Array.length - 10)->Int.toString} more)` : ""
240
+ throw(
241
+ Failed(
242
+ `only ${(found->Array.length)->Int.toString}/${(ids->Array.length)
243
+ ->Int.toString} ids reached ${fieldName} within ${timeoutMs->Int.toString}ms.\n missing: ${shown}${extra}`,
244
+ ),
245
+ )
246
+ } else {
247
+ await sleep(250)
248
+ }
249
+ }
250
+ result.contents->Option.getOr(0)
251
+ }
Binary file
@@ -0,0 +1,80 @@
1
+ // Deterministic pseudo-randomness for reproducible datasets.
2
+ //
3
+ // A seeded generator rather than `Math.random`, so two runs against a fresh
4
+ // store produce identical data — which is what makes a seeded store usable as a
5
+ // baseline for screenshots or comparison.
6
+
7
+ @scope("Math") @val external imul: (int, int) => int = "imul"
8
+
9
+ type t = {mutable state: int}
10
+
11
+ let make = (~seed: int): t => {state: seed}
12
+
13
+
14
+ /** mulberry32 — small, fast, and stable across JS engines and Node versions. */
15
+ let float = (r: t): float => {
16
+ r.state = (r.state + 0x6d2b79f5)->Int.bitwiseOr(0)
17
+ let a = r.state
18
+ let b = imul(a->Int.bitwiseXor(a->Int.shiftRightUnsigned(15)), a->Int.bitwiseOr(1))
19
+ let c = b->Int.bitwiseXor(b + imul(b->Int.bitwiseXor(b->Int.shiftRightUnsigned(7)), b->Int.bitwiseOr(61)))
20
+ let bits = c->Int.bitwiseXor(c->Int.shiftRightUnsigned(14))
21
+ // `bits` is a signed 32-bit result; fold the sign bit back in to get the
22
+ // unsigned value a `>>> 0` would produce.
23
+ let unsigned = bits < 0 ? Int.toFloat(bits) +. 4294967296.0 : Int.toFloat(bits)
24
+ unsigned /. 4294967296.0
25
+ }
26
+
27
+ /** Inclusive on both ends. */
28
+ let int = (r: t, ~min: int, ~max: int): int =>
29
+ min + Int.fromFloat(float(r) *. Int.toFloat(max - min + 1))
30
+
31
+ let pick = (r: t, xs: array<'a>): option<'a> =>
32
+ xs->Array.get(Int.fromFloat(float(r) *. Int.toFloat(xs->Array.length)))
33
+
34
+ let pickOr = (r: t, ~fallback: 'a, xs: array<'a>): 'a => pick(r, xs)->Option.getOr(fallback)
35
+
36
+ /**
37
+ * Weighted sample without replacement.
38
+ *
39
+ * Used both for skewing which items are chosen (a few popular ones, a long
40
+ * tail) and for shuffling — pass equal weights and a count equal to the input
41
+ * length to get a deterministic permutation.
42
+ */
43
+ let sampleWeighted = (r: t, entries: array<('a, float)>, ~count: int): array<'a> => {
44
+ let pool = entries->Array.copy
45
+ let chosen = []
46
+ let remaining = count < pool->Array.length ? count : pool->Array.length
47
+ for _ in 1 to remaining {
48
+ let total = pool->Array.reduce(0.0, (sum, (_, w)) => sum +. w)
49
+ let cursor = ref(float(r) *. total)
50
+ let index = ref(pool->Array.length - 1)
51
+ let found = ref(false)
52
+ pool->Array.forEachWithIndex(((_, w), i) => {
53
+ if !found.contents {
54
+ cursor := cursor.contents -. w
55
+ if cursor.contents <= 0.0 {
56
+ index := i
57
+ found := true
58
+ }
59
+ }
60
+ })
61
+ switch pool->Array.get(index.contents) {
62
+ | Some((item, _)) =>
63
+ chosen->Array.push(item)
64
+ pool->Array.splice(~start=index.contents, ~remove=1, ~insert=[])
65
+ | None => ()
66
+ }
67
+ }
68
+ chosen
69
+ }
70
+
71
+ /**
72
+ * Zipf-like weights over an already-ordered array: the first entry carries the
73
+ * most weight and the tail decays smoothly. `exponent` controls how sharp the
74
+ * head is — around 1.1 gives a clear leader with a long tail.
75
+ */
76
+ let zipfWeights = (items: array<'a>, ~exponent: float): array<('a, float)> =>
77
+ items->Array.mapWithIndex((item, rank) => (
78
+ item,
79
+ 1.0 /. Math.pow(Int.toFloat(rank + 1), ~exp=exponent),
80
+ ))
Binary file
@@ -0,0 +1,114 @@
1
+ // Run orchestration: progress output, view verification, and failure reporting.
2
+
3
+ open Seed_Types
4
+
5
+ @scope("process") @val external exit: int => unit = "exit"
6
+ @scope("process") @val external env: dict<string> = "env"
7
+
8
+ let envOr = (key: string, fallback: string): string =>
9
+ env->Dict.get(key)->Option.getOr(fallback)
10
+
11
+ /** One line of progress per phase, so a slow run never looks hung. */
12
+ let report = (line: string): unit => Console.log(` ${line}`)
13
+
14
+ let heading = (line: string): unit => {
15
+ Console.log("")
16
+ Console.log(line)
17
+ }
18
+
19
+ /**
20
+ * A view the seed is expected to fill, or one it demonstrably cannot.
21
+ *
22
+ * Splitting them matters: an unexpected empty view means the seed missed a
23
+ * component, which is exactly the "is it broken or is it empty?" ambiguity
24
+ * seeding exists to remove — so it fails the run. A view listed as unfillable
25
+ * reports its reason instead, so a zero is never mistaken for a gap.
26
+ */
27
+ type view =
28
+ | Seeded(string)
29
+ | Unfillable(string, string)
30
+
31
+ let viewName = (v: view): string =>
32
+ switch v {
33
+ | Seeded(name) => name
34
+ | Unfillable(name, _) => name
35
+ }
36
+
37
+ /**
38
+ * Counts every view and fails if one that should have rows is empty.
39
+ * Returns the counts so callers can fold them into their own summary.
40
+ */
41
+ let verifyViews = async (client: Seed_Client.t, ~views: array<view>): dict<int> => {
42
+ let counts = Dict.make()
43
+ for i in 0 to views->Array.length - 1 {
44
+ switch views->Array.get(i) {
45
+ | Some(v) =>
46
+ let name = v->viewName
47
+ counts->Dict.set(name, await Seed_Client.countNodes(client, ~field=name))
48
+ | None => ()
49
+ }
50
+ }
51
+
52
+ heading("View row counts:")
53
+ views->Array.forEach(v => {
54
+ let name = v->viewName
55
+ let count = counts->Dict.get(name)->Option.getOr(0)
56
+ let note = switch v {
57
+ | Unfillable(_, _) => " (known empty — see warning below)"
58
+ | Seeded(_) => ""
59
+ }
60
+ Console.log(` ${count->Int.toString->String.padStart(4, " ")} ${name}${note}`)
61
+ })
62
+
63
+ let empty =
64
+ views
65
+ ->Array.filterMap(v =>
66
+ switch v {
67
+ | Seeded(name) if counts->Dict.get(name)->Option.getOr(0) == 0 => Some(name)
68
+ | _ => None
69
+ }
70
+ )
71
+ if empty->Array.length > 0 {
72
+ throw(Failed(`these views are still empty after seeding: ${empty->Array.join(", ")}`))
73
+ }
74
+ counts
75
+ }
76
+
77
+ /** Collects the reasons for any view that legitimately could not be filled. */
78
+ let unfillableWarnings = (~views: array<view>, ~counts: dict<int>): array<string> =>
79
+ views->Array.filterMap(v =>
80
+ switch v {
81
+ | Unfillable(name, reason) if counts->Dict.get(name)->Option.getOr(0) == 0 =>
82
+ Some(`${name} is empty: ${reason}.`)
83
+ | _ => None
84
+ }
85
+ )
86
+
87
+ let warn = (warnings: array<string>): unit =>
88
+ if warnings->Array.length > 0 {
89
+ heading("WARNING — the dataset is complete, but not everything could be filled:")
90
+ warnings->Array.forEach(w => Console.log(` - ${w}`))
91
+ }
92
+
93
+ /**
94
+ * Wraps a seed run. On failure it prints the cause and exits non-zero, saying
95
+ * plainly that the store is now half-seeded — recovery is a reset, not a re-run.
96
+ */
97
+ let run = async (main: unit => promise<unit>): unit =>
98
+ try await main() catch {
99
+ | Failed(message) =>
100
+ Console.error("")
101
+ Console.error("Seeding aborted — the store is now half-seeded; re-run against a")
102
+ Console.error("fresh store once the cause below is fixed.")
103
+ Console.error("")
104
+ Console.error(message)
105
+ exit(1)
106
+ | exn =>
107
+ Console.error("")
108
+ Console.error("Seeding aborted with an unexpected error:")
109
+ Console.error("")
110
+ Console.error(
111
+ exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown"),
112
+ )
113
+ exit(1)
114
+ }
Binary file
@@ -0,0 +1,49 @@
1
+ // Argument values for a seeded mutation, and their GraphQL literal rendering.
2
+ //
3
+ // Seeding sends inline literals rather than declared variables, so any mutation
4
+ // can be called without generating a per-command variable signature. That means
5
+ // values have to know how to render themselves — in particular enums, which are
6
+ // bare identifiers in GraphQL and would be rejected if quoted like strings.
7
+
8
+ exception Failed(string)
9
+
10
+ type rec value =
11
+ | String(string)
12
+ | Id(string)
13
+ | Int(int)
14
+ | Float(float)
15
+ | Bool(bool)
16
+ /** Rendered unquoted — GraphQL enum values are identifiers, not strings. */
17
+ | Enum(string)
18
+ | List(array<value>)
19
+ /** A nested GraphQL input object, e.g. a geo-point `{lat, lng}`. Keys are
20
+ rendered as bare identifiers, as GraphQL input-object fields require. */
21
+ | Object(array<(string, value)>)
22
+
23
+ let quote = (s: string): string => JSON.stringify(JSON.Encode.string(s))
24
+
25
+ let rec toLiteral = (v: value): string =>
26
+ switch v {
27
+ | String(s) | Id(s) => quote(s)
28
+ | Int(i) => i->Int.toString
29
+ | Float(f) => f->Float.toString
30
+ | Bool(b) => b ? "true" : "false"
31
+ | Enum(name) => name
32
+ | List(items) => `[${items->Array.map(toLiteral)->Array.join(", ")}]`
33
+ | Object(fields) =>
34
+ `{${fields->Array.map(((k, v)) => `${k}: ${toLiteral(v)}`)->Array.join(", ")}}`
35
+ }
36
+
37
+ let ids = (xs: array<string>): value => List(xs->Array.map(x => Id(x)))
38
+ let strings = (xs: array<string>): value => List(xs->Array.map(x => String(x)))
39
+
40
+ /** One mutation call: the field name and its inline arguments. */
41
+ type mutation = {field: string, args: array<(string, value)>}
42
+
43
+ let mutation = (field: string, args: array<(string, value)>): mutation => {field, args}
44
+
45
+ let renderArgs = (args: array<(string, value)>): string =>
46
+ args->Array.map(((k, v)) => `${k}: ${toLiteral(v)}`)->Array.join(", ")
47
+
48
+ /** Human-readable form used in progress output and failure messages. */
49
+ let describe = (m: mutation): string => `${m.field}(${renderArgs(m.args)})`
Binary file
@@ -0,0 +1,99 @@
1
+ // Seed-time asset upload against the framework's presign-shaped upload contract.
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.
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.
13
+
14
+ type fetchInit = {method: string, headers: dict<string>, body: string}
15
+ type response
16
+
17
+ @val external fetch: (string, fetchInit) => promise<response> = "fetch"
18
+ @send external responseJson: response => promise<JSON.t> = "json"
19
+ @send external responseText: response => promise<string> = "text"
20
+ @get external responseOk: response => bool = "ok"
21
+ @get external responseStatus: response => int = "status"
22
+
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).
25
+ type url
26
+ @new external makeUrl: (string, string) => url = "URL"
27
+ @get external urlHref: url => string = "href"
28
+
29
+ let field = (json: JSON.t, key: string): option<JSON.t> =>
30
+ switch json {
31
+ | Object(obj) => obj->Dict.get(key)
32
+ | _ => None
33
+ }
34
+
35
+ let asString = (json: JSON.t): option<string> =>
36
+ switch json {
37
+ | String(s) => Some(s)
38
+ | _ => None
39
+ }
40
+
41
+ // A `fetch` that reports an unreachable endpoint as `Error` instead of throwing,
42
+ // so the two-step upload can thread failures without exceptions.
43
+ let tryFetch = async (url: string, init: fetchInit): result<response, string> =>
44
+ try Ok(await fetch(url, init)) catch {
45
+ | _ => Error(`cannot reach ${url}`)
46
+ }
47
+
48
+ /**
49
+ * Uploads `bytes` under `fileName`/`contentType` through `uploadEndpoint` and
50
+ * 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
+ */
55
+ let uploadAsset = async (
56
+ ~uploadEndpoint: string,
57
+ ~bytes: string,
58
+ ~fileName: string,
59
+ ~contentType: string,
60
+ ~authToken: option<string>=?,
61
+ ): 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
+ )
72
+
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
80
+ switch (
81
+ json->field("uploadUrl")->Option.flatMap(asString),
82
+ json->field("storageRef")->Option.flatMap(asString),
83
+ ) {
84
+ | (Some(uploadUrl), Some(storageRef)) =>
85
+ let putUrl = urlHref(makeUrl(uploadUrl, uploadEndpoint))
86
+ switch await tryFetch(
87
+ putUrl,
88
+ {method: "PUT", headers: Dict.fromArray([("content-type", contentType)]), body: bytes},
89
+ ) {
90
+ | Error(m) => Error(`upload PUT: ${m}`)
91
+ | Ok(putRes) if putRes->responseOk => Ok(storageRef)
92
+ | Ok(putRes) =>
93
+ let detail = await putRes->responseText
94
+ Error(`upload PUT failed with HTTP ${(putRes->responseStatus)->Int.toString}: ${detail}`)
95
+ }
96
+ | _ => Error("presign response missing uploadUrl/storageRef")
97
+ }
98
+ }
99
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@reventlessdev/reventless-seed",
3
+ "version": "1.0.0-alpha.1",
4
+ "description": "Reusable harness for seeding a Reventless platform through its public GraphQL command API",
5
+ "license": "Apache-2.0",
6
+ "devDependencies": {
7
+ "rescript": "12.3.0"
8
+ },
9
+ "peerDependencies": {
10
+ "rescript": "12.3.0"
11
+ },
12
+ "publishConfig": {
13
+ "registry": "https://registry.npmjs.org"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ReventlessDev/reventless-core.git",
18
+ "directory": "reventless/seed"
19
+ },
20
+ "scripts": {
21
+ "build": "rescript build",
22
+ "start": "rescript watch",
23
+ "clean": "rescript clean",
24
+ "rebuild": "pnpm run clean && pnpm run build"
25
+ }
26
+ }
package/rescript.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@reventlessdev/reventless-seed",
3
+ "package-specs": {
4
+ "module": "esmodule",
5
+ "in-source": true
6
+ },
7
+ "namespace": "ReventlessSeed",
8
+ "warnings": {
9
+ "error": "-44+101"
10
+ },
11
+ "sources": [
12
+ {
13
+ "dir": "src",
14
+ "subdirs": true
15
+ }
16
+ ],
17
+ "dependencies": [],
18
+ "compiler-flags": [],
19
+ "suffix": ".res.mjs"
20
+ }