@reventlessdev/reventless-aws 3.0.0-alpha.227 → 3.0.0-alpha.229

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.
@@ -0,0 +1,170 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Aws from "@pulumi/aws";
4
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as IAM$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/IAM.res.mjs";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Pulumi from "@pulumi/pulumi";
8
+ import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
9
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
10
+ import * as AWS$ReventlessAws from "../AWS.res.mjs";
11
+ import * as ClientS3 from "@aws-sdk/client-s3";
12
+ import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
13
+ import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
14
+ import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
15
+ import * as S3RequestPresigner from "@aws-sdk/s3-request-presigner";
16
+
17
+ let getEnv = (function(k) { var v = process.env[k]; return (v === undefined || v === null || v === "") ? undefined : v; });
18
+
19
+ let genUuid = (function() {
20
+ return (globalThis.crypto && globalThis.crypto.randomUUID)
21
+ ? globalThis.crypto.randomUUID()
22
+ : require("crypto").randomUUID();
23
+ });
24
+
25
+ let decodeJwtSub = (function(header) {
26
+ try {
27
+ var token = header.indexOf("Bearer ") === 0 ? header.slice(7) : header;
28
+ var parts = token.split(".");
29
+ if (parts.length < 2) return undefined;
30
+ var base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
31
+ var json = Buffer.from(base64, "base64").toString("utf8");
32
+ var claims = JSON.parse(json);
33
+ return (claims && claims.sub) ? String(claims.sub) : undefined;
34
+ } catch (_) { return undefined; }
35
+ });
36
+
37
+ function corsHeaders() {
38
+ return Object.fromEntries([
39
+ [
40
+ "content-type",
41
+ "application/json"
42
+ ],
43
+ [
44
+ "access-control-allow-origin",
45
+ "*"
46
+ ],
47
+ [
48
+ "access-control-allow-methods",
49
+ "POST,OPTIONS"
50
+ ],
51
+ [
52
+ "access-control-allow-headers",
53
+ "*"
54
+ ]
55
+ ]);
56
+ }
57
+
58
+ function identityPrefix(event) {
59
+ let authHeader = Stdlib_Option.flatMap(event.headers, h => Stdlib_Option.orElse(h["authorization"], h["Authorization"]));
60
+ let sub = Stdlib_Option.flatMap(authHeader, decodeJwtSub);
61
+ if (sub !== undefined) {
62
+ return sub + `/`;
63
+ } else {
64
+ return "";
65
+ }
66
+ }
67
+
68
+ async function handlePresign(event, _context) {
69
+ try {
70
+ let bucket = Stdlib_Option.getOr(getEnv("UPLOAD_BUCKET"), "");
71
+ let parsed = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(JSON.parse(Stdlib_Option.getOr(event.body, "{}"))), {});
72
+ let fileName = Stdlib_Option.getOr(Stdlib_Option.flatMap(parsed["fileName"], Stdlib_JSON.Decode.string), "upload");
73
+ let contentType = Stdlib_Option.flatMap(parsed["contentType"], Stdlib_JSON.Decode.string);
74
+ let servedPrefix = Stdlib_Option.getOr(getEnv("SERVED_PREFIX"), "uploads");
75
+ let key = servedPrefix + `/` + identityPrefix(event) + genUuid() + `/` + fileName;
76
+ let client = new ClientS3.S3Client();
77
+ let command = new ClientS3.PutObjectCommand({
78
+ Bucket: bucket,
79
+ Key: key,
80
+ ContentType: contentType
81
+ });
82
+ let uploadUrl = await S3RequestPresigner.getSignedUrl(client, command, {
83
+ expiresIn: 300
84
+ });
85
+ let storageRef = `/` + key;
86
+ return {
87
+ statusCode: 200,
88
+ headers: corsHeaders(),
89
+ body: JSON.stringify(Object.fromEntries([
90
+ [
91
+ "uploadUrl",
92
+ uploadUrl
93
+ ],
94
+ [
95
+ "storageRef",
96
+ storageRef
97
+ ]
98
+ ]))
99
+ };
100
+ } catch (exn) {
101
+ return {
102
+ statusCode: 400,
103
+ headers: corsHeaders(),
104
+ body: JSON.stringify(Object.fromEntries([[
105
+ "error",
106
+ "presign_failed"
107
+ ]]))
108
+ };
109
+ }
110
+ }
111
+
112
+ function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
113
+ let corsOrigins = corsOriginsOpt !== undefined ? corsOriginsOpt : ["*"];
114
+ let servedPrefix = servedPrefixOpt !== undefined ? servedPrefixOpt : "uploads";
115
+ let serviceName = "UploadPresignService";
116
+ let opts$1 = Stdlib_Option.map(opts, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions);
117
+ let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(serviceName, Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(serviceName, "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
118
+ bucketName.apply(b => {
119
+ let arn = `arn:aws:s3:::` + b + `/*`;
120
+ new (Aws.iam.RolePolicy)(serviceName + `Policy`, {
121
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, serviceName + `Policy`, [{
122
+ Sid: "AllowUploadPut",
123
+ Effect: "Allow",
124
+ Action: "s3:PutObject",
125
+ Resource: arn
126
+ }])),
127
+ role: lambdaRole.id
128
+ }, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
129
+ });
130
+ let environment = {
131
+ variables: Object.fromEntries([
132
+ [
133
+ "UPLOAD_BUCKET",
134
+ bucketName
135
+ ],
136
+ [
137
+ "SERVED_PREFIX",
138
+ servedPrefix
139
+ ]
140
+ ])
141
+ };
142
+ let lambda = new (Aws.lambda.CallbackFunction)(serviceName, Lambda$PulumiAws.CallbackFunction.Args.make(handlePresign, lambdaRole, undefined, undefined, undefined, undefined, 30, undefined, undefined, undefined, AWS_Tags$ReventlessAws.make(serviceName, "Platform", "Runtime", "Platform", undefined, undefined, undefined, undefined), environment), opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
143
+ let functionUrl = new (Aws.lambda.FunctionUrl)(serviceName + `Url`, {
144
+ authorizationType: "NONE",
145
+ functionName: lambda.name,
146
+ cors: {
147
+ allowHeaders: ["*"].map(prim => prim),
148
+ allowMethods: ["POST"].map(prim => prim),
149
+ allowOrigins: corsOrigins.map(prim => prim)
150
+ }
151
+ }, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
152
+ return {
153
+ url: functionUrl.functionUrl,
154
+ resources: [
155
+ lambda.arn,
156
+ functionUrl.functionArn
157
+ ]
158
+ };
159
+ }
160
+
161
+ export {
162
+ getEnv,
163
+ genUuid,
164
+ decodeJwtSub,
165
+ corsHeaders,
166
+ identityPrefix,
167
+ handlePresign,
168
+ make,
169
+ }
170
+ /* @pulumi/aws Not a pure module */
@@ -142,6 +142,7 @@ let makeUiBundleDistribution = (
142
142
  ~stableName: bool=false,
143
143
  ~excludeFiles: array<string>=[],
144
144
  ~customDomain: option<customDomain>=?,
145
+ ~servedBuckets: array<ReventlessInfra.Platform.servedBucket>=[],
145
146
  ): bundleDistribution => {
146
147
  // When stableName=true, Pulumi resource names omit bundleVersion so every
147
148
  // deploy updates the same bucket + CloudFront distribution in place. The
@@ -219,9 +220,26 @@ let makeUiBundleDistribution = (
219
220
  cachedMethods: Pulumi.Input.make(["GET", "HEAD"]),
220
221
  cachePolicyId: Pulumi.Input.make(cachingDisabledPolicyId),
221
222
  }
223
+ // Ordered cache behavior routing a served bucket's `{prefix}/*` path to its own
224
+ // origin. Served objects have immutable uuid keys ⇒ the long-TTL
225
+ // CachingOptimized policy is safe. Prepended to `orderedCacheBehaviors` so a
226
+ // served path is matched before the SPA-serving default behavior and never
227
+ // routed to the bundle bucket.
228
+ let servedOriginId = (prefix: string): string => "served-" ++ prefix
229
+ let servedCacheBehavior = (prefix: string): PulumiAws.CloudFront.Distribution.orderedCacheBehavior => {
230
+ pathPattern: Pulumi.Input.make(prefix ++ "/*"),
231
+ targetOriginId: Pulumi.Input.make(servedOriginId(prefix)),
232
+ viewerProtocolPolicy: Pulumi.Input.make("redirect-to-https"),
233
+ allowedMethods: Pulumi.Input.make(["GET", "HEAD"]),
234
+ cachedMethods: Pulumi.Input.make(["GET", "HEAD"]),
235
+ cachePolicyId: Pulumi.Input.make(cachingOptimizedPolicyId),
236
+ }
222
237
  let orderedCacheBehaviors = Array.concat(
223
- [noCacheBehavior("/remoteEntry.js")],
224
- spaFallback ? [noCacheBehavior("/" ++ indexDocument), noCacheBehavior("/config.json")] : [],
238
+ servedBuckets->Array.map(sb => servedCacheBehavior(sb.prefix)),
239
+ Array.concat(
240
+ [noCacheBehavior("/remoteEntry.js")],
241
+ spaFallback ? [noCacheBehavior("/" ++ indexDocument), noCacheBehavior("/config.json")] : [],
242
+ ),
225
243
  )
226
244
 
227
245
  // Custom-domain provisioning. When `customDomain` is supplied the bundle is
@@ -305,13 +323,23 @@ let makeUiBundleDistribution = (
305
323
  origins: (bucket.bucketRegionalDomainName, oac.id)
306
324
  ->Pulumi.Output.all2
307
325
  ->Pulumi.Output.apply(((domainName, oacId)) =>
308
- [
309
- {
310
- PulumiAws.CloudFront.Distribution.domainName: Pulumi.Input.make(domainName),
311
- originId: Pulumi.Input.make(originId),
326
+ Array.concat(
327
+ [
328
+ {
329
+ PulumiAws.CloudFront.Distribution.domainName: Pulumi.Input.make(domainName),
330
+ originId: Pulumi.Input.make(originId),
331
+ originAccessControlId: Pulumi.Input.make(oacId),
332
+ },
333
+ ],
334
+ // One S3 origin per served bucket, sharing the same OAC as the bundle
335
+ // origin (the OAC only authorizes CloudFront→S3 sigv4 signing; the
336
+ // per-bucket read grant is the served bucket's own BucketPolicy below).
337
+ servedBuckets->Array.map(sb => {
338
+ PulumiAws.CloudFront.Distribution.domainName: sb.bucketRegionalDomainName,
339
+ originId: Pulumi.Input.make(servedOriginId(sb.prefix)),
312
340
  originAccessControlId: Pulumi.Input.make(oacId),
313
- },
314
- ]
341
+ }),
342
+ )
315
343
  )
316
344
  ->Pulumi.Output.asInput,
317
345
  defaultCacheBehavior: Pulumi.Input.make(
@@ -400,6 +428,41 @@ let makeUiBundleDistribution = (
400
428
  },
401
429
  )
402
430
 
431
+ // Per served bucket: grant CloudFront read scoped to THIS distribution, so the
432
+ // object is fetchable at `https://<ui-domain>/{prefix}/<key>` while the bucket
433
+ // stays private (direct S3 GET is 403). Mirrors the bundle bucket policy above;
434
+ // the served bucket keeps its own all-true BucketPublicAccessBlock (app-owned).
435
+ servedBuckets->Array.forEach(sb => {
436
+ let _ = PulumiAws.S3.BucketPolicy.make(
437
+ ~name=name ++ "-served-" ++ sb.prefix ++ "-policy",
438
+ ~args={
439
+ bucket: sb.bucketId,
440
+ policy: (sb.bucketArn->Pulumi.Output.fromInput, distribution.arn)
441
+ ->Pulumi.Output.all2
442
+ ->Pulumi.Output.apply(((bucketArn, distributionArn)) =>
443
+ {
444
+ "Version": "2012-10-17",
445
+ "Statement": [
446
+ {
447
+ "Sid": "AllowCloudFrontServicePrincipal",
448
+ "Effect": "Allow",
449
+ "Principal": {"Service": "cloudfront.amazonaws.com"},
450
+ "Action": "s3:GetObject",
451
+ "Resource": bucketArn ++ "/*",
452
+ "Condition": {
453
+ "StringEquals": {"AWS:SourceArn": distributionArn},
454
+ },
455
+ },
456
+ ],
457
+ }
458
+ ->JSON.stringifyAny
459
+ ->Option.getUnsafe
460
+ )
461
+ ->Pulumi.Output.asInput,
462
+ },
463
+ )
464
+ })
465
+
403
466
  switch assetsDir {
404
467
  | None => ()
405
468
  | Some(dir) =>
@@ -68,11 +68,12 @@ async function invalidateDistribution(distributionId, paths) {
68
68
  }
69
69
  }
70
70
 
71
- function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbackOpt, indexDocumentOpt, stableNameOpt, excludeFilesOpt, customDomain) {
71
+ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbackOpt, indexDocumentOpt, stableNameOpt, excludeFilesOpt, customDomain, servedBucketsOpt) {
72
72
  let spaFallback = spaFallbackOpt !== undefined ? spaFallbackOpt : false;
73
73
  let indexDocument = indexDocumentOpt !== undefined ? indexDocumentOpt : "index.html";
74
74
  let stableName = stableNameOpt !== undefined ? stableNameOpt : false;
75
75
  let excludeFiles = excludeFilesOpt !== undefined ? excludeFilesOpt : [];
76
+ let servedBuckets = servedBucketsOpt !== undefined ? servedBucketsOpt : [];
76
77
  let name = stableName ? pluginId : pluginId + "-" + bundleVersion;
77
78
  let bucket = new (Aws.s3.Bucket)(name + "-bundle", {
78
79
  tags: AWS_Tags$ReventlessAws.make(name + "-bundle", "Plugin", "Hosting", "Plugin", undefined, undefined, pluginId, undefined)
@@ -118,10 +119,26 @@ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbac
118
119
  ],
119
120
  cachePolicyId: cachingDisabledPolicyId
120
121
  });
121
- let orderedCacheBehaviors = [noCacheBehavior("/remoteEntry.js")].concat(spaFallback ? [
122
+ let orderedCacheBehaviors = servedBuckets.map(sb => {
123
+ let prefix = sb.prefix;
124
+ return {
125
+ pathPattern: prefix + "/*",
126
+ targetOriginId: "served-" + prefix,
127
+ viewerProtocolPolicy: "redirect-to-https",
128
+ allowedMethods: [
129
+ "GET",
130
+ "HEAD"
131
+ ],
132
+ cachedMethods: [
133
+ "GET",
134
+ "HEAD"
135
+ ],
136
+ cachePolicyId: cachingOptimizedPolicyId
137
+ };
138
+ }).concat([noCacheBehavior("/remoteEntry.js")].concat(spaFallback ? [
122
139
  noCacheBehavior("/" + indexDocument),
123
140
  noCacheBehavior("/config.json")
124
- ] : []);
141
+ ] : []));
125
142
  let viewerCertificate;
126
143
  if (customDomain !== undefined) {
127
144
  let usEast1 = _getUsEast1Provider();
@@ -163,11 +180,18 @@ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbac
163
180
  origins: Pulumi.all([
164
181
  bucket.bucketRegionalDomainName,
165
182
  oac.id
166
- ]).apply(param => [{
167
- domainName: param[0],
168
- originId: originId,
169
- originAccessControlId: param[1]
170
- }]),
183
+ ]).apply(param => {
184
+ let oacId = param[1];
185
+ return [{
186
+ domainName: param[0],
187
+ originId: originId,
188
+ originAccessControlId: oacId
189
+ }].concat(servedBuckets.map(sb => ({
190
+ domainName: sb.bucketRegionalDomainName,
191
+ originId: "served-" + sb.prefix,
192
+ originAccessControlId: oacId
193
+ })));
194
+ }),
171
195
  defaultCacheBehavior: {
172
196
  targetOriginId: originId,
173
197
  viewerProtocolPolicy: "redirect-to-https",
@@ -228,6 +252,31 @@ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbac
228
252
  }]
229
253
  }))
230
254
  });
255
+ servedBuckets.forEach(sb => {
256
+ new (Aws.s3.BucketPolicy)(name + "-served-" + sb.prefix + "-policy", {
257
+ bucket: sb.bucketId,
258
+ policy: Pulumi.all([
259
+ sb.bucketArn,
260
+ distribution.arn
261
+ ]).apply(param => JSON.stringify({
262
+ Version: "2012-10-17",
263
+ Statement: [{
264
+ Sid: "AllowCloudFrontServicePrincipal",
265
+ Effect: "Allow",
266
+ Principal: {
267
+ Service: "cloudfront.amazonaws.com"
268
+ },
269
+ Action: "s3:GetObject",
270
+ Resource: param[0] + "/*",
271
+ Condition: {
272
+ StringEquals: {
273
+ "AWS:SourceArn": param[1]
274
+ }
275
+ }
276
+ }]
277
+ }))
278
+ });
279
+ });
231
280
  if (assetsDir !== undefined) {
232
281
  let allEntries = Util_StaticBundle$ReventlessAws.walk(assetsDir);
233
282
  if (allEntries.length === 0) {
@@ -34,10 +34,16 @@ let buildJsonEvent' = dict => {
34
34
  ->Dict.fromArray
35
35
  ->JSON.Encode.object
36
36
  }
37
+ // Carry the stored `recorded_at` column into the envelope so StateViewSlice
38
+ // projections receive it as `consumed.recordedAt` (the authoritative storage
39
+ // time on the AWS path). Absent on non-event rows → empty string.
40
+ let recordedAt =
41
+ dict->Dict.get("recordedAt")->Option.getOr(""->JSON.Encode.string)
37
42
  Some(
38
43
  [
39
44
  ("id", dict->Dict.get("id")->Option.getOrThrow),
40
45
  ("meta", meta),
46
+ ("recordedAt", recordedAt),
41
47
  ("event", ReventlessCore.Message.combineMessage(eventType, payload)),
42
48
  ]
43
49
  ->Dict.fromArray
@@ -53,6 +53,7 @@ function buildJsonEvent$p(dict) {
53
53
  ]
54
54
  ]);
55
55
  }
56
+ let recordedAt = Stdlib_Option.getOr(dict["recordedAt"], "");
56
57
  return Object.fromEntries([
57
58
  [
58
59
  "id",
@@ -62,6 +63,10 @@ function buildJsonEvent$p(dict) {
62
63
  "meta",
63
64
  meta
64
65
  ],
66
+ [
67
+ "recordedAt",
68
+ recordedAt
69
+ ],
65
70
  [
66
71
  "event",
67
72
  Message$ReventlessCore.combineMessage(match, payload)
@@ -5,6 +5,7 @@ let log = ReventlessCore.Logger.fromEnv()
5
5
 
6
6
  @module("fs") external existsSync: string => bool = "existsSync"
7
7
  @module("fs") external readFileSync: string => Js.TypedArray2.Uint8Array.t = "readFileSync"
8
+ @module("fs") external readFileSyncUtf8: (string, string) => string = "readFileSync"
8
9
  type dirent
9
10
  @module("fs")
10
11
  external readdirSync: (string, {"withFileTypes": bool}) => array<dirent> = "readdirSync"
@@ -69,6 +70,26 @@ let walk = (assetsDir: string): array<fileEntry> => {
69
70
  acc
70
71
  }
71
72
 
73
+ /**
74
+ * Read a JSON file at deploy time and return its exact bytes as a string, after
75
+ * validating that it parses. A missing or malformed file throws with an
76
+ * actionable message so a broken file fails the deploy rather than shipping a
77
+ * file a consumer will fetch-and-ignore. The verbatim bytes are returned (not
78
+ * re-serialised) so formatting/key order the author chose is preserved.
79
+ */
80
+ let readJsonFileVerbatim = (~path: string, ~label: string): string => {
81
+ if !existsSync(path) {
82
+ JsError.throwWithMessage(`${label}: file does not exist: ${path}`)
83
+ }
84
+ let content = readFileSyncUtf8(path, "utf8")
85
+ try {
86
+ let _ = JSON.parseOrThrow(content)
87
+ content
88
+ } catch {
89
+ | _ => JsError.throwWithMessage(`${label}: file is not valid JSON: ${path}`)
90
+ }
91
+ }
92
+
72
93
  /**
73
94
  * Replace `/` and `.` so a path can be used as a Pulumi resource URN segment.
74
95
  */
@@ -52,6 +52,19 @@ function walk(assetsDir) {
52
52
  return acc;
53
53
  }
54
54
 
55
+ function readJsonFileVerbatim(path, label) {
56
+ if (!Fs.existsSync(path)) {
57
+ Stdlib_JsError.throwWithMessage(label + `: file does not exist: ` + path);
58
+ }
59
+ let content = Fs.readFileSync(path, "utf8");
60
+ try {
61
+ JSON.parse(content);
62
+ return content;
63
+ } catch (exn) {
64
+ return Stdlib_JsError.throwWithMessage(label + `: file is not valid JSON: ` + path);
65
+ }
66
+ }
67
+
55
68
  function sanitizeName(relativePath) {
56
69
  return relativePath.replaceAll("/", "-").replaceAll(".", "-");
57
70
  }
@@ -125,6 +138,7 @@ export {
125
138
  isHidden,
126
139
  walkInto,
127
140
  walk,
141
+ readJsonFileVerbatim,
128
142
  sanitizeName,
129
143
  extensionOf,
130
144
  contentTypeFor,
@@ -134,3 +134,43 @@ describe("Util_StaticBundle.walk", () => {
134
134
  expect(threw)->toBe(true)
135
135
  })
136
136
  })
137
+
138
+ describe("Util_StaticBundle.readJsonFileVerbatim", () => {
139
+ let writeTmp = (contents: string): string => {
140
+ let dir = mkdtempSync(join2(tmpdir(), "ui-hints-"))
141
+ let path = join2(dir, "ui-hints.json")
142
+ writeFileSync(path, contents)
143
+ path
144
+ }
145
+
146
+ testSync("returns the file's exact bytes for valid JSON (verbatim, not re-serialised)", () => {
147
+ // Deliberately non-canonical formatting: extra spaces + trailing newline.
148
+ let raw = "{\n \"dashboards\": [ ] \n}\n"
149
+ let path = writeTmp(raw)
150
+ expect(Util_StaticBundle.readJsonFileVerbatim(~path, ~label="test"))->toBe(raw)
151
+ rmSync(path, {"recursive": true, "force": true})
152
+ })
153
+
154
+ testSync("throws on malformed JSON", () => {
155
+ let path = writeTmp("{ not valid json ")
156
+ let threw = try {
157
+ let _ = Util_StaticBundle.readJsonFileVerbatim(~path, ~label="test")
158
+ false
159
+ } catch {
160
+ | _ => true
161
+ }
162
+ expect(threw)->toBe(true)
163
+ rmSync(path, {"recursive": true, "force": true})
164
+ })
165
+
166
+ testSync("throws when the file does not exist", () => {
167
+ let missing = join2(tmpdir(), "ui-hints-missing-xyz123.json")
168
+ let threw = try {
169
+ let _ = Util_StaticBundle.readJsonFileVerbatim(~path=missing, ~label="test")
170
+ false
171
+ } catch {
172
+ | _ => true
173
+ }
174
+ expect(threw)->toBe(true)
175
+ })
176
+ })
@@ -143,6 +143,50 @@ globalThis.describe("Util_StaticBundle.walk", () => {
143
143
  });
144
144
  });
145
145
 
146
+ globalThis.describe("Util_StaticBundle.readJsonFileVerbatim", () => {
147
+ let writeTmp = contents => {
148
+ let dir = Fs.mkdtempSync(Path.join(Os.tmpdir(), "ui-hints-"));
149
+ let path = Path.join(dir, "ui-hints.json");
150
+ Fs.writeFileSync(path, contents);
151
+ return path;
152
+ };
153
+ globalThis.test("returns the file's exact bytes for valid JSON (verbatim, not re-serialised)", () => {
154
+ let raw = "{\n \"dashboards\": [ ] \n}\n";
155
+ let path = writeTmp(raw);
156
+ globalThis.expect(Util_StaticBundle$ReventlessAws.readJsonFileVerbatim(path, "test")).toBe(raw);
157
+ Fs.rmSync(path, {
158
+ recursive: true,
159
+ force: true
160
+ });
161
+ });
162
+ globalThis.test("throws on malformed JSON", () => {
163
+ let path = writeTmp("{ not valid json ");
164
+ let threw;
165
+ try {
166
+ Util_StaticBundle$ReventlessAws.readJsonFileVerbatim(path, "test");
167
+ threw = false;
168
+ } catch (exn) {
169
+ threw = true;
170
+ }
171
+ globalThis.expect(threw).toBe(true);
172
+ Fs.rmSync(path, {
173
+ recursive: true,
174
+ force: true
175
+ });
176
+ });
177
+ globalThis.test("throws when the file does not exist", () => {
178
+ let missing = Path.join(Os.tmpdir(), "ui-hints-missing-xyz123.json");
179
+ let threw;
180
+ try {
181
+ Util_StaticBundle$ReventlessAws.readJsonFileVerbatim(missing, "test");
182
+ threw = false;
183
+ } catch (exn) {
184
+ threw = true;
185
+ }
186
+ globalThis.expect(threw).toBe(true);
187
+ });
188
+ });
189
+
146
190
  export {
147
191
  makeFixture,
148
192
  }
@@ -10,7 +10,7 @@
10
10
  open Reventless.Projection
11
11
  open SvsTestSlice
12
12
 
13
- let project = event =>
13
+ let project = ({event}: Reventless.StateViewSlice.consumed<consumedEvent>) =>
14
14
  switch event {
15
15
  | ItemAddedToCart({cartId, productId, qty}) => [
16
16
  UpdateMultiState(
@@ -1,7 +1,8 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
 
4
- function project(event) {
4
+ function project(param) {
5
+ let event = param.event;
5
6
  let qty = event.qty;
6
7
  let productId = event.productId;
7
8
  let cartId = event.cartId;