@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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +202 -0
- package/README.md +167 -0
- package/lib/bs/.compiler.log +2 -0
- package/lib/bs/ReventlessSeed.cmi +0 -0
- package/lib/bs/ReventlessSeed.cmj +0 -0
- package/lib/bs/ReventlessSeed.cmt +0 -0
- package/lib/bs/ReventlessSeed.mlmap +7 -0
- package/lib/bs/build.ninja +0 -0
- package/lib/bs/compiler-info.json +8 -0
- package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed-ReventlessSeed.cmj +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 +30 -0
- package/lib/bs/src/Seed.res.mjs +34 -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 +251 -0
- package/lib/bs/src/Seed_Client.res.mjs +242 -0
- package/lib/bs/src/Seed_Random-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Random-ReventlessSeed.cmj +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 +80 -0
- package/lib/bs/src/Seed_Random.res.mjs +86 -0
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Runner.ast +0 -0
- package/lib/bs/src/Seed_Runner.res +114 -0
- package/lib/bs/src/Seed_Runner.res.mjs +118 -0
- package/lib/bs/src/Seed_Types-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Types-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Types-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Types.ast +0 -0
- package/lib/bs/src/Seed_Types.res +49 -0
- package/lib/bs/src/Seed_Types.res.mjs +79 -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 +99 -0
- package/lib/bs/src/Seed_Upload.res.mjs +118 -0
- package/lib/ocaml/.compiler.log +2 -0
- package/lib/ocaml/ReventlessSeed.cmi +0 -0
- package/lib/ocaml/ReventlessSeed.cmj +0 -0
- package/lib/ocaml/ReventlessSeed.cmt +0 -0
- package/lib/ocaml/ReventlessSeed.mlmap +7 -0
- package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed.ast +0 -0
- package/lib/ocaml/Seed.res +30 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Client.ast +0 -0
- package/lib/ocaml/Seed_Client.res +251 -0
- package/lib/ocaml/Seed_Random-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Random-ReventlessSeed.cmj +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 +80 -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 +114 -0
- package/lib/ocaml/Seed_Types-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Types-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Types-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Types.ast +0 -0
- package/lib/ocaml/Seed_Types.res +49 -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 +99 -0
- package/package.json +26 -0
- package/rescript.json +20 -0
- package/src/Seed.res +30 -0
- package/src/Seed.res.mjs +34 -0
- package/src/Seed_Client.res +251 -0
- package/src/Seed_Client.res.mjs +242 -0
- package/src/Seed_Random.res +80 -0
- package/src/Seed_Random.res.mjs +86 -0
- package/src/Seed_Runner.res +114 -0
- package/src/Seed_Runner.res.mjs +118 -0
- package/src/Seed_Types.res +49 -0
- package/src/Seed_Types.res.mjs +79 -0
- package/src/Seed_Upload.res +99 -0
- 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
|
|
Binary file
|
|
Binary file
|
|
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
|
|
Binary file
|
|
Binary file
|
|
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
|
|
Binary file
|
|
Binary file
|
|
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
|
|
Binary file
|
|
Binary file
|
|
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
|
+
}
|