@reventlessdev/reventless-aws 3.0.0-alpha.253 → 3.0.0-alpha.255

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +10 -10
  3. package/src/Platform.res +134 -20
  4. package/src/Platform.res.mjs +95 -6
  5. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +14 -0
  6. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +4 -1
  7. package/src/adapter/Api/Platform_UIFragments_Lambda.res +14 -0
  8. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +4 -1
  9. package/src/adapter/Geocoder/Geocoder_AwsLocation.res +14 -0
  10. package/src/adapter/Geocoder/Geocoder_AwsLocation.res.mjs +4 -1
  11. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +5 -0
  12. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +3 -0
  13. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +5 -0
  14. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +3 -0
  15. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +57 -8
  16. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +15 -3
  17. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +5 -0
  18. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +3 -0
  19. package/src/adapter/StateTopic/StateTopic_AppSync.res +14 -0
  20. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +4 -1
  21. package/src/adapter/Upload/Upload_Claim_S3.res +375 -0
  22. package/src/adapter/Upload/Upload_Claim_S3.res.mjs +206 -0
  23. package/src/adapter/Upload/Upload_Claim_S3_Ops.res +279 -0
  24. package/src/adapter/Upload/Upload_Claim_S3_Ops.res.mjs +261 -0
  25. package/src/adapter/Upload/Upload_PendingTag.res +41 -0
  26. package/src/adapter/Upload/Upload_PendingTag.res.mjs +15 -0
  27. package/src/adapter/Upload/Upload_Presign_S3.res +26 -2
  28. package/src/adapter/Upload/Upload_Presign_S3.res.mjs +5 -1
  29. package/src/adapter/Upload/Upload_Presign_S3_Ops.res +13 -1
  30. package/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs +3 -1
  31. package/src/capability/Capability_ObjectStore_S3.res +42 -1
  32. package/src/capability/Capability_ObjectStore_S3.res.mjs +19 -1
  33. package/src/components/Api/AppSync_Adapter.res +36 -0
  34. package/src/components/Api/AppSync_Adapter.res.mjs +14 -0
  35. package/src/util/Util_DeadLetterQueue.res +1 -0
  36. package/src/util/Util_DeadLetterQueue.res.mjs +6 -2
  37. package/src/util/Util_LambdaLogging.res +83 -0
  38. package/src/util/Util_LambdaLogging.res.mjs +59 -0
  39. package/src/util/Util_LogRetention.res +86 -0
  40. package/src/util/Util_LogRetention.res.mjs +43 -0
  41. package/src/util/Util_StoreLayout.res +37 -0
  42. package/src/util/Util_StoreLayout.res.mjs +18 -0
  43. package/tests/Upload_ClaimTest.res +132 -0
  44. package/tests/Upload_ClaimTest.res.mjs +101 -0
  45. package/tests/Util_LogRetentionTest.res +122 -0
  46. package/tests/Util_LogRetentionTest.res.mjs +95 -0
  47. package/tests/Util_StoreLayoutTest.res +63 -0
  48. package/tests/Util_StoreLayoutTest.res.mjs +42 -0
@@ -11,6 +11,7 @@ import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/sr
11
11
  import * as Monitoring$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Monitoring/Monitoring.res.mjs";
12
12
  import * as Util_Bundle$ReventlessAws from "./Util_Bundle.res.mjs";
13
13
  import * as Util_Lambda$ReventlessAws from "./Util_Lambda.res.mjs";
14
+ import * as Util_LambdaLogging$ReventlessAws from "./Util_LambdaLogging.res.mjs";
14
15
  import * as Util_EventSourceMapping$ReventlessAws from "./Util_EventSourceMapping.res.mjs";
15
16
 
16
17
  let name = "DeadLetterQueue";
@@ -73,10 +74,13 @@ let handler = new (Aws.lambda.Function)(name, {
73
74
  layers: layers,
74
75
  tags: AWS_Tags$ReventlessAws.make(name, "Plugin", "DeadLetter", "Plugin", undefined, undefined, undefined, undefined),
75
76
  environment: {
76
- variables: Object.fromEntries([[
77
+ variables: Object.fromEntries([
78
+ [
77
79
  "Environment",
78
80
  Pulumi.getStack()
79
- ]])
81
+ ],
82
+ Util_LambdaLogging$ReventlessAws.logLevelEntry()
83
+ ])
80
84
  },
81
85
  sourceCodeHash: sourceCodeHash
82
86
  }, opts);
@@ -0,0 +1,83 @@
1
+ // Shared application of the environment-tiered logging policy
2
+ // (`Util_LogRetention`) to Lambdas. `RuntimeEnvironment_Lambda.makeFromCodeAsset`
3
+ // and the bespoke `Lambda.Function.make` builders (DeadLetterQueue, Upload,
4
+ // Geocoder, the platform UI Lambdas, the state-topic publisher) all route the
5
+ // `LOG_LEVEL` default and the managed log group through here, so a builder cannot
6
+ // silently miss the tier policy — the gap that left ~8 platform Lambdas on the
7
+ // logger's default and unmanaged, forever-retained log groups.
8
+ open PulumiAws
9
+
10
+ // Stack + prod allow-list + the unmanaged-groups escape hatch, resolved from
11
+ // config. Impure by necessity (reads the stack name and config keys); the tier
12
+ // decisions it feeds are the pure functions in `Util_LogRetention`.
13
+ let stackContext = () => {
14
+ let stack = Pulumi.Pulumi.getStackName()
15
+ let prodStacks = Util_HostUiDomain.resolveProdStacks()
16
+ let unmanagedStacks = Util_LogRetention.parseUnmanagedStacks(
17
+ Util_LocalConfig.get("unmanagedLogGroupStacks")->Option.getOr(""),
18
+ )
19
+ (stack, prodStacks, unmanagedStacks)
20
+ }
21
+
22
+ // The `("LOG_LEVEL", <tier default>)` env-var entry. For the bespoke builders
23
+ // that assemble their env with `Dict.fromArray([...])` and never pin LOG_LEVEL —
24
+ // they just append this entry.
25
+ let logLevelEntry = (): (string, Pulumi.Input.t<string>) => {
26
+ let (stack, prodStacks, _) = stackContext()
27
+ (
28
+ "LOG_LEVEL",
29
+ Util_LogRetention.logLevelFor(
30
+ ~stack,
31
+ ~prodStacks,
32
+ ~configOverride=?Util_LocalConfig.get("logLevel"),
33
+ )->Pulumi.Input.make,
34
+ )
35
+ }
36
+
37
+ // Default `LOG_LEVEL` to the environment tier when the caller has not pinned one.
38
+ // Mutates the given variables dict in place, so it must run after the caller's
39
+ // own env vars are set (theirs win). For `makeFromCodeAsset`, whose callers can
40
+ // pass LOG_LEVEL through `envVars`.
41
+ let applyLogLevelDefault = (variables: dict<Pulumi.Input.t<string>>) =>
42
+ if variables->Dict.get("LOG_LEVEL")->Option.isNone {
43
+ let (key, value) = logLevelEntry()
44
+ variables->Dict.set(key, value)
45
+ }
46
+
47
+ // Create a managed CloudWatch log group with tier retention for a Lambda when the
48
+ // stack manages its groups (`Util_LogRetention.managesLogGroup`). The group name
49
+ // derives from the function's own *physical* name output — never the logical
50
+ // name — so it matches the group Lambda would auto-create and carries Pulumi's
51
+ // uniqueness suffix. The caller supplies `~tags` (Logs-role, its own attribution
52
+ // convention) and `~opts` (so the group parents to the same component and is torn
53
+ // down with it — closing the orphan-group loop for the bespoke builders too). The
54
+ // trailing `()` terminates the optional `~opts`.
55
+ let makeManagedLogGroup = (
56
+ ~name: string,
57
+ ~lambdaName: Pulumi.Output.t<string>,
58
+ ~tags,
59
+ ~opts=?,
60
+ (),
61
+ ) => {
62
+ let (stack, prodStacks, unmanagedStacks) = stackContext()
63
+ if Util_LogRetention.managesLogGroup(~stack, ~unmanagedStacks) {
64
+ let days = Util_LogRetention.retentionDaysFor(
65
+ ~stack,
66
+ ~prodStacks,
67
+ ~configOverride=?Util_LocalConfig.get("logRetentionDays")->Option.flatMap(s =>
68
+ Int.fromString(s)
69
+ ),
70
+ )
71
+ let _ = Cloudwatch.LogGroup.make(
72
+ ~name=`${name}LogGroup`,
73
+ ~args={
74
+ name: lambdaName
75
+ ->Pulumi.Output.apply(n => `/aws/lambda/${n}`)
76
+ ->Pulumi.Output.asInput,
77
+ retentionInDays: days->Pulumi.Input.make,
78
+ tags,
79
+ },
80
+ ~opts?,
81
+ )
82
+ }
83
+ }
@@ -0,0 +1,59 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
4
+ import * as Aws from "@pulumi/aws";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Pulumi from "@pulumi/pulumi";
7
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
8
+ import * as Util_LocalConfig$ReventlessAws from "./Util_LocalConfig.res.mjs";
9
+ import * as Util_HostUiDomain$ReventlessAws from "./Util_HostUiDomain.res.mjs";
10
+ import * as Util_LogRetention$ReventlessAws from "./Util_LogRetention.res.mjs";
11
+
12
+ function stackContext() {
13
+ let stack = Pulumi.getStack();
14
+ let prodStacks = Util_HostUiDomain$ReventlessAws.resolveProdStacks();
15
+ let unmanagedStacks = Util_LogRetention$ReventlessAws.parseUnmanagedStacks(Stdlib_Option.getOr(Util_LocalConfig$ReventlessAws.get("unmanagedLogGroupStacks"), ""));
16
+ return [
17
+ stack,
18
+ prodStacks,
19
+ unmanagedStacks
20
+ ];
21
+ }
22
+
23
+ function logLevelEntry() {
24
+ let match = stackContext();
25
+ return [
26
+ "LOG_LEVEL",
27
+ Util_LogRetention$ReventlessAws.logLevelFor(match[0], match[1], Util_LocalConfig$ReventlessAws.get("logLevel"))
28
+ ];
29
+ }
30
+
31
+ function applyLogLevelDefault(variables) {
32
+ if (!Stdlib_Option.isNone(variables["LOG_LEVEL"])) {
33
+ return;
34
+ }
35
+ let match = logLevelEntry();
36
+ variables[match[0]] = match[1];
37
+ }
38
+
39
+ function makeManagedLogGroup(name, lambdaName, tags, opts, param) {
40
+ let match = stackContext();
41
+ let stack = match[0];
42
+ if (!Util_LogRetention$ReventlessAws.managesLogGroup(stack, match[2])) {
43
+ return;
44
+ }
45
+ let days = Util_LogRetention$ReventlessAws.retentionDaysFor(stack, match[1], Stdlib_Option.flatMap(Util_LocalConfig$ReventlessAws.get("logRetentionDays"), s => Stdlib_Int.fromString(s, undefined)));
46
+ new (Aws.cloudwatch.LogGroup)(name + `LogGroup`, {
47
+ name: lambdaName.apply(n => `/aws/lambda/` + n),
48
+ retentionInDays: days,
49
+ tags: tags
50
+ }, opts !== undefined ? Primitive_option.valFromOption(opts) : undefined);
51
+ }
52
+
53
+ export {
54
+ stackContext,
55
+ logLevelEntry,
56
+ applyLogLevelDefault,
57
+ makeManagedLogGroup,
58
+ }
59
+ /* @pulumi/aws Not a pure module */
@@ -0,0 +1,86 @@
1
+ // How long a stack keeps its logs, and how verbose they are — tiered by
2
+ // environment, the same way `Util_StoreLayout` tiers bucket layout and
3
+ // `Util_HostUiDomain` tiers the host-shell FQDN. Pure functions so both
4
+ // decisions are decidable and unit-testable without touching Pulumi.
5
+ //
6
+ // Retention and level pull in opposite directions between prod and dev, which is
7
+ // the whole reason a single global setting cannot serve both:
8
+ //
9
+ // prod — quiet (`info`) and long-lived (365d): incident + forensic window
10
+ // dev — loud (`debug`) and short-lived (3–7d): consumed within hours
11
+ //
12
+ // The `prodStacks` allow-list is `Util_HostUiDomain.resolveProdStacks` — the one
13
+ // notion of "is this stack production" the platform already shares for domain
14
+ // naming and store layout, on purpose. A `configOverride` (from the Pulumi
15
+ // `logRetentionDays` / `logLevel` config keys) wins over the tier default so a
16
+ // stack can be dialled without a code change.
17
+
18
+ // CloudWatch only accepts a fixed enum of retention values; every number below is
19
+ // a member of it. `0` means "never expire" and is a valid explicit opt-in, but
20
+ // never a tier default — hot CloudWatch storage billed forever is what this
21
+ // tiering exists to bound.
22
+
23
+ /** Retention in days for a stack. prod/main = 365, beta = 30, `pr-*` = 3,
24
+ everything else (alpha and any unlisted stack) = 7. A `configOverride` wins. */
25
+ let retentionDaysFor = (
26
+ ~stack: string,
27
+ ~prodStacks: array<string>,
28
+ ~configOverride: option<int>=?,
29
+ ): int =>
30
+ switch configOverride {
31
+ | Some(days) => days
32
+ | None =>
33
+ switch stack {
34
+ | s if prodStacks->Array.includes(s) => 365
35
+ | "beta" => 30
36
+ | s if s->String.startsWith("pr-") => 3
37
+ | _ => 7
38
+ }
39
+ }
40
+
41
+ /** The default `LOG_LEVEL` for a stack. prod/main and beta stay at `info` so
42
+ pre-prod behaves like prod and debug detail never leaks payloads in prod;
43
+ every other stack defaults to `debug` for verbose feedback during active
44
+ development (paired with short retention, so cheap). A `configOverride`
45
+ wins. */
46
+ let logLevelFor = (
47
+ ~stack: string,
48
+ ~prodStacks: array<string>,
49
+ ~configOverride: option<string>=?,
50
+ ): string =>
51
+ switch configOverride {
52
+ | Some(level) => level
53
+ | None =>
54
+ switch stack {
55
+ | s if prodStacks->Array.includes(s) => "info"
56
+ | "beta" => "info"
57
+ | _ => "debug"
58
+ }
59
+ }
60
+
61
+ /**
62
+ Whether the framework creates a *managed* CloudWatch log group for this stack, or
63
+ leaves Lambda/AppSync to auto-create an unmanaged one.
64
+
65
+ **Managed for every stack by default.** A stack deployed on this framework gets
66
+ its log groups from Pulumi on the first `pulumi up`, before Lambda/AppSync would
67
+ lazily auto-create them — so on a fresh stack there is nothing to adopt and no
68
+ `ResourceAlreadyExists` hazard. Every current stack is `alpha`, and there is no
69
+ prod stack to migrate, so extending management to all stacks costs nothing today.
70
+
71
+ The one case that still needs care is *adopting* a stack that predates managed
72
+ groups: it already carries Lambda's auto-created group, and `CreateLogGroup`
73
+ fails with `ResourceAlreadyExists` rather than adopting it. When such a stack
74
+ ever appears, either `pulumi import` its groups first, or name it in the
75
+ `unmanagedLogGroupStacks` config key to keep it on auto-created groups until the
76
+ import is done. That escape hatch is the "prepared for the future" seam — turning
77
+ a prod adoption into a config flip rather than an edit to this function.
78
+ */
79
+ let managesLogGroup = (~stack: string, ~unmanagedStacks: array<string>=[]): bool =>
80
+ !(unmanagedStacks->Array.includes(stack))
81
+
82
+ /** Parse the `unmanagedLogGroupStacks` config value — a CSV of stack names kept
83
+ on auto-created groups pending a `pulumi import`. Same tolerant parse as the
84
+ prod-stacks list: trims, drops empty entries. */
85
+ let parseUnmanagedStacks = (csv: string): array<string> =>
86
+ csv->String.split(",")->Array.map(String.trim)->Array.filter(s => s !== "")
@@ -0,0 +1,43 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ function retentionDaysFor(stack, prodStacks, configOverride) {
5
+ if (configOverride !== undefined) {
6
+ return configOverride;
7
+ } else if (prodStacks.includes(stack)) {
8
+ return 365;
9
+ } else if (stack === "beta") {
10
+ return 30;
11
+ } else if (stack.startsWith("pr-")) {
12
+ return 3;
13
+ } else {
14
+ return 7;
15
+ }
16
+ }
17
+
18
+ function logLevelFor(stack, prodStacks, configOverride) {
19
+ if (configOverride !== undefined) {
20
+ return configOverride;
21
+ } else if (prodStacks.includes(stack) || stack === "beta") {
22
+ return "info";
23
+ } else {
24
+ return "debug";
25
+ }
26
+ }
27
+
28
+ function managesLogGroup(stack, unmanagedStacksOpt) {
29
+ let unmanagedStacks = unmanagedStacksOpt !== undefined ? unmanagedStacksOpt : [];
30
+ return !unmanagedStacks.includes(stack);
31
+ }
32
+
33
+ function parseUnmanagedStacks(csv) {
34
+ return csv.split(",").map(prim => prim.trim()).filter(s => s !== "");
35
+ }
36
+
37
+ export {
38
+ retentionDaysFor,
39
+ logLevelFor,
40
+ managesLogGroup,
41
+ parseUnmanagedStacks,
42
+ }
43
+ /* No side effect */
@@ -189,3 +189,40 @@ let coverageFor = (~required: array<string>, ~provisioned: array<string>): cover
189
189
  | (missing, provisioned) => Missing({missing, provisioned})
190
190
  }
191
191
  }
192
+
193
+ /**
194
+ After how many days a store expires objects still tagged pending — `None`, which
195
+ is the default, meaning it expires nothing.
196
+
197
+ The setting is a deployment's, not a declaration's: `@storageRef("productImages")`
198
+ says a store is needed, and says nothing about how long an unclaimed upload
199
+ should live there. Read from the `pendingUploadExpiryDays` config key (env var
200
+ `REVENTLESS_PENDING_UPLOAD_EXPIRY_DAYS`, or the `Pulumi.local.yaml` sidecar) as a
201
+ comma-separated list of `{plugin}.{store}=days` pairs:
202
+
203
+ pendingUploadExpiryDays: "Catalog.productImages=30, Ordering.receipts=14"
204
+
205
+ **Per store and off by default, on purpose.** Enabling this is the one step in
206
+ the mechanism that deletes data, and it is only sound once the claim component
207
+ has been live long enough for reconciliation to confirm that the objects still
208
+ tagged pending really are the unreferenced ones. A global switch — or a default
209
+ value — would turn it on for stores nobody had checked yet, which is the whole
210
+ failure this sequencing exists to prevent. A store that accumulates forever is a
211
+ legitimate choice, and stays the choice until a deployment says otherwise.
212
+
213
+ Only positive whole days are accepted. A malformed or non-positive entry is
214
+ ignored rather than rounded or defaulted: guessing at what a typo meant, in the
215
+ one setting that deletes objects, is not a service.
216
+ */
217
+ let pendingExpiryFor = (~config: option<string>, ~store: string): option<int> =>
218
+ config
219
+ ->Option.getOr("")
220
+ ->String.split(",")
221
+ ->Array.filterMap(entry =>
222
+ switch entry->String.split("=") {
223
+ | [name, days] if name->String.trim == store =>
224
+ days->String.trim->Int.fromString->Option.filter(d => d > 0)
225
+ | _ => None
226
+ }
227
+ )
228
+ ->Array.get(0)
@@ -1,5 +1,8 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
4
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
3
6
 
4
7
  let defaultEphemeralPrefixes = ["pr-"];
5
8
 
@@ -64,6 +67,20 @@ function coverageFor(required, provisioned) {
64
67
  }
65
68
  }
66
69
 
70
+ function pendingExpiryFor(config, store) {
71
+ return Stdlib_Array.filterMap(Stdlib_Option.getOr(config, "").split(","), entry => {
72
+ let match = entry.split("=");
73
+ if (match.length !== 2) {
74
+ return;
75
+ }
76
+ let name = match[0];
77
+ let days = match[1];
78
+ if (name.trim() === store) {
79
+ return Stdlib_Option.filter(Stdlib_Int.fromString(days.trim(), undefined), d => d > 0);
80
+ }
81
+ })[0];
82
+ }
83
+
67
84
  export {
68
85
  defaultEphemeralPrefixes,
69
86
  layoutFor,
@@ -72,5 +89,6 @@ export {
72
89
  keyPrefixFor,
73
90
  servingFor,
74
91
  coverageFor,
92
+ pendingExpiryFor,
75
93
  }
76
94
  /* No side effect */
@@ -0,0 +1,132 @@
1
+ // The claim component's pure decisions — no S3, no DynamoDB stream.
2
+ //
3
+ // Everything between "a committed event appeared" and "an S3 untag happens" is
4
+ // decided by three functions in `Upload_Claim_S3_Ops`: which table a record came
5
+ // from, which refs a declared field holds, and whether a ref resolves to an
6
+ // object this claimer may touch. The untag itself has no branching left in it,
7
+ // so pinning these pins the behaviour that matters.
8
+ //
9
+ // The direction under test throughout is refusal. An untag this component
10
+ // wrongly *skips* costs a delayed cleanup; one it wrongly *performs* removes the
11
+ // only thing standing between an object and an expiry rule.
12
+
13
+ open JestGlobals
14
+
15
+ module Ops = Upload_Claim_S3_Ops
16
+
17
+ // `stores` and `refFieldsByTable` are read from the environment at module load,
18
+ // so these tests exercise the decision functions against explicitly-built
19
+ // inputs rather than a configured module — which is also the honest scope: the
20
+ // env parsing is `JSON.parse` plus field reads.
21
+
22
+ describe("Upload_Claim_S3_Ops.tableNameFromEventSourceArn", () => {
23
+ testSync("reads the table name out of a stream ARN", () =>
24
+ expect(
25
+ Ops.tableNameFromEventSourceArn(
26
+ "arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-a1b2c3/stream/2026-08-02T00:00:00.000",
27
+ ),
28
+ )->toEqual(Some("CatalogDcbEventLog-a1b2c3"))
29
+ )
30
+
31
+ testSync("returns None for an ARN that is not a table stream", () =>
32
+ expect(Ops.tableNameFromEventSourceArn("arn:aws:sqs:eu-west-1:123456789012:SomeQueue"))->toEqual(
33
+ None,
34
+ )
35
+ )
36
+ })
37
+
38
+ describe("Upload_Claim_S3_Ops.keyOfRef", () => {
39
+ testSync("strips the leading slash the mint side writes", () =>
40
+ expect(Ops.keyOfRef("/Catalog/productImages/sub/uuid/p.png"))->toBe(
41
+ "Catalog/productImages/sub/uuid/p.png",
42
+ )
43
+ )
44
+
45
+ testSync("leaves an already-bare key alone", () =>
46
+ expect(Ops.keyOfRef("Catalog/productImages/sub/uuid/p.png"))->toBe(
47
+ "Catalog/productImages/sub/uuid/p.png",
48
+ )
49
+ )
50
+ })
51
+
52
+ describe("Upload_Claim_S3_Ops.refsOfField", () => {
53
+ let single: Ops.refField = {field: "imageUrl", many: false, store: "Catalog.productImages"}
54
+ let multi: Ops.refField = {field: "imageUrls", many: true, store: "Catalog.productImages"}
55
+ let ref1 = "/Catalog/productImages/sub/one/p.png"
56
+ let ref2 = "/Catalog/productImages/sub/two/q.png"
57
+
58
+ testSync("reads one ref from a string field", () =>
59
+ expect(
60
+ Ops.refsOfField(~data=Dict.fromArray([("imageUrl", JSON.Encode.string(ref1))]), single),
61
+ )->toEqual([ref1])
62
+ )
63
+
64
+ testSync("reads every ref from an array field", () =>
65
+ expect(
66
+ Ops.refsOfField(
67
+ ~data=Dict.fromArray([
68
+ ("imageUrls", JSON.Encode.array([JSON.Encode.string(ref1), JSON.Encode.string(ref2)])),
69
+ ]),
70
+ multi,
71
+ ),
72
+ )->toEqual([ref1, ref2])
73
+ )
74
+
75
+ // `StorageRef` admits "" as the "no object" sentinel, so a present-but-empty
76
+ // field must cost no S3 call rather than resolving to the store's root.
77
+ testSync("ignores the empty-string sentinel", () =>
78
+ expect(
79
+ Ops.refsOfField(~data=Dict.fromArray([("imageUrl", JSON.Encode.string(""))]), single),
80
+ )->toEqual([])
81
+ )
82
+
83
+ testSync("ignores an absent field", () =>
84
+ expect(Ops.refsOfField(~data=Dict.make(), single))->toEqual([])
85
+ )
86
+
87
+ // Arity is declared, not sniffed: a field declared single that arrives as an
88
+ // array (or the reverse) is a schema and a deploy disagreeing, and reading it
89
+ // anyway would mean guessing which one is right.
90
+ testSync("a single-arity field ignores an array value", () =>
91
+ expect(
92
+ Ops.refsOfField(
93
+ ~data=Dict.fromArray([("imageUrl", JSON.Encode.array([JSON.Encode.string(ref1)]))]),
94
+ single,
95
+ ),
96
+ )->toEqual([])
97
+ )
98
+
99
+ testSync("a multi-arity field ignores a bare string value", () =>
100
+ expect(
101
+ Ops.refsOfField(~data=Dict.fromArray([("imageUrls", JSON.Encode.string(ref1))]), multi),
102
+ )->toEqual([])
103
+ )
104
+
105
+ testSync("ignores non-string elements of an array field", () =>
106
+ expect(
107
+ Ops.refsOfField(
108
+ ~data=Dict.fromArray([
109
+ ("imageUrls", JSON.Encode.array([JSON.Encode.string(ref1), JSON.Encode.int(7)])),
110
+ ]),
111
+ multi,
112
+ ),
113
+ )->toEqual([ref1])
114
+ )
115
+ })
116
+
117
+ describe("Upload_Claim_S3_Ops.claim", () => {
118
+ // The tag key is the contract between three separately-deployed things: the
119
+ // presigned PUT that writes it, this component that removes it, and the
120
+ // lifecycle rule that expires what still carries it. A drift in any one of
121
+ // them is silent — a rule filtered on a key nobody writes matches nothing, and
122
+ // a claimer stripping the wrong key leaves every object tagged.
123
+ testSync("removes the same tag the mint side writes", () =>
124
+ expect(Upload_PendingTag.key)->toBe("reventless:pending")
125
+ )
126
+
127
+ // The presigned URL carries `Tagging` as a URL-encoded query string, so the
128
+ // colon has to survive one decode by S3 to land as the key above.
129
+ testSync("the presign tagging parameter decodes to that key and value", () =>
130
+ expect(Upload_PendingTag.putObjectTagging)->toBe("reventless%3Apending=true")
131
+ )
132
+ })
@@ -0,0 +1,101 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Upload_PendingTag$ReventlessAws from "../src/adapter/Upload/Upload_PendingTag.res.mjs";
4
+ import * as Upload_Claim_S3_Ops$ReventlessAws from "../src/adapter/Upload/Upload_Claim_S3_Ops.res.mjs";
5
+
6
+ globalThis.describe("Upload_Claim_S3_Ops.tableNameFromEventSourceArn", () => {
7
+ globalThis.test("reads the table name out of a stream ARN", () => {
8
+ globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.tableNameFromEventSourceArn("arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-a1b2c3/stream/2026-08-02T00:00:00.000")).toEqual("CatalogDcbEventLog-a1b2c3");
9
+ });
10
+ globalThis.test("returns None for an ARN that is not a table stream", () => {
11
+ globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.tableNameFromEventSourceArn("arn:aws:sqs:eu-west-1:123456789012:SomeQueue")).toEqual(undefined);
12
+ });
13
+ });
14
+
15
+ globalThis.describe("Upload_Claim_S3_Ops.keyOfRef", () => {
16
+ globalThis.test("strips the leading slash the mint side writes", () => {
17
+ globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.keyOfRef("/Catalog/productImages/sub/uuid/p.png")).toBe("Catalog/productImages/sub/uuid/p.png");
18
+ });
19
+ globalThis.test("leaves an already-bare key alone", () => {
20
+ globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.keyOfRef("Catalog/productImages/sub/uuid/p.png")).toBe("Catalog/productImages/sub/uuid/p.png");
21
+ });
22
+ });
23
+
24
+ globalThis.describe("Upload_Claim_S3_Ops.refsOfField", () => {
25
+ let single = {
26
+ field: "imageUrl",
27
+ many: false,
28
+ store: "Catalog.productImages"
29
+ };
30
+ let multi = {
31
+ field: "imageUrls",
32
+ many: true,
33
+ store: "Catalog.productImages"
34
+ };
35
+ let ref1 = "/Catalog/productImages/sub/one/p.png";
36
+ let ref2 = "/Catalog/productImages/sub/two/q.png";
37
+ globalThis.test("reads one ref from a string field", () => {
38
+ globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.refsOfField(Object.fromEntries([[
39
+ "imageUrl",
40
+ ref1
41
+ ]]), single)).toEqual([ref1]);
42
+ });
43
+ globalThis.test("reads every ref from an array field", () => {
44
+ globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.refsOfField(Object.fromEntries([[
45
+ "imageUrls",
46
+ [
47
+ ref1,
48
+ ref2
49
+ ]
50
+ ]]), multi)).toEqual([
51
+ ref1,
52
+ ref2
53
+ ]);
54
+ });
55
+ globalThis.test("ignores the empty-string sentinel", () => {
56
+ globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.refsOfField(Object.fromEntries([[
57
+ "imageUrl",
58
+ ""
59
+ ]]), single)).toEqual([]);
60
+ });
61
+ globalThis.test("ignores an absent field", () => {
62
+ globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.refsOfField({}, single)).toEqual([]);
63
+ });
64
+ globalThis.test("a single-arity field ignores an array value", () => {
65
+ globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.refsOfField(Object.fromEntries([[
66
+ "imageUrl",
67
+ [ref1]
68
+ ]]), single)).toEqual([]);
69
+ });
70
+ globalThis.test("a multi-arity field ignores a bare string value", () => {
71
+ globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.refsOfField(Object.fromEntries([[
72
+ "imageUrls",
73
+ ref1
74
+ ]]), multi)).toEqual([]);
75
+ });
76
+ globalThis.test("ignores non-string elements of an array field", () => {
77
+ globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.refsOfField(Object.fromEntries([[
78
+ "imageUrls",
79
+ [
80
+ ref1,
81
+ 7
82
+ ]
83
+ ]]), multi)).toEqual([ref1]);
84
+ });
85
+ });
86
+
87
+ globalThis.describe("Upload_Claim_S3_Ops.claim", () => {
88
+ globalThis.test("removes the same tag the mint side writes", () => {
89
+ globalThis.expect(Upload_PendingTag$ReventlessAws.key).toBe("reventless:pending");
90
+ });
91
+ globalThis.test("the presign tagging parameter decodes to that key and value", () => {
92
+ globalThis.expect(Upload_PendingTag$ReventlessAws.putObjectTagging).toBe("reventless%3Apending=true");
93
+ });
94
+ });
95
+
96
+ let Ops;
97
+
98
+ export {
99
+ Ops,
100
+ }
101
+ /* Not a pure module */