@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 +14 -0
- package/package.json +8 -8
- package/src/components/Api/AppSync_Adapter.res +10 -0
- package/src/components/Api/AppSync_Adapter.res.mjs +10 -1
- package/src/plugin/stack/Plugin_Stack.res +24 -28
- package/src/plugin/stack/Plugin_Stack.res.mjs +11 -11
- package/src/util/Util_StaticBundleUpload.res +275 -0
- package/src/util/Util_StaticBundleUpload.res.mjs +211 -0
- package/tests/AppSync_AdapterTest.res +33 -0
- package/tests/AppSync_AdapterTest.res.mjs +25 -0
- package/tests/Util_StaticBundleUploadTest.res +30 -0
- package/tests/Util_StaticBundleUploadTest.res.mjs +22 -0
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.
|
|
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-
|
|
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/
|
|
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-
|
|
36
|
-
"@reventlessdev/reventless-
|
|
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,
|
|
118
|
-
* with the correct MIME type
|
|
119
|
-
*
|
|
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.
|
|
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
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
|
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
|
|
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,
|
|
532
|
+
(distribution.id, upload.uploaded)
|
|
537
533
|
->Pulumi.Output.all2
|
|
538
|
-
->Pulumi.Output.flatMap(((distId,
|
|
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
|
|
291
|
-
bucket: bucket.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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 */
|