@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
package/src/Seed.res ADDED
@@ -0,0 +1,30 @@
1
+ // Facade for the seeding harness.
2
+ //
3
+ // A seed run is: build a list of mutations from typed domain commands, send
4
+ // them through the public GraphQL API in phases, then verify what the views
5
+ // actually contain. Nothing here knows about a specific domain — the mapping
6
+ // from a plugin's command types to `mutation` values is the caller's adapter.
7
+
8
+ module Random = Seed_Random
9
+ module Client = Seed_Client
10
+ module Runner = Seed_Runner
11
+ module Upload = Seed_Upload
12
+
13
+ exception Failed = Seed_Types.Failed
14
+
15
+ type rec value = Seed_Types.value =
16
+ | String(string)
17
+ | Id(string)
18
+ | Int(int)
19
+ | Float(float)
20
+ | Bool(bool)
21
+ | Enum(string)
22
+ | List(array<value>)
23
+ | Object(array<(string, value)>)
24
+
25
+ type mutation = Seed_Types.mutation = {field: string, args: array<(string, value)>}
26
+
27
+ let mutation = Seed_Types.mutation
28
+ let ids = Seed_Types.ids
29
+ let strings = Seed_Types.strings
30
+ let describe = Seed_Types.describe
@@ -0,0 +1,34 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Seed_Types$ReventlessSeed from "./Seed_Types.res.mjs";
4
+
5
+ let Random;
6
+
7
+ let Client;
8
+
9
+ let Runner;
10
+
11
+ let Upload;
12
+
13
+ let Failed = Seed_Types$ReventlessSeed.Failed;
14
+
15
+ let mutation = Seed_Types$ReventlessSeed.mutation;
16
+
17
+ let ids = Seed_Types$ReventlessSeed.ids;
18
+
19
+ let strings = Seed_Types$ReventlessSeed.strings;
20
+
21
+ let describe = Seed_Types$ReventlessSeed.describe;
22
+
23
+ export {
24
+ Random,
25
+ Client,
26
+ Runner,
27
+ Upload,
28
+ Failed,
29
+ mutation,
30
+ ids,
31
+ strings,
32
+ describe,
33
+ }
34
+ /* No side effect */
@@ -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
+ }
@@ -0,0 +1,242 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Seed_Types$ReventlessSeed from "./Seed_Types.res.mjs";
6
+
7
+ function sleep(ms) {
8
+ return new Promise((resolve, param) => {
9
+ setTimeout(() => resolve(), ms);
10
+ });
11
+ }
12
+
13
+ function make(config) {
14
+ return {
15
+ config: config,
16
+ token: undefined
17
+ };
18
+ }
19
+
20
+ function currentToken(t) {
21
+ return t.token;
22
+ }
23
+
24
+ function field(json, key) {
25
+ if (typeof json === "object" && json !== null && !Array.isArray(json)) {
26
+ return json[key];
27
+ }
28
+ }
29
+
30
+ function asString(json) {
31
+ if (typeof json === "string") {
32
+ return json;
33
+ }
34
+ }
35
+
36
+ function nodeString(node, key) {
37
+ return Stdlib_Option.flatMap(field(node, key), asString);
38
+ }
39
+
40
+ async function login(t) {
41
+ let body = JSON.stringify(Object.fromEntries([
42
+ [
43
+ "username",
44
+ t.config.username
45
+ ],
46
+ [
47
+ "password",
48
+ t.config.password
49
+ ]
50
+ ]));
51
+ let res;
52
+ try {
53
+ res = await fetch(t.config.loginEndpoint, {
54
+ method: "POST",
55
+ headers: Object.fromEntries([[
56
+ "content-type",
57
+ "application/json"
58
+ ]]),
59
+ body: body
60
+ });
61
+ } catch (exn) {
62
+ throw {
63
+ RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
64
+ _1: `login: cannot reach ` + t.config.loginEndpoint + ` — is the platform running?`,
65
+ Error: new Error()
66
+ };
67
+ }
68
+ if (!res.ok) {
69
+ let detail = await res.text();
70
+ throw {
71
+ RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
72
+ _1: `login as "` + t.config.username + `" failed with HTTP ` + res.status.toString() + `: ` + detail,
73
+ Error: new Error()
74
+ };
75
+ }
76
+ let json = await res.json();
77
+ t.token = Stdlib_Option.flatMap(field(json, "token"), asString);
78
+ }
79
+
80
+ async function gql(t, query, label) {
81
+ let headers = Object.fromEntries([[
82
+ "content-type",
83
+ "application/json"
84
+ ]]);
85
+ let token = t.token;
86
+ if (token !== undefined) {
87
+ headers["authorization"] = `Bearer ` + token;
88
+ }
89
+ let body = JSON.stringify(Object.fromEntries([[
90
+ "query",
91
+ query
92
+ ]]));
93
+ let res;
94
+ try {
95
+ res = await fetch(t.config.endpoint, {
96
+ method: "POST",
97
+ headers: headers,
98
+ body: body
99
+ });
100
+ } catch (exn) {
101
+ throw {
102
+ RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
103
+ _1: label + `: cannot reach ` + t.config.endpoint + ` — is the platform running?`,
104
+ Error: new Error()
105
+ };
106
+ }
107
+ let json = await res.json();
108
+ let errors = field(json, "errors");
109
+ if (errors !== undefined) {
110
+ throw {
111
+ RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
112
+ _1: label + ` failed\n query: ` + query + `\n response: ` + JSON.stringify(errors),
113
+ Error: new Error()
114
+ };
115
+ }
116
+ return Stdlib_Option.getOr(field(json, "data"), null);
117
+ }
118
+
119
+ let commandResultSelection = `__typename
120
+ ... on CommandAccepted { eventCount }
121
+ ... on CommandRejected { errorCode errorDetail }`;
122
+
123
+ async function send(t, m, tolerateOpt) {
124
+ let tolerate = tolerateOpt !== undefined ? tolerateOpt : [];
125
+ let label = Seed_Types$ReventlessSeed.describe(m);
126
+ let query = `mutation { r: ` + m.field + `(` + Seed_Types$ReventlessSeed.renderArgs(m.args) + `) { ` + commandResultSelection + ` } }`;
127
+ let data = await gql(t, query, label);
128
+ let result = Stdlib_Option.getOr(field(data, "r"), null);
129
+ let match = nodeString(result, "__typename");
130
+ if (match === undefined) {
131
+ return;
132
+ }
133
+ if (match !== "CommandRejected") {
134
+ return;
135
+ }
136
+ let code = Stdlib_Option.getOr(nodeString(result, "errorCode"), "unknown");
137
+ let detail = Stdlib_Option.getOr(nodeString(result, "errorDetail"), "none");
138
+ if (tolerate.includes(code)) {
139
+ return code;
140
+ }
141
+ throw {
142
+ RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
143
+ _1: label + ` was rejected\n errorCode: ` + code + `\n errorDetail: ` + detail,
144
+ Error: new Error()
145
+ };
146
+ }
147
+
148
+ async function sendAll(t, mutations) {
149
+ for (let i = 0, i_finish = mutations.length; i < i_finish; ++i) {
150
+ let m = mutations[i];
151
+ if (m !== undefined) {
152
+ await send(t, m, undefined);
153
+ }
154
+ }
155
+ }
156
+
157
+ async function queryAllNodes(t, fieldName, selection) {
158
+ let nodes = [];
159
+ let after;
160
+ let more = true;
161
+ while (more) {
162
+ let c = after;
163
+ let cursor = c !== undefined ? `, after: ` + Seed_Types$ReventlessSeed.quote(c) : "";
164
+ let query = `{ ` + fieldName + `(first: 100` + cursor + `) { edges { node { ` + selection + ` } } pageInfo { hasNextPage endCursor } } }`;
165
+ let data = await gql(t, query, fieldName);
166
+ let connection = Stdlib_Option.getOr(field(data, fieldName), null);
167
+ let match = field(connection, "edges");
168
+ if (Array.isArray(match)) {
169
+ match.forEach(edge => {
170
+ let node = field(edge, "node");
171
+ if (node !== undefined) {
172
+ nodes.push(node);
173
+ return;
174
+ }
175
+ });
176
+ }
177
+ let pageInfo = Stdlib_Option.getOr(field(connection, "pageInfo"), null);
178
+ let match$1 = field(pageInfo, "hasNextPage");
179
+ let hasNext = match$1 === true;
180
+ let endCursor = nodeString(pageInfo, "endCursor");
181
+ if (hasNext && endCursor !== undefined) {
182
+ after = endCursor;
183
+ } else {
184
+ more = false;
185
+ }
186
+ };
187
+ return nodes;
188
+ }
189
+
190
+ async function countNodes(t, field) {
191
+ return (await queryAllNodes(t, field, "id")).length;
192
+ }
193
+
194
+ async function waitForIds(t, fieldName, ids, timeoutMsOpt) {
195
+ let timeoutMs = timeoutMsOpt !== undefined ? timeoutMsOpt : 60000;
196
+ let deadline = Date.now() + timeoutMs;
197
+ let idList = ids.map(Seed_Types$ReventlessSeed.quote).join(", ");
198
+ let result;
199
+ while (result === undefined) {
200
+ let query = `{ ` + fieldName + `(ids: [` + idList + `]) { id } }`;
201
+ let data = await gql(t, query, fieldName);
202
+ let match = field(data, fieldName);
203
+ let found = match !== undefined ? (
204
+ Array.isArray(match) ? match : []
205
+ ) : [];
206
+ if (found.length >= ids.length) {
207
+ result = found.length;
208
+ } else {
209
+ if (Date.now() > deadline) {
210
+ let present = Stdlib_Array.filterMap(found, row => nodeString(row, "id"));
211
+ let missing = ids.filter(id => !present.includes(id));
212
+ let shown = missing.slice(0, 10).join(", ");
213
+ let extra = missing.length > 10 ? ` (+` + (missing.length - 10 | 0).toString() + ` more)` : "";
214
+ throw {
215
+ RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
216
+ _1: `only ` + found.length.toString() + `/` + ids.length.toString() + ` ids reached ` + fieldName + ` within ` + timeoutMs.toString() + `ms.\n missing: ` + shown + extra,
217
+ Error: new Error()
218
+ };
219
+ }
220
+ await sleep(250);
221
+ }
222
+ };
223
+ return Stdlib_Option.getOr(result, 0);
224
+ }
225
+
226
+ export {
227
+ sleep,
228
+ make,
229
+ currentToken,
230
+ field,
231
+ asString,
232
+ nodeString,
233
+ login,
234
+ gql,
235
+ commandResultSelection,
236
+ send,
237
+ sendAll,
238
+ queryAllNodes,
239
+ countNodes,
240
+ waitForIds,
241
+ }
242
+ /* No side effect */
@@ -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
+ ))