@reventlessdev/reventless-aws 3.0.0-alpha.391 → 3.0.0-alpha.393

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 CHANGED
@@ -3,6 +3,20 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.393 (2026-10-21)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** a failed AppSync schema push names the API and AWS's message ([01f662a](https://github.com/ReventlessDev/reventless-core/commit/01f662a7c664807808c15933f6234b6b6588addd))
11
+
12
+
13
+ # 3.0.0-alpha.392 (2026-10-21)
14
+
15
+ ### Features
16
+
17
+ * **aws:** a UI bundle uploads as one resource, putting only the files that changed ([9e28682](https://github.com/ReventlessDev/reventless-core/commit/9e286829817c6679aace100b3f17195a777a4cf4))
18
+
19
+
6
20
  # 3.0.0-alpha.391 (2026-10-21)
7
21
 
8
22
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.391",
3
+ "version": "3.0.0-alpha.393",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -23,18 +23,18 @@
23
23
  "uuid": "^13.0.0",
24
24
  "yaml": "^2.8.3",
25
25
  "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.33",
26
- "@reventlessdev/rescript-node": "2.0.0-alpha.23",
27
26
  "@reventlessdev/rescript-jest": "1.0.0-alpha.13",
28
- "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.31",
27
+ "@reventlessdev/rescript-node": "2.0.0-alpha.23",
29
28
  "@reventlessdev/rescript-effect": "1.0.0-alpha.1",
29
+ "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.31",
30
30
  "@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
31
- "@reventlessdev/rescript-web": "1.0.0-alpha.8",
32
31
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
33
- "@reventlessdev/reventless-core": "3.0.0-alpha.303",
32
+ "@reventlessdev/rescript-web": "1.0.0-alpha.8",
33
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.201",
34
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.168",
34
35
  "@reventlessdev/reventless-interop": "3.0.0-alpha.42",
35
- "@reventlessdev/reventless-infra": "3.0.0-alpha.200",
36
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.167",
37
- "@reventlessdev/reventless-spec": "3.0.0-alpha.172"
36
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.173",
37
+ "@reventlessdev/reventless-core": "3.0.0-alpha.304"
38
38
  },
39
39
  "devDependencies": {
40
40
  "rescript": "12.3.1",
@@ -45,6 +45,15 @@ let startSchemaCreation = (client: appSyncClient, input: startSchemaCreationInpu
45
45
  // jittered exponential backoff so a losing call can wait for the in-progress
46
46
  // schema to finish and try again. Permanent errors (e.g. invalid SDL) are not
47
47
  // retried — see AppSync_Error.classify.
48
+ // What the deploy reports when the push gives up. Carries AWS's own message: the classified
49
+ // error is a variant, which a rejected promise prints as `[object Object]`.
50
+ let schemaPushFailure = (~apiId: string, err: AppSync_Error.t): string =>
51
+ switch err {
52
+ | Transient(msg) =>
53
+ `StartSchemaCreation for AppSync API ${apiId} still failed after its retries: ${msg}`
54
+ | Permanent(msg) => `StartSchemaCreation for AppSync API ${apiId} failed: ${msg}`
55
+ }
56
+
48
57
  let startSchemaCreationRetrying = (client: appSyncClient, input: startSchemaCreationInput): promise<
49
58
  unit,
50
59
  > => {
@@ -52,6 +61,7 @@ let startSchemaCreationRetrying = (client: appSyncClient, input: startSchemaCrea
52
61
  startSchemaCreation(client, input)->Promise.then(_ => Promise.resolve())
53
62
  )
54
63
  ->Effect.retry(AppSync_Error.retrySchedule)
64
+ ->Effect.catchAll(err => Effect.fail(schemaPushFailure(~apiId=input.apiId, err)))
55
65
  ->Effect.runPromise
56
66
  }
57
67
 
@@ -33,8 +33,16 @@ function startSchemaCreation(client, input) {
33
33
  return client.send(new ClientAppsync.StartSchemaCreationCommand(input));
34
34
  }
35
35
 
36
+ function schemaPushFailure(apiId, err) {
37
+ if (err.TAG === "Transient") {
38
+ return `StartSchemaCreation for AppSync API ` + apiId + ` still failed after its retries: ` + err._0;
39
+ } else {
40
+ return `StartSchemaCreation for AppSync API ` + apiId + ` failed: ` + err._0;
41
+ }
42
+ }
43
+
36
44
  function startSchemaCreationRetrying(client, input) {
37
- return Effect$1.runPromise(Effect$1.retry(Effect.tryPromise(AppSync_Error$ReventlessAws.classify, () => client.send(new ClientAppsync.StartSchemaCreationCommand(input)).then(param => Promise.resolve())), AppSync_Error$ReventlessAws.retrySchedule));
45
+ return Effect$1.runPromise(Effect$1.catchAll(Effect$1.retry(Effect.tryPromise(AppSync_Error$ReventlessAws.classify, () => client.send(new ClientAppsync.StartSchemaCreationCommand(input)).then(param => Promise.resolve())), AppSync_Error$ReventlessAws.retrySchedule), err => Effect$1.fail(schemaPushFailure(input.apiId, err))));
38
46
  }
39
47
 
40
48
  async function waitForSchemaActive(client, apiId, maxAttemptsOpt, attemptOpt) {
@@ -326,6 +334,7 @@ export {
326
334
  log,
327
335
  sha256Hex,
328
336
  startSchemaCreation,
337
+ schemaPushFailure,
329
338
  startSchemaCreationRetrying,
330
339
  waitForSchemaActive,
331
340
  _client,
@@ -114,9 +114,10 @@ let invalidateDistribution = async (~distributionId: string, ~paths: array<strin
114
114
 
115
115
  /**
116
116
  * Provision an S3 bucket + CloudFront distribution for a UI bundle. When
117
- * `~assetsDir` is supplied, every file under it is uploaded as a `BucketObject`
118
- * with the correct MIME type and an etag derived from the file's SHA-256 so
119
- * Pulumi only re-uploads files that actually changed. When `~spaFallback=true`,
117
+ * `~assetsDir` is supplied, its files are uploaded by one resource
118
+ * (`Util_StaticBundleUpload`) with the correct MIME type, putting only files
119
+ * that changed and deleting those two deploys old. A stack must have deployed
120
+ * the per-file objects with `retainOnDelete` before this replaces them. When `~spaFallback=true`,
120
121
  * CloudFront 403/404 responses fall back to the index document so client-side
121
122
  * routes resolve correctly on first paint.
122
123
  *
@@ -147,9 +148,7 @@ let makeUiBundleDistribution = (
147
148
  ): bundleDistribution => {
148
149
  // When stableName=true, Pulumi resource names omit bundleVersion so every
149
150
  // deploy updates the same bucket + CloudFront distribution in place. The
150
- // public CloudFront domain stays the same across releases. Per-file etags
151
- // still drive BucketObject diffs, and obsolete objects are removed when
152
- // their Pulumi resource disappears from state.
151
+ // public CloudFront domain stays the same across releases.
153
152
  //
154
153
  // When false (the default), bundleVersion is baked into every resource name,
155
154
  // so each release provisions a fresh bucket + distribution — useful for
@@ -500,42 +499,39 @@ let makeUiBundleDistribution = (
500
499
  if allEntries->Array.length == 0 {
501
500
  JsError.throwWithMessage(`Plugin_Stack.makeUiBundleDistribution: assetsDir is empty: ${dir}`)
502
501
  }
503
- // Drop excluded relative paths before BucketObject creation. Used so the
502
+ // Drop excluded relative paths before the upload. Used so the
504
503
  // host-shell deploy can skip the dev-mode `public/config.json` it ships
505
504
  // with — the explicit Platform.res BucketObject writes the production
506
505
  // config to the same S3 key and would otherwise race with the bundle
507
506
  // upload (whichever Pulumi applies last wins).
508
507
  let entries =
509
508
  allEntries->Array.filter(entry => !(excludeFiles->Array.includes(entry.relativePath)))
510
- let objectEtags = entries->Array.map(entry => {
511
- let obj = PulumiAws.S3.BucketObject.make(
512
- ~name=name ++ "-asset-" ++ Util.StaticBundle.sanitizeName(entry.relativePath),
513
- ~args={
514
- bucket: bucket.id->Pulumi.Output.asInput,
515
- key: Pulumi.Input.make(entry.relativePath),
516
- source: Pulumi.Input.make(entry.fileAsset),
517
- contentType: Pulumi.Input.make(Util.StaticBundle.contentTypeFor(entry.relativePath)),
518
- cacheControl: Pulumi.Input.make(cacheControlFor(entry.relativePath)),
519
- etag: Pulumi.Input.make(entry.contentHash),
520
- },
521
- // Retained, so the update that replaces these with one upload resource drops them from
522
- // state instead of deleting the keys it has just uploaded.
523
- ~opts={retainOnDelete: true},
524
- )
525
- obj.etag
526
- })
509
+ // One resource for the whole directory: it puts what is new or changed and deletes what
510
+ // is two deploys old (`Util_StaticBundleUpload`).
511
+ let upload = Util_StaticBundleUpload.make(
512
+ ~name=name ++ "-assets",
513
+ ~props={
514
+ bucket: bucket.bucket->Pulumi.Output.asInput,
515
+ dir,
516
+ files: entries->Array.map((entry): Util_StaticBundleUpload.file => {
517
+ key: entry.relativePath,
518
+ contentType: Util.StaticBundle.contentTypeFor(entry.relativePath),
519
+ cacheControl: cacheControlFor(entry.relativePath),
520
+ }),
521
+ },
522
+ )
527
523
 
528
- // Auto-invalidate CloudFront once every object is (re)uploaded. Stable-named
524
+ // Auto-invalidate CloudFront once the upload is done. Stable-named
529
525
  // entry files (index.html, the module-federation mf-entry-bootstrap-*.js)
530
526
  // are served by the long-TTL default cache behavior, so without this a
531
527
  // `pulumi up` lands the new bundle in S3 while CloudFront keeps serving the
532
- // prior build until its TTL expires. Chained on the upload etags so it runs
528
+ // prior build until its TTL expires. Chained on the upload so it runs
533
529
  // AFTER the new content is in S3; `/*` is a single invalidation path (well
534
530
  // within CloudFront's free-tier monthly allowance even on frequent deploys).
535
531
  let _ =
536
- (distribution.id, Pulumi.Output.all(objectEtags))
532
+ (distribution.id, upload.uploaded)
537
533
  ->Pulumi.Output.all2
538
- ->Pulumi.Output.flatMap(((distId, _etags)) =>
534
+ ->Pulumi.Output.flatMap(((distId, _uploaded)) =>
539
535
  invalidateDistribution(~distributionId=distId, ~paths=["/*"])->Pulumi.Output.fromPromise
540
536
  )
541
537
  }
@@ -13,6 +13,7 @@ import * as AWS_Tags$ReventlessAws from "../../adapter/AWS_Tags.res.mjs";
13
13
  import * as ClientCloudfront from "@aws-sdk/client-cloudfront";
14
14
  import * as Util_StoreLayout$ReventlessAws from "../../util/Util_StoreLayout.res.mjs";
15
15
  import * as Util_StaticBundle$ReventlessAws from "../../util/Util_StaticBundle.res.mjs";
16
+ import * as Util_StaticBundleUpload$ReventlessAws from "../../util/Util_StaticBundleUpload.res.mjs";
16
17
 
17
18
  let log = Logger$ReventlessCore.fromEnv();
18
19
 
@@ -287,19 +288,18 @@ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbac
287
288
  Stdlib_JsError.throwWithMessage(`Plugin_Stack.makeUiBundleDistribution: assetsDir is empty: ` + assetsDir);
288
289
  }
289
290
  let entries = allEntries.filter(entry => !excludeFiles.includes(entry.relativePath));
290
- let objectEtags = entries.map(entry => new (Aws.s3.BucketObject)(name + "-asset-" + Util_StaticBundle$ReventlessAws.sanitizeName(entry.relativePath), {
291
- bucket: bucket.id,
292
- key: entry.relativePath,
293
- source: entry.fileAsset,
294
- contentType: Util_StaticBundle$ReventlessAws.contentTypeFor(entry.relativePath),
295
- cacheControl: cacheControlFor(entry.relativePath),
296
- etag: entry.contentHash
297
- }, {
298
- retainOnDelete: true
299
- }).etag);
291
+ let upload = Util_StaticBundleUpload$ReventlessAws.make(name + "-assets", {
292
+ bucket: bucket.bucket,
293
+ dir: assetsDir,
294
+ files: entries.map(entry => ({
295
+ key: entry.relativePath,
296
+ contentType: Util_StaticBundle$ReventlessAws.contentTypeFor(entry.relativePath),
297
+ cacheControl: cacheControlFor(entry.relativePath)
298
+ }))
299
+ });
300
300
  Output$Pulumi.flatMap(Pulumi.all([
301
301
  distribution.id,
302
- Pulumi.all(objectEtags)
302
+ upload.uploaded
303
303
  ]), param => invalidateDistribution(param[0], ["/*"]));
304
304
  }
305
305
  let distributionUrl = customDomain !== undefined ? Pulumi.output("https://" + customDomain.fqdn) : distribution.domainName.apply(d => "https://" + d);
@@ -0,0 +1,275 @@
1
+ /** A static bundle uploaded as **one** resource rather than one `BucketObject` per file.
2
+
3
+ A per-file resource costs a create or a delete each, and a rebuild renames most hashed
4
+ files, so a bundle of a few hundred files spent most of its deploy creating and deleting
5
+ objects. This resource lists the bucket once, puts what is new or changed sixteen at a time,
6
+ and deletes what is two generations old: a reader with a page open from the previous deploy
7
+ can still load its chunks.
8
+
9
+ **Always updated.** `diff_` reports a change on every deploy, and the update compares with
10
+ what the bucket holds (`ListObjectsV2`'s ETag is the MD5 of a single-part upload), so a file
11
+ removed outside Pulumi is put back by the next deploy. Keys this resource never uploaded —
12
+ an explicitly written `config.json`, a served bucket's prefix — are never touched.
13
+
14
+ Caveat inherited from every Pulumi dynamic provider: the captured closure is serialised into
15
+ stack state, so the SDK and the Node modules are imported lazily and nothing here captures a
16
+ Pulumi Output. A fix reaches a stack once this resource is next updated, which is every
17
+ deploy. */
18
+ let log = ReventlessCore.Logger.fromEnv()
19
+
20
+ // ── The SDK, imported lazily ─────────────────────────────────────────────────
21
+
22
+ type s3Client
23
+ type buffer
24
+
25
+ type listInput = {
26
+ @as("Bucket") bucket: string,
27
+ @as("ContinuationToken") continuationToken?: string,
28
+ }
29
+ type listedObject = {@as("Key") key: string, @as("ETag") etag?: string}
30
+ type listResult = {
31
+ @as("Contents") contents?: array<listedObject>,
32
+ @as("IsTruncated") isTruncated?: bool,
33
+ @as("NextContinuationToken") nextContinuationToken?: string,
34
+ }
35
+ type putInput = {
36
+ @as("Bucket") bucket: string,
37
+ @as("Key") key: string,
38
+ @as("Body") body: buffer,
39
+ @as("ContentType") contentType: string,
40
+ @as("CacheControl") cacheControl: string,
41
+ }
42
+ type deleteInput = {@as("Bucket") bucket: string, @as("Key") key: string}
43
+
44
+ type listCmd
45
+ type putCmd
46
+ type deleteCmd
47
+
48
+ type sdkModule = {
49
+ @as("S3Client") clientCtor: unknown,
50
+ @as("ListObjectsV2Command") listCtor: unknown,
51
+ @as("PutObjectCommand") putCtor: unknown,
52
+ @as("DeleteObjectCommand") deleteCtor: unknown,
53
+ }
54
+
55
+ @module("./Util_LogGroup_Adopting_Js.mjs") external newCommand: ('ctor, 'arg) => 'cmd = "newCommand"
56
+ @module("./Util_LogGroup_Adopting_Js.mjs") external newClient: 'ctor => s3Client = "newClient"
57
+
58
+ @send external sendList: (s3Client, listCmd) => promise<listResult> = "send"
59
+ @send external sendPut: (s3Client, putCmd) => promise<unit> = "send"
60
+ @send external sendDelete: (s3Client, deleteCmd) => promise<unit> = "send"
61
+
62
+ type fsModule = {readFile: string => promise<buffer>}
63
+ type hash
64
+ type cryptoModule = {createHash: string => hash}
65
+ @send external hashUpdate: (hash, buffer) => hash = "update"
66
+ @send external hashDigest: (hash, string) => string = "digest"
67
+
68
+ // Emitted as a literal `import("...")`, so the serialiser does not capture the modules.
69
+ @val external importSdk: string => promise<sdkModule> = "import"
70
+ @val external importFs: string => promise<fsModule> = "import"
71
+ @val external importCrypto: string => promise<cryptoModule> = "import"
72
+
73
+ let _client: ref<option<(sdkModule, s3Client)>> = ref(None)
74
+
75
+ let client = async (): (sdkModule, s3Client) =>
76
+ switch _client.contents {
77
+ | Some(c) => c
78
+ | None =>
79
+ let sdk = await importSdk("@aws-sdk/client-s3")
80
+ let c = (sdk, newClient(sdk.clientCtor))
81
+ _client.contents = Some(c)
82
+ c
83
+ }
84
+
85
+ // ── The resource ─────────────────────────────────────────────────────────────
86
+
87
+ /** One file of the bundle: its key, read from `dir ++ "/" ++ key` at upload time. */
88
+ type file = {key: string, contentType: string, cacheControl: string}
89
+
90
+ type providerInputs = {bucket: string, dir: string, files: array<file>}
91
+
92
+ /** `keys` are this deploy's, `previousKeys` the deploy's before; a key in neither goes. */
93
+ type outs = {
94
+ bucket: Nullable.t<string>,
95
+ keys: Nullable.t<array<string>>,
96
+ previousKeys: Nullable.t<array<string>>,
97
+ uploaded: Nullable.t<int>,
98
+ }
99
+
100
+ type createResultOut = {id: string, outs: outs}
101
+ type updateResultOut = {outs: outs}
102
+ type diffResult = {changes: bool, replaces: array<string>, deleteBeforeReplace: bool}
103
+
104
+ /** What the bucket holds, key to ETag (quotes stripped). */
105
+ let listed = async (~bucket: string): dict<string> => {
106
+ let (sdk, s3) = await client()
107
+ let found = Dict.make()
108
+ let rec page = async (token: option<string>) => {
109
+ let result = await s3->sendList(newCommand(sdk.listCtor, {bucket, continuationToken: ?token}))
110
+ result.contents
111
+ ->Option.getOr([])
112
+ ->Array.forEach(o =>
113
+ found->Dict.set(o.key, o.etag->Option.getOr("")->String.replaceAll("\"", ""))
114
+ )
115
+ switch (result.isTruncated, result.nextContinuationToken) {
116
+ | (Some(true), Some(next)) => await page(Some(next))
117
+ | _ => ()
118
+ }
119
+ }
120
+ await page(None)
121
+ found
122
+ }
123
+
124
+ /** Runs `work` over `items`, `limit` at a time. */
125
+ let inBatches = async (items: array<'a>, ~limit: int, work: 'a => promise<unit>): unit => {
126
+ let rec from = async (start: int) =>
127
+ if start < Array.length(items) {
128
+ let _ = await Promise.all(items->Array.slice(~start, ~end=start + limit)->Array.map(work))
129
+ await from(start + limit)
130
+ }
131
+ await from(0)
132
+ }
133
+
134
+ /** Puts every file the bucket lacks or holds different bytes for; returns how many. */
135
+ let upload = async ({bucket, dir, files}: providerInputs): int => {
136
+ let (sdk, s3) = await client()
137
+ let fs = await importFs("node:fs/promises")
138
+ let crypto = await importCrypto("node:crypto")
139
+ let held = await listed(~bucket)
140
+ let count = ref(0)
141
+ await files->inBatches(~limit=16, async f => {
142
+ let body = await fs.readFile(dir ++ "/" ++ f.key)
143
+ let md5 = crypto.createHash("md5")->hashUpdate(body)->hashDigest("hex")
144
+ if held->Dict.get(f.key) != Some(md5) {
145
+ await s3->sendPut(
146
+ newCommand(
147
+ sdk.putCtor,
148
+ {bucket, key: f.key, body, contentType: f.contentType, cacheControl: f.cacheControl},
149
+ ),
150
+ )
151
+ count := count.contents + 1
152
+ }
153
+ })
154
+ log.info(
155
+ ~comp="Util_StaticBundleUpload",
156
+ `${bucket}: ${Int.toString(count.contents)} of ${Int.toString(
157
+ Array.length(files),
158
+ )} files uploaded`,
159
+ )
160
+ count.contents
161
+ }
162
+
163
+ let remove = async (~bucket: string, keys: array<string>): unit => {
164
+ let (sdk, s3) = await client()
165
+ await keys->inBatches(~limit=16, async key =>
166
+ await s3->sendDelete(newCommand(sdk.deleteCtor, {bucket, key}))
167
+ )
168
+ }
169
+
170
+ let keysOf = (inputs: providerInputs) => inputs.files->Array.map(f => f.key)
171
+
172
+ /** The keys an update deletes: last deploy's previous ones that neither this deploy nor the
173
+ last one names. */
174
+ let staleKeys = (
175
+ ~current: array<string>,
176
+ ~previous: array<string>,
177
+ ~beforePrevious: array<string>,
178
+ ) =>
179
+ beforePrevious->Array.filter(k => !(current->Array.includes(k)) && !(previous->Array.includes(k)))
180
+
181
+ let create = async (inputs: providerInputs): createResultOut => {
182
+ let uploaded = await upload(inputs)
183
+ {
184
+ id: inputs.bucket,
185
+ outs: {
186
+ bucket: Nullable.make(inputs.bucket),
187
+ keys: Nullable.make(keysOf(inputs)),
188
+ previousKeys: Nullable.make([]),
189
+ uploaded: Nullable.make(uploaded),
190
+ },
191
+ }
192
+ }
193
+
194
+ let update = async (_id: string, olds: outs, news: providerInputs): updateResultOut => {
195
+ let uploaded = await upload(news)
196
+ let current = keysOf(news)
197
+ let previous = olds.keys->Nullable.toOption->Option.getOr([])
198
+ let stale = staleKeys(
199
+ ~current,
200
+ ~previous,
201
+ ~beforePrevious=olds.previousKeys->Nullable.toOption->Option.getOr([]),
202
+ )
203
+ await remove(~bucket=news.bucket, stale)
204
+ {
205
+ outs: {
206
+ bucket: Nullable.make(news.bucket),
207
+ keys: Nullable.make(current),
208
+ previousKeys: Nullable.make(previous),
209
+ uploaded: Nullable.make(uploaded),
210
+ },
211
+ }
212
+ }
213
+
214
+ /** Takes its files with it: the ones it uploaded in its last two deploys. */
215
+ let delete_ = async (_id: string, olds: outs): unit =>
216
+ switch olds.bucket->Nullable.toOption {
217
+ | None => ()
218
+ | Some(bucket) =>
219
+ let keys = Array.concat(
220
+ olds.keys->Nullable.toOption->Option.getOr([]),
221
+ olds.previousKeys->Nullable.toOption->Option.getOr([]),
222
+ )
223
+ await remove(~bucket, keys->Set.fromArray->Set.values->Iterator.toArray)
224
+ }
225
+
226
+ let diff_ = (_id: string, olds: outs, news: providerInputs): diffResult => {
227
+ changes: true,
228
+ replaces: olds.bucket->Nullable.toOption != Some(news.bucket) ? ["bucket"] : [],
229
+ deleteBeforeReplace: false,
230
+ }
231
+
232
+ let provider = {
233
+ "create": create,
234
+ "update": update,
235
+ "delete": delete_,
236
+ "diff": diff_,
237
+ }
238
+
239
+ // ── Pulumi dynamic resource binding ──────────────────────────────────────────
240
+
241
+ type t = {uploaded: Pulumi.Output.t<int>}
242
+
243
+ type constructorProps = {
244
+ bucket: Pulumi.Input.t<string>,
245
+ dir: string,
246
+ files: array<file>,
247
+ }
248
+
249
+ /** Output-only properties are declared `undefined`, Pulumi's way to register them. */
250
+ type resourceProps = {
251
+ bucket: Pulumi.Input.t<string>,
252
+ dir: string,
253
+ files: array<file>,
254
+ keys: Nullable.t<array<string>>,
255
+ previousKeys: Nullable.t<array<string>>,
256
+ uploaded: Nullable.t<int>,
257
+ }
258
+
259
+ @module("@pulumi/pulumi/dynamic/index.js") @new
260
+ external _newResource: ('provider, string, 'props, Pulumi.CustomResourceOptions.t) => t = "Resource"
261
+
262
+ let make = (~name: string, ~props: constructorProps): t =>
263
+ _newResource(
264
+ provider,
265
+ name,
266
+ {
267
+ bucket: props.bucket,
268
+ dir: props.dir,
269
+ files: props.files,
270
+ keys: Nullable.undefined,
271
+ previousKeys: Nullable.undefined,
272
+ uploaded: Nullable.undefined,
273
+ },
274
+ {},
275
+ )
@@ -0,0 +1,211 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
5
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
6
+ import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
7
+ import * as Util_LogGroup_Adopting_JsMjs from "./Util_LogGroup_Adopting_Js.mjs";
8
+ import * as IndexJs from "@pulumi/pulumi/dynamic/index.js";
9
+
10
+ let log = Logger$ReventlessCore.fromEnv();
11
+
12
+ function newCommand(prim0, prim1) {
13
+ return Util_LogGroup_Adopting_JsMjs.newCommand(prim0, prim1);
14
+ }
15
+
16
+ function newClient(prim) {
17
+ return Util_LogGroup_Adopting_JsMjs.newClient(prim);
18
+ }
19
+
20
+ let _client = {
21
+ contents: undefined
22
+ };
23
+
24
+ async function client() {
25
+ let c = _client.contents;
26
+ if (c !== undefined) {
27
+ return c;
28
+ }
29
+ let sdk = await import("@aws-sdk/client-s3");
30
+ let c_1 = Util_LogGroup_Adopting_JsMjs.newClient(sdk.S3Client);
31
+ let c$1 = [
32
+ sdk,
33
+ c_1
34
+ ];
35
+ _client.contents = c$1;
36
+ return c$1;
37
+ }
38
+
39
+ async function listed(bucket) {
40
+ let match = await client();
41
+ let s3 = match[1];
42
+ let sdk = match[0];
43
+ let found = {};
44
+ let page = async token => {
45
+ let result = await s3.send(Util_LogGroup_Adopting_JsMjs.newCommand(sdk.ListObjectsV2Command, {
46
+ Bucket: bucket,
47
+ ContinuationToken: token
48
+ }));
49
+ Stdlib_Option.getOr(result.Contents, []).forEach(o => {
50
+ found[o.Key] = Stdlib_Option.getOr(o.ETag, "").replaceAll("\"", "");
51
+ });
52
+ let match = result.IsTruncated;
53
+ let match$1 = result.NextContinuationToken;
54
+ if (match !== undefined && match && match$1 !== undefined) {
55
+ return await page(match$1);
56
+ }
57
+ };
58
+ await page(undefined);
59
+ return found;
60
+ }
61
+
62
+ async function inBatches(items, limit, work) {
63
+ let from = async start => {
64
+ if (start < items.length) {
65
+ await Promise.all(items.slice(start, start + limit | 0).map(work));
66
+ return await from(start + limit | 0);
67
+ }
68
+ };
69
+ return await from(0);
70
+ }
71
+
72
+ async function upload(param) {
73
+ let files = param.files;
74
+ let dir = param.dir;
75
+ let bucket = param.bucket;
76
+ let match = await client();
77
+ let s3 = match[1];
78
+ let sdk = match[0];
79
+ let fs = await import("node:fs/promises");
80
+ let crypto = await import("node:crypto");
81
+ let held = await listed(bucket);
82
+ let count = {
83
+ contents: 0
84
+ };
85
+ await inBatches(files, 16, async f => {
86
+ let body = await fs.readFile(dir + "/" + f.key);
87
+ let md5 = crypto.createHash("md5").update(body).digest("hex");
88
+ if (Primitive_object.notequal(held[f.key], md5)) {
89
+ await s3.send(Util_LogGroup_Adopting_JsMjs.newCommand(sdk.PutObjectCommand, {
90
+ Bucket: bucket,
91
+ Key: f.key,
92
+ Body: body,
93
+ ContentType: f.contentType,
94
+ CacheControl: f.cacheControl
95
+ }));
96
+ count.contents = count.contents + 1 | 0;
97
+ return;
98
+ }
99
+ });
100
+ log.info("Util_StaticBundleUpload", undefined, bucket + `: ` + count.contents.toString() + ` of ` + files.length.toString() + ` files uploaded`);
101
+ return count.contents;
102
+ }
103
+
104
+ async function remove(bucket, keys) {
105
+ let match = await client();
106
+ let s3 = match[1];
107
+ let sdk = match[0];
108
+ return await inBatches(keys, 16, async key => await s3.send(Util_LogGroup_Adopting_JsMjs.newCommand(sdk.DeleteObjectCommand, {
109
+ Bucket: bucket,
110
+ Key: key
111
+ })));
112
+ }
113
+
114
+ function keysOf(inputs) {
115
+ return inputs.files.map(f => f.key);
116
+ }
117
+
118
+ function staleKeys(current, previous, beforePrevious) {
119
+ return beforePrevious.filter(k => {
120
+ if (current.includes(k)) {
121
+ return false;
122
+ } else {
123
+ return !previous.includes(k);
124
+ }
125
+ });
126
+ }
127
+
128
+ async function create(inputs) {
129
+ let uploaded = await upload(inputs);
130
+ return {
131
+ id: inputs.bucket,
132
+ outs: {
133
+ bucket: inputs.bucket,
134
+ keys: keysOf(inputs),
135
+ previousKeys: [],
136
+ uploaded: uploaded
137
+ }
138
+ };
139
+ }
140
+
141
+ async function update(_id, olds, news) {
142
+ let uploaded = await upload(news);
143
+ let current = keysOf(news);
144
+ let previous = Stdlib_Option.getOr(Primitive_option.fromNullable(olds.keys), []);
145
+ let stale = staleKeys(current, previous, Stdlib_Option.getOr(Primitive_option.fromNullable(olds.previousKeys), []));
146
+ await remove(news.bucket, stale);
147
+ return {
148
+ outs: {
149
+ bucket: news.bucket,
150
+ keys: current,
151
+ previousKeys: previous,
152
+ uploaded: uploaded
153
+ }
154
+ };
155
+ }
156
+
157
+ async function delete_(_id, olds) {
158
+ let bucket = olds.bucket;
159
+ if (bucket == null) {
160
+ return;
161
+ }
162
+ let keys = Stdlib_Option.getOr(Primitive_option.fromNullable(olds.keys), []).concat(Stdlib_Option.getOr(Primitive_option.fromNullable(olds.previousKeys), []));
163
+ return await remove(bucket, new Set(keys).values().toArray());
164
+ }
165
+
166
+ function diff_(_id, olds, news) {
167
+ return {
168
+ changes: true,
169
+ replaces: Primitive_object.notequal(Primitive_option.fromNullable(olds.bucket), news.bucket) ? ["bucket"] : [],
170
+ deleteBeforeReplace: false
171
+ };
172
+ }
173
+
174
+ let provider = {
175
+ create: create,
176
+ update: update,
177
+ delete: delete_,
178
+ diff: diff_
179
+ };
180
+
181
+ function make(name, props) {
182
+ return new IndexJs.Resource(provider, name, {
183
+ bucket: props.bucket,
184
+ dir: props.dir,
185
+ files: props.files,
186
+ keys: undefined,
187
+ previousKeys: undefined,
188
+ uploaded: undefined
189
+ }, {});
190
+ }
191
+
192
+ export {
193
+ log,
194
+ newCommand,
195
+ newClient,
196
+ _client,
197
+ client,
198
+ listed,
199
+ inBatches,
200
+ upload,
201
+ remove,
202
+ keysOf,
203
+ staleKeys,
204
+ create,
205
+ update,
206
+ delete_,
207
+ diff_,
208
+ provider,
209
+ make,
210
+ }
211
+ /* log Not a pure module */
@@ -1080,3 +1080,36 @@ describe("AppSync_Adapter.injectAwsAuth — subscriptions", () => {
1080
1080
  expect(decodeFragment(aug).subscriptions->Array.length)->toBe(2)
1081
1081
  })
1082
1082
  })
1083
+
1084
+ // ── A failed schema push says why ────────────────────────────────────────────
1085
+
1086
+ describe("AppSync_Adapter.startSchemaCreationRetrying", () => {
1087
+ // A client whose every call rejects the way the SDK does, with an Error.
1088
+ let rejectingClient: string => AppSync_Adapter.appSyncClient = %raw(`
1089
+ message => ({send: () => Promise.reject(new Error(message))})
1090
+ `)
1091
+
1092
+ test("fails with the API and AWS's message, not [object Object]", async () => {
1093
+ let message = ref("")
1094
+ try {
1095
+ await AppSync_Adapter.startSchemaCreationRetrying(
1096
+ rejectingClient("BadRequestException: Failed to parse schema document"),
1097
+ {apiId: "api-1", definition: "type Query"},
1098
+ )
1099
+ } catch {
1100
+ | exn => message := exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("")
1101
+ }
1102
+ expect(message.contents)->toContain("api-1")
1103
+ expect(message.contents)->toContain("Failed to parse schema document")
1104
+ expect(message.contents)->not_->toContain("[object Object]")
1105
+ })
1106
+
1107
+ testSync("says when the retries ran out", () =>
1108
+ expect(
1109
+ AppSync_Adapter.schemaPushFailure(
1110
+ ~apiId="api-1",
1111
+ Transient("ConcurrentModificationException: Schema is currently being altered"),
1112
+ ),
1113
+ )->toContain("still failed after its retries")
1114
+ )
1115
+ })
@@ -953,6 +953,31 @@ globalThis.describe("AppSync_Adapter.injectAwsAuth — subscriptions", () => {
953
953
  });
954
954
  });
955
955
 
956
+ globalThis.describe("AppSync_Adapter.startSchemaCreationRetrying", () => {
957
+ let rejectingClient = (message => ({send: () => Promise.reject(new Error(message))}));
958
+ globalThis.test("fails with the API and AWS's message, not [object Object]", async () => {
959
+ let message = "";
960
+ try {
961
+ await AppSync_Adapter$ReventlessAws.startSchemaCreationRetrying(rejectingClient("BadRequestException: Failed to parse schema document"), {
962
+ apiId: "api-1",
963
+ definition: "type Query"
964
+ });
965
+ } catch (raw_exn) {
966
+ let exn = Primitive_exceptions.internalToException(raw_exn);
967
+ message = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "");
968
+ }
969
+ globalThis.expect(message).toContain("api-1");
970
+ globalThis.expect(message).toContain("Failed to parse schema document");
971
+ globalThis.expect(message).not.toContain("[object Object]");
972
+ });
973
+ globalThis.test("says when the retries ran out", () => {
974
+ globalThis.expect(AppSync_Adapter$ReventlessAws.schemaPushFailure("api-1", {
975
+ TAG: "Transient",
976
+ _0: "ConcurrentModificationException: Schema is currently being altered"
977
+ })).toContain("still failed after its retries");
978
+ });
979
+ });
980
+
956
981
  export {
957
982
  decodeFragment,
958
983
  TwoRuleFeed,
@@ -0,0 +1,30 @@
1
+ open Jest
2
+ open Expect
3
+
4
+ describe("Util_StaticBundleUpload.staleKeys", () => {
5
+ test("deletes a key only once two deploys have not named it", () =>
6
+ expect(
7
+ Util_StaticBundleUpload.staleKeys(
8
+ ~current=["index.html", "assets/c.js"],
9
+ ~previous=["index.html", "assets/b.js"],
10
+ ~beforePrevious=["index.html", "assets/a.js", "assets/b.js"],
11
+ ),
12
+ )->toEqual(["assets/a.js"])
13
+ )
14
+
15
+ test("keeps a key that came back after a deploy without it", () =>
16
+ expect(
17
+ Util_StaticBundleUpload.staleKeys(
18
+ ~current=["assets/a.js"],
19
+ ~previous=["assets/b.js"],
20
+ ~beforePrevious=["assets/a.js"],
21
+ ),
22
+ )->toEqual([])
23
+ )
24
+
25
+ test("deletes nothing on the first two deploys", () =>
26
+ expect(
27
+ Util_StaticBundleUpload.staleKeys(~current=["a"], ~previous=[], ~beforePrevious=[]),
28
+ )->toEqual([])
29
+ )
30
+ })
@@ -0,0 +1,22 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Jest from "@reventlessdev/rescript-jest/src/Jest.res.mjs";
4
+ import * as Util_StaticBundleUpload$ReventlessAws from "../src/util/Util_StaticBundleUpload.res.mjs";
5
+
6
+ Jest.describe("Util_StaticBundleUpload.staleKeys", () => {
7
+ Jest.test("deletes a key only once two deploys have not named it", () => Jest.Expect.toEqual(Jest.Expect.expect(Util_StaticBundleUpload$ReventlessAws.staleKeys([
8
+ "index.html",
9
+ "assets/c.js"
10
+ ], [
11
+ "index.html",
12
+ "assets/b.js"
13
+ ], [
14
+ "index.html",
15
+ "assets/a.js",
16
+ "assets/b.js"
17
+ ])), ["assets/a.js"]));
18
+ Jest.test("keeps a key that came back after a deploy without it", () => Jest.Expect.toEqual(Jest.Expect.expect(Util_StaticBundleUpload$ReventlessAws.staleKeys(["assets/a.js"], ["assets/b.js"], ["assets/a.js"])), []));
19
+ Jest.test("deletes nothing on the first two deploys", () => Jest.Expect.toEqual(Jest.Expect.expect(Util_StaticBundleUpload$ReventlessAws.staleKeys(["a"], [], [])), []));
20
+ });
21
+
22
+ /* Not a pure module */