@reventlessdev/reventless-aws 3.0.0-alpha.390 → 3.0.0-alpha.392

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.392 (2026-10-21)
7
+
8
+ ### Features
9
+
10
+ * **aws:** a UI bundle uploads as one resource, putting only the files that changed ([9e28682](https://github.com/ReventlessDev/reventless-core/commit/9e286829817c6679aace100b3f17195a777a4cf4))
11
+
12
+
13
+ # 3.0.0-alpha.391 (2026-10-21)
14
+
15
+ ### Features
16
+
17
+ * **aws:** a UI bundle's per-file objects are retained on delete, and the deploy workflow takes a pulumi-parallel ([7155acf](https://github.com/ReventlessDev/reventless-core/commit/7155acfc85680f7eebef843e2c34d40a79114a7c))
18
+
19
+
6
20
  # 3.0.0-alpha.390 (2026-10-21)
7
21
 
8
22
  * feat!: a rejected command says whether the model, the access check or the platform said no ([6a8a6b0](https://github.com/ReventlessDev/reventless-core/commit/6a8a6b0f2f31e766ae26a944c23b0904e51a5865))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.390",
3
+ "version": "3.0.0-alpha.392",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -24,15 +24,15 @@
24
24
  "yaml": "^2.8.3",
25
25
  "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.33",
26
26
  "@reventlessdev/rescript-effect": "1.0.0-alpha.1",
27
- "@reventlessdev/rescript-jest": "1.0.0-alpha.13",
28
27
  "@reventlessdev/rescript-node": "2.0.0-alpha.23",
29
28
  "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.31",
30
- "@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
31
- "@reventlessdev/rescript-web": "1.0.0-alpha.8",
29
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.13",
32
30
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
33
- "@reventlessdev/reventless-interop": "3.0.0-alpha.42",
31
+ "@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
34
32
  "@reventlessdev/reventless-core": "3.0.0-alpha.303",
35
33
  "@reventlessdev/reventless-infra": "3.0.0-alpha.200",
34
+ "@reventlessdev/rescript-web": "1.0.0-alpha.8",
35
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.42",
36
36
  "@reventlessdev/reventless-postgres": "3.0.0-alpha.167",
37
37
  "@reventlessdev/reventless-spec": "3.0.0-alpha.172"
38
38
  },
@@ -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,39 +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
- )
522
- obj.etag
523
- })
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
+ )
524
523
 
525
- // Auto-invalidate CloudFront once every object is (re)uploaded. Stable-named
524
+ // Auto-invalidate CloudFront once the upload is done. Stable-named
526
525
  // entry files (index.html, the module-federation mf-entry-bootstrap-*.js)
527
526
  // are served by the long-TTL default cache behavior, so without this a
528
527
  // `pulumi up` lands the new bundle in S3 while CloudFront keeps serving the
529
- // 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
530
529
  // AFTER the new content is in S3; `/*` is a single invalidation path (well
531
530
  // within CloudFront's free-tier monthly allowance even on frequent deploys).
532
531
  let _ =
533
- (distribution.id, Pulumi.Output.all(objectEtags))
532
+ (distribution.id, upload.uploaded)
534
533
  ->Pulumi.Output.all2
535
- ->Pulumi.Output.flatMap(((distId, _etags)) =>
534
+ ->Pulumi.Output.flatMap(((distId, _uploaded)) =>
536
535
  invalidateDistribution(~distributionId=distId, ~paths=["/*"])->Pulumi.Output.fromPromise
537
536
  )
538
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,17 +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
- }).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
+ });
298
300
  Output$Pulumi.flatMap(Pulumi.all([
299
301
  distribution.id,
300
- Pulumi.all(objectEtags)
302
+ upload.uploaded
301
303
  ]), param => invalidateDistribution(param[0], ["/*"]));
302
304
  }
303
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 */
@@ -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 */