@reventlessdev/reventless-aws 3.0.0-alpha.170 → 3.0.0-alpha.172

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,26 @@
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.172 (2026-07-05)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **reventless-aws:** sub-select CommandResult in AppSync mutation caller ([24cf37c](https://github.com/ReventlessDev/reventless-core/commit/24cf37cc537215b9523d1eac4a0f8ac6c610257d))
11
+ ### Features
12
+
13
+ * **postgres:** add reventless-postgres backend + local-platform integration ([6913200](https://github.com/ReventlessDev/reventless-core/commit/69132001f9271e832a5af33416acd5b645feaf47))
14
+ * **postgres:** cold-start pool foundation for AWS Postgres adapters ([b393449](https://github.com/ReventlessDev/reventless-core/commit/b393449769b6cd92abd03d2d5e7f564fe092938e))
15
+ * **reventless-aws:** add PgConnection component + wire reventless-postgres dep ([a403a62](https://github.com/ReventlessDev/reventless-core/commit/a403a6292381513cfe679a2f7a967fda0ab00c0e))
16
+
17
+
18
+ # 3.0.0-alpha.171 (2026-07-04)
19
+
20
+ **Note:** Version bump only for package @reventlessdev/reventless-aws
21
+
22
+
23
+
24
+
25
+
6
26
  # 3.0.0-alpha.170 (2026-07-03)
7
27
 
8
28
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.170",
3
+ "version": "3.0.0-alpha.172",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -9,13 +9,14 @@
9
9
  "sury": "11.0.0-alpha.4",
10
10
  "uuid": "^13.0.0",
11
11
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.20",
12
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.44",
12
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.24",
13
13
  "@reventlessdev/rescript-jest": "1.0.0-alpha.5",
14
14
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
15
- "@reventlessdev/rescript-effect": "0.1.0-alpha.24",
16
15
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.13",
17
- "@reventlessdev/reventless-core": "3.0.0-alpha.137",
18
- "@reventlessdev/reventless-infra": "3.0.0-alpha.84",
16
+ "@reventlessdev/reventless-core": "3.0.0-alpha.139",
17
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.45",
18
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.85",
19
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.1",
19
20
  "@reventlessdev/reventless-interop": "3.0.0-alpha.23",
20
21
  "@reventlessdev/reventless-spec": "3.0.0-alpha.64"
21
22
  },
package/rescript.json CHANGED
@@ -32,7 +32,8 @@
32
32
  "@reventlessdev/reventless-infra",
33
33
  "@reventlessdev/reventless-interop",
34
34
  "@reventlessdev/reventless-spec",
35
- "@reventlessdev/reventless-core"
35
+ "@reventlessdev/reventless-core",
36
+ "@reventlessdev/reventless-postgres"
36
37
  ],
37
38
  "compiler-flags": [],
38
39
  "suffix": ".res.mjs"
@@ -595,7 +595,7 @@ let read = (table: resolvedTable, ~crossPartitionTagKeys: array<string>=[]) =>
595
595
  //
596
596
  // All Puts and Updates ride a single `TransactWriteItems` call — atomic by
597
597
  // DynamoDB. Conflicts surface as `TransactionCanceledException` →
598
- // `DynamoDb_Error.StaleState` → `Error("Conflict: …")`.
598
+ // `DynamoDb_Error.StaleState` → `Error(DcbEventLog.Conflict)`.
599
599
 
600
600
  let fenceSortKey = "FENCE"
601
601
 
@@ -861,9 +861,9 @@ let runTransactWrite = async (
861
861
  ->Effect.map(_ => Ok(basePosition))
862
862
  ->Effect.catchAll(err =>
863
863
  switch err {
864
- | DynamoDb_Error.StaleState(msg) => Effect.succeed(Error(`Conflict: ${msg}`))
864
+ | DynamoDb_Error.StaleState(_msg) => Effect.succeed(Error(ReventlessInfra.DcbEventLog.Conflict))
865
865
  | Transient(msg) | Permanent(msg) =>
866
- Effect.succeed(Error(`${errorPrefix}: ${msg}`))
866
+ Effect.succeed(Error(ReventlessInfra.DcbEventLog.StorageFailure(`${errorPrefix}: ${msg}`)))
867
867
  }
868
868
  )
869
869
  ->Effect.runPromise
@@ -886,7 +886,9 @@ let appendUnconditional = async (
886
886
  Ok(generatePosition())
887
887
  } else if totalItems > transactWriteItemsLimit {
888
888
  Error(
889
- `DCB append: TransactWriteItems limit exceeded (${totalItems->Int.toString} > ${transactWriteItemsLimit->Int.toString}); reduce events or distinct tag values per command`,
889
+ ReventlessInfra.DcbEventLog.StorageFailure(
890
+ `DCB append: TransactWriteItems limit exceeded (${totalItems->Int.toString} > ${transactWriteItemsLimit->Int.toString}); reduce events or distinct tag values per command`,
891
+ ),
890
892
  )
891
893
  } else {
892
894
  let basePosition = generatePosition()
@@ -1211,7 +1213,9 @@ let appendConditional = async (
1211
1213
 
1212
1214
  if queryTags->Array.length == 0 {
1213
1215
  Error(
1214
- "DCB append: tagless conditions are not supported on DynamoDB — every queryItem must have at least one tag",
1216
+ ReventlessInfra.DcbEventLog.StorageFailure(
1217
+ "DCB append: tagless conditions are not supported on DynamoDB — every queryItem must have at least one tag",
1218
+ ),
1215
1219
  )
1216
1220
  } else {
1217
1221
  let basePosition = generatePosition()
@@ -1219,7 +1223,9 @@ let appendConditional = async (
1219
1223
  let totalItems = transactItems->Array.length
1220
1224
  if totalItems > transactWriteItemsLimit {
1221
1225
  Error(
1222
- `DCB append: TransactWriteItems limit exceeded (${totalItems->Int.toString} > ${transactWriteItemsLimit->Int.toString}); reduce events or distinct tag values per command`,
1226
+ ReventlessInfra.DcbEventLog.StorageFailure(
1227
+ `DCB append: TransactWriteItems limit exceeded (${totalItems->Int.toString} > ${transactWriteItemsLimit->Int.toString}); reduce events or distinct tag values per command`,
1228
+ ),
1223
1229
  )
1224
1230
  } else {
1225
1231
  let input: TransactWriteCommand.input = {transactItems: transactItems}
@@ -641,7 +641,7 @@ async function runTransactWrite(input, basePosition, errorPrefix) {
641
641
  case "StaleState" :
642
642
  return Effect$1.succeed({
643
643
  TAG: "Error",
644
- _0: `Conflict: ` + err._0
644
+ _0: "Conflict"
645
645
  });
646
646
  case "Transient" :
647
647
  case "Permanent" :
@@ -649,7 +649,10 @@ async function runTransactWrite(input, basePosition, errorPrefix) {
649
649
  }
650
650
  return Effect$1.succeed({
651
651
  TAG: "Error",
652
- _0: errorPrefix + `: ` + err._0
652
+ _0: {
653
+ TAG: "StorageFailure",
654
+ _0: errorPrefix + `: ` + err._0
655
+ }
653
656
  });
654
657
  }));
655
658
  }
@@ -666,7 +669,10 @@ async function appendUnconditional(table, events, partitionTag) {
666
669
  if (totalItems > 100) {
667
670
  return {
668
671
  TAG: "Error",
669
- _0: `DCB append: TransactWriteItems limit exceeded (` + totalItems.toString() + ` > ` + (100).toString() + `); reduce events or distinct tag values per command`
672
+ _0: {
673
+ TAG: "StorageFailure",
674
+ _0: `DCB append: TransactWriteItems limit exceeded (` + totalItems.toString() + ` > ` + (100).toString() + `); reduce events or distinct tag values per command`
675
+ }
670
676
  };
671
677
  }
672
678
  let basePosition = generatePosition();
@@ -908,7 +914,10 @@ async function appendConditional(table, events, cond, partitionTag, crossPartiti
908
914
  if (queryTags.length === 0) {
909
915
  return {
910
916
  TAG: "Error",
911
- _0: "DCB append: tagless conditions are not supported on DynamoDB — every queryItem must have at least one tag"
917
+ _0: {
918
+ TAG: "StorageFailure",
919
+ _0: "DCB append: tagless conditions are not supported on DynamoDB — every queryItem must have at least one tag"
920
+ }
912
921
  };
913
922
  }
914
923
  let basePosition = generatePosition();
@@ -917,7 +926,10 @@ async function appendConditional(table, events, cond, partitionTag, crossPartiti
917
926
  if (totalItems > 100) {
918
927
  return {
919
928
  TAG: "Error",
920
- _0: `DCB append: TransactWriteItems limit exceeded (` + totalItems.toString() + ` > ` + (100).toString() + `); reduce events or distinct tag values per command`
929
+ _0: {
930
+ TAG: "StorageFailure",
931
+ _0: `DCB append: TransactWriteItems limit exceeded (` + totalItems.toString() + ` > ` + (100).toString() + `); reduce events or distinct tag values per command`
932
+ }
921
933
  };
922
934
  }
923
935
  let input = {
@@ -0,0 +1,169 @@
1
+ /** Deploy-time component that provisions a managed Postgres instance (RDS) and
2
+ resolves the connection details the `reventless-postgres` runtime needs inside
3
+ a Lambda.
4
+
5
+ One `PgConnection` hosts **all three** storage surfaces (classic `event_log`,
6
+ DCB `dcb_event`, and the QueryDb `qdb_*` tables) in a single database — the
7
+ per-surface adapters take a reference to it, they do not each provision a DB.
8
+
9
+ Networking is supplied by the platform (an existing VPC + private subnets); a
10
+ `PgConnection` does not provision a VPC. It creates:
11
+ - a **shared security group** with a self-referencing `5432` ingress rule, so
12
+ every Lambda that attaches this same SG (see C1 / `securityGroupId`) can
13
+ reach the DB without a separate client SG;
14
+ - a **DB subnet group** over the given private subnets;
15
+ - the **RDS instance** with `manageMasterUserPassword` — RDS mints and rotates
16
+ the master credentials in Secrets Manager, so no plaintext password ever
17
+ lands in Pulumi state. The resulting secret ARN surfaces on
18
+ `connectionConfig.secretArn`; the runtime resolves it at cold start.
19
+
20
+ Aurora / Aurora Serverless v2 is a planned second engine behind the same
21
+ `connectionConfig` output shape (tracked in the AWS-Postgres plan). */
22
+
23
+ /** Resolved at deploy time, serialized into the handler Lambda env, and consumed
24
+ at cold start to build a `PgDriver` pool. `secretArn` points at the
25
+ RDS-managed `{username, password}` secret; `host`/`port`/`database` come from
26
+ the instance itself. */
27
+ @schema
28
+ type connectionConfig = {
29
+ host: string,
30
+ port: int,
31
+ database: string,
32
+ /** RDS master username — set at deploy time, not secret. The pool's `user`;
33
+ the Secrets Manager secret supplies only the matching password. */
34
+ username: string,
35
+ secretArn: string,
36
+ }
37
+
38
+ type t = {
39
+ /** Deploy-time resources (currently the RDS instance) for dependency wiring —
40
+ e.g. the A3 migration Lambda and the B-phase adapters order after these. */
41
+ resources: array<ReventlessInfra.Adapter.resource>,
42
+ connectionConfig: Pulumi.Output.t<connectionConfig>,
43
+ /** Attach to every Lambda that talks to Postgres: the SG's self-referencing
44
+ rule is what lets those Lambdas reach the DB on 5432. */
45
+ securityGroupId: Pulumi.Output.t<string>,
46
+ /** The DB's private subnets, echoed back for the Lambda `vpcConfig` so
47
+ handlers land in the same subnets as the database. */
48
+ subnetIds: array<Pulumi.Input.t<string>>,
49
+ }
50
+
51
+ /** Postgres wire port. */
52
+ let port = 5432
53
+
54
+ let make = (
55
+ ~name,
56
+ ~vpcId: Pulumi.Input.t<string>,
57
+ ~subnetIds: array<Pulumi.Input.t<string>>,
58
+ ~databaseName="reventless",
59
+ ~username="reventless_admin",
60
+ ~engineVersion="16",
61
+ ~instanceClass="db.t3.micro",
62
+ ~allocatedStorage=20,
63
+ ~multiAz=false,
64
+ ~storageEncrypted=true,
65
+ ~backupRetentionPeriod=7,
66
+ ~deletionProtection=true,
67
+ ~skipFinalSnapshot=false,
68
+ ~opts: option<Pulumi.ComponentResource.options>=?,
69
+ ): t => {
70
+ let opts =
71
+ opts->Option.map(ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions)
72
+
73
+ let tags =
74
+ [
75
+ ("Name", name),
76
+ ("Environment", Pulumi.Pulumi.getStackName()),
77
+ ("reventless:role", "postgres-connection"),
78
+ ]->Dict.fromArray
79
+
80
+ // Shared DB-access security group. Self-referencing 5432 ingress lets member
81
+ // Lambdas reach the DB; open egress so both the DB and those Lambdas can make
82
+ // outbound calls (e.g. Secrets Manager, other AWS APIs).
83
+ let sg = PulumiAws.EC2.SecurityGroup.make(
84
+ ~name=`${name}-pg-sg`,
85
+ ~args={
86
+ name: `${name}-pg-sg`,
87
+ vpcId,
88
+ ingress: [
89
+ {
90
+ fromPort: port,
91
+ protocol: "tcp",
92
+ toPort: port,
93
+ cidrBlocks: [],
94
+ self: true,
95
+ },
96
+ ],
97
+ egress: [PulumiAws.EC2.SecurityGroup.Egress.allowAll],
98
+ tags,
99
+ },
100
+ ~opts?,
101
+ )
102
+
103
+ let subnetGroup = PulumiAws.Rds.SubnetGroup.make(
104
+ ~name=`${name}-pg-subnets`,
105
+ ~args={
106
+ name: `${name}-pg-subnets`->Pulumi.Input.make,
107
+ subnetIds: subnetIds->Pulumi.Input.make,
108
+ tags: tags->Pulumi.Input.make,
109
+ },
110
+ ~opts?,
111
+ )
112
+
113
+ let instance = PulumiAws.Rds.Instance.make(
114
+ ~name=`${name}-pg`,
115
+ ~args={
116
+ engine: "postgres"->Pulumi.Input.make,
117
+ engineVersion: engineVersion->Pulumi.Input.make,
118
+ instanceClass: instanceClass->Pulumi.Input.make,
119
+ allocatedStorage: allocatedStorage->Pulumi.Input.make,
120
+ dbName: databaseName->Pulumi.Input.make,
121
+ username: username->Pulumi.Input.make,
122
+ manageMasterUserPassword: true->Pulumi.Input.make,
123
+ dbSubnetGroupName: subnetGroup.name->Pulumi.Output.asInput,
124
+ vpcSecurityGroupIds: [sg.id->Pulumi.Output.asInput]->Pulumi.Input.make,
125
+ port: port->Pulumi.Input.make,
126
+ multiAz: multiAz->Pulumi.Input.make,
127
+ publiclyAccessible: false->Pulumi.Input.make,
128
+ storageEncrypted: storageEncrypted->Pulumi.Input.make,
129
+ backupRetentionPeriod: backupRetentionPeriod->Pulumi.Input.make,
130
+ deletionProtection: deletionProtection->Pulumi.Input.make,
131
+ skipFinalSnapshot: skipFinalSnapshot->Pulumi.Input.make,
132
+ tags: tags->Pulumi.Input.make,
133
+ },
134
+ ~opts?,
135
+ )
136
+
137
+ let connectionConfig =
138
+ (instance.address, instance.masterUserSecrets)
139
+ ->Pulumi.Output.all2
140
+ ->Pulumi.Output.apply(((host, secrets)) => {
141
+ host,
142
+ port,
143
+ database: databaseName,
144
+ username,
145
+ secretArn: switch secrets->Array.get(0) {
146
+ | Some(secret) => secret.secretArn
147
+ | None =>
148
+ JsError.throwWithMessage(
149
+ "RDS instance exposes no master user secret — manageMasterUserPassword must be enabled",
150
+ )
151
+ },
152
+ })
153
+
154
+ {
155
+ resources: [
156
+ ReventlessInfra.Adapter.make(
157
+ ~name=name->Pulumi.Output.make,
158
+ ~id=instance.id,
159
+ ~urn=instance.arn,
160
+ ~service="aws:rds"->Pulumi.Output.make,
161
+ ~role="postgres"->Pulumi.Output.make,
162
+ ~resourceType="aws:rds:Instance"->Pulumi.Output.make,
163
+ ),
164
+ ],
165
+ connectionConfig,
166
+ securityGroupId: sg.id,
167
+ subnetIds,
168
+ }
169
+ }
@@ -0,0 +1,112 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
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 Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
8
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
9
+ import * as Adapter$ReventlessInfra from "@reventlessdev/reventless-infra/src/adapter/Adapter.res.mjs";
10
+ import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
11
+ import * as EC2_SecurityGroup$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/EC2/EC2_SecurityGroup.res.mjs";
12
+
13
+ let connectionConfigSchema = S.schema(s => ({
14
+ host: s.m(S.string),
15
+ port: s.m(S.int),
16
+ database: s.m(S.string),
17
+ username: s.m(S.string),
18
+ secretArn: s.m(S.string)
19
+ }));
20
+
21
+ function make(name, vpcId, subnetIds, databaseNameOpt, usernameOpt, engineVersionOpt, instanceClassOpt, allocatedStorageOpt, multiAzOpt, storageEncryptedOpt, backupRetentionPeriodOpt, deletionProtectionOpt, skipFinalSnapshotOpt, opts) {
22
+ let databaseName = databaseNameOpt !== undefined ? databaseNameOpt : "reventless";
23
+ let username = usernameOpt !== undefined ? usernameOpt : "reventless_admin";
24
+ let engineVersion = engineVersionOpt !== undefined ? engineVersionOpt : "16";
25
+ let instanceClass = instanceClassOpt !== undefined ? instanceClassOpt : "db.t3.micro";
26
+ let allocatedStorage = allocatedStorageOpt !== undefined ? allocatedStorageOpt : 20;
27
+ let multiAz = multiAzOpt !== undefined ? multiAzOpt : false;
28
+ let storageEncrypted = storageEncryptedOpt !== undefined ? storageEncryptedOpt : true;
29
+ let backupRetentionPeriod = backupRetentionPeriodOpt !== undefined ? backupRetentionPeriodOpt : 7;
30
+ let deletionProtection = deletionProtectionOpt !== undefined ? deletionProtectionOpt : true;
31
+ let skipFinalSnapshot = skipFinalSnapshotOpt !== undefined ? skipFinalSnapshotOpt : false;
32
+ let opts$1 = Stdlib_Option.map(opts, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions);
33
+ let tags = Object.fromEntries([
34
+ [
35
+ "Name",
36
+ name
37
+ ],
38
+ [
39
+ "Environment",
40
+ Pulumi.getStack()
41
+ ],
42
+ [
43
+ "reventless:role",
44
+ "postgres-connection"
45
+ ]
46
+ ]);
47
+ let sg = EC2_SecurityGroup$PulumiAws.make(name + `-pg-sg`, {
48
+ name: name + `-pg-sg`,
49
+ vpcId: vpcId,
50
+ ingress: [{
51
+ fromPort: 5432,
52
+ protocol: "tcp",
53
+ toPort: 5432,
54
+ cidrBlocks: [],
55
+ self: true
56
+ }],
57
+ egress: [EC2_SecurityGroup$PulumiAws.Egress.allowAll],
58
+ tags: tags
59
+ }, opts$1);
60
+ let subnetGroup = new (Aws.rds.SubnetGroup)(name + `-pg-subnets`, {
61
+ name: name + `-pg-subnets`,
62
+ subnetIds: subnetIds,
63
+ tags: tags
64
+ }, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
65
+ let instance = new (Aws.rds.Instance)(name + `-pg`, {
66
+ allocatedStorage: allocatedStorage,
67
+ engine: "postgres",
68
+ engineVersion: engineVersion,
69
+ instanceClass: instanceClass,
70
+ dbName: databaseName,
71
+ username: username,
72
+ manageMasterUserPassword: true,
73
+ dbSubnetGroupName: subnetGroup.name,
74
+ vpcSecurityGroupIds: [sg.id],
75
+ port: 5432,
76
+ multiAz: multiAz,
77
+ publiclyAccessible: false,
78
+ storageEncrypted: storageEncrypted,
79
+ backupRetentionPeriod: backupRetentionPeriod,
80
+ deletionProtection: deletionProtection,
81
+ skipFinalSnapshot: skipFinalSnapshot,
82
+ tags: tags
83
+ }, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
84
+ let connectionConfig = Pulumi.all([
85
+ instance.address,
86
+ instance.masterUserSecrets
87
+ ]).apply(param => {
88
+ let secret = param[1][0];
89
+ return {
90
+ host: param[0],
91
+ port: 5432,
92
+ database: databaseName,
93
+ username: username,
94
+ secretArn: secret !== undefined ? secret.secretArn : Stdlib_JsError.throwWithMessage("RDS instance exposes no master user secret — manageMasterUserPassword must be enabled")
95
+ };
96
+ });
97
+ return {
98
+ resources: [Adapter$ReventlessInfra.make(Pulumi.output(name), instance.id, instance.arn, Pulumi.output("aws:rds"), undefined, Pulumi.output("postgres"), undefined, Pulumi.output("aws:rds:Instance"), undefined, undefined)],
99
+ connectionConfig: connectionConfig,
100
+ securityGroupId: sg.id,
101
+ subnetIds: subnetIds
102
+ };
103
+ }
104
+
105
+ let port = 5432;
106
+
107
+ export {
108
+ connectionConfigSchema,
109
+ port,
110
+ make,
111
+ }
112
+ /* connectionConfigSchema Not a pure module */
@@ -0,0 +1,71 @@
1
+ /** Cold-start Postgres connectivity for deployed Lambdas.
2
+
3
+ Given a resolved `PgConnection.connectionConfig` (injected into the handler env
4
+ at deploy time), this resolves the RDS-managed master password from Secrets
5
+ Manager and builds **one pg pool per container**, memoized by secret ARN.
6
+
7
+ Rotation-safe by construction: the pool is created with a *password provider*,
8
+ which pg invokes each time it opens a physical connection — so a rotated secret
9
+ is picked up on the next new connection. The fetched secret is cached per ARN
10
+ (as a promise) to avoid a Secrets Manager round trip on every connection; a
11
+ password rotation surfaces as auth failures that recycle the container, after
12
+ which the next cold start re-fetches. */
13
+
14
+ /** RDS-managed master secret payload (`{username, password}` JSON). We only need
15
+ the password here — the username is carried on `connectionConfig` (deploy-time
16
+ known) so the pool can be constructed without first awaiting the secret. */
17
+ let passwordFromSecret = async (secretArn: string): string => {
18
+ let out =
19
+ await {AwsSdk.SecretsManager.GetSecretValueCommand.secretId: secretArn}
20
+ ->AwsSdk.SecretsManager.GetSecretValueCommand.make
21
+ ->AwsSdk.SecretsManager.GetSecretValueCommand.send
22
+ switch out.secretString {
23
+ | Some(str) =>
24
+ switch str->JSON.parseOrThrow->JSON.Decode.object {
25
+ | Some(obj) =>
26
+ switch obj->Dict.get("password")->Option.flatMap(JSON.Decode.string) {
27
+ | Some(password) => password
28
+ | None => JsError.throwWithMessage("Secrets Manager secret has no `password` field")
29
+ }
30
+ | None => JsError.throwWithMessage("Secrets Manager secret is not a JSON object")
31
+ }
32
+ | None =>
33
+ JsError.throwWithMessage(`Secrets Manager secret ${secretArn} has no SecretString`)
34
+ }
35
+ }
36
+
37
+ // Cache the in-flight/settled password fetch per ARN so pg's per-connection
38
+ // provider doesn't hit Secrets Manager on every physical connection.
39
+ let passwordCache: dict<promise<string>> = Dict.make()
40
+
41
+ let cachedPassword = (secretArn: string): promise<string> =>
42
+ switch passwordCache->Dict.get(secretArn) {
43
+ | Some(p) => p
44
+ | None =>
45
+ let p = passwordFromSecret(secretArn)
46
+ passwordCache->Dict.set(secretArn, p)
47
+ p
48
+ }
49
+
50
+ // One pool per secret ARN for the life of the container.
51
+ let poolCache: dict<ReventlessPostgres.PgDriver.pool> = Dict.make()
52
+
53
+ /** The container-lifetime pool for the given connection config, built on first
54
+ use. pg pools connect lazily, so this is cheap until the first query. */
55
+ let poolFor = (config: PgConnection.connectionConfig): ReventlessPostgres.PgDriver.pool =>
56
+ switch poolCache->Dict.get(config.secretArn) {
57
+ | Some(pool) => pool
58
+ | None =>
59
+ let pool = ReventlessPostgres.PgDriver.makePool({
60
+ host: config.host,
61
+ port: config.port,
62
+ database: config.database,
63
+ user: config.username,
64
+ password: () => cachedPassword(config.secretArn),
65
+ // RDS PG15+ defaults to rds.force_ssl=1. Encrypt without CA verification
66
+ // for v1; pin the RDS CA bundle as a hardening follow-up.
67
+ ssl: {rejectUnauthorized: false},
68
+ })
69
+ poolCache->Dict.set(config.secretArn, pool)
70
+ pool
71
+ }
@@ -0,0 +1,71 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
6
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
7
+ import * as SecretsManager$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/SecretsManager.res.mjs";
8
+ import * as PgDriver$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/PgDriver.res.mjs";
9
+ import * as ClientSecretsManager from "@aws-sdk/client-secrets-manager";
10
+
11
+ async function passwordFromSecret(secretArn) {
12
+ let out = await SecretsManager$AwsSdk.GetSecretValueCommand.send(new ClientSecretsManager.GetSecretValueCommand({
13
+ SecretId: secretArn
14
+ }));
15
+ let str = out.SecretString;
16
+ if (str === undefined) {
17
+ return Stdlib_JsError.throwWithMessage(`Secrets Manager secret ` + secretArn + ` has no SecretString`);
18
+ }
19
+ let obj = Stdlib_JSON.Decode.object(JSON.parse(str));
20
+ if (obj === undefined) {
21
+ return Stdlib_JsError.throwWithMessage("Secrets Manager secret is not a JSON object");
22
+ }
23
+ let password = Stdlib_Option.flatMap(obj["password"], Stdlib_JSON.Decode.string);
24
+ if (password !== undefined) {
25
+ return password;
26
+ } else {
27
+ return Stdlib_JsError.throwWithMessage("Secrets Manager secret has no `password` field");
28
+ }
29
+ }
30
+
31
+ let passwordCache = {};
32
+
33
+ function cachedPassword(secretArn) {
34
+ let p = passwordCache[secretArn];
35
+ if (p !== undefined) {
36
+ return p;
37
+ }
38
+ let p$1 = passwordFromSecret(secretArn);
39
+ passwordCache[secretArn] = p$1;
40
+ return p$1;
41
+ }
42
+
43
+ let poolCache = {};
44
+
45
+ function poolFor(config) {
46
+ let pool = poolCache[config.secretArn];
47
+ if (pool !== undefined) {
48
+ return Primitive_option.valFromOption(pool);
49
+ }
50
+ let pool$1 = PgDriver$ReventlessPostgres.makePool({
51
+ host: config.host,
52
+ port: config.port,
53
+ database: config.database,
54
+ user: config.username,
55
+ password: () => cachedPassword(config.secretArn),
56
+ ssl: {
57
+ rejectUnauthorized: false
58
+ }
59
+ });
60
+ poolCache[config.secretArn] = pool$1;
61
+ return pool$1;
62
+ }
63
+
64
+ export {
65
+ passwordFromSecret,
66
+ passwordCache,
67
+ cachedPassword,
68
+ poolCache,
69
+ poolFor,
70
+ }
71
+ /* SecretsManager-AwsSdk Not a pure module */
@@ -107,13 +107,25 @@ let rec jsonToLiteral = (value: JSON.t): string =>
107
107
  }
108
108
  }
109
109
 
110
- let buildQuery = (~mutation: string, ~variablesDict: dict<JSON.t>): string => {
110
+ // ~selection is the sub-selection appended after the field call. It defaults to
111
+ // `{ __typename }` because every command mutation dispatched through this caller
112
+ // returns the `CommandResult` union (CommandAccepted | CommandPending |
113
+ // CommandRejected); GraphQL rejects a selectionless field on any object/union
114
+ // type with `SubSelectionRequired`. Pass ~selection="" for a scalar-returning
115
+ // mutation, or a richer set (e.g. "{ __typename ... on CommandRejected { reason } }")
116
+ // to inspect the result.
117
+ let buildQuery = (
118
+ ~mutation: string,
119
+ ~selection: string="{ __typename }",
120
+ ~variablesDict: dict<JSON.t>,
121
+ ): string => {
111
122
  let args =
112
123
  variablesDict
113
124
  ->Dict.toArray
114
125
  ->Array.map(((k, v)) => `${k}: ${jsonToLiteral(v)}`)
115
126
  ->Array.join(", ")
116
- `mutation { ${mutation}(${args}) }`
127
+ let sel = selection == "" ? "" : ` ${selection}`
128
+ `mutation { ${mutation}(${args})${sel} }`
117
129
  }
118
130
 
119
131
  // ── sendMutation ────────────────────────────────────────────────────────────
@@ -121,6 +133,8 @@ let buildQuery = (~mutation: string, ~variablesDict: dict<JSON.t>): string => {
121
133
  // Signs and sends a single GraphQL mutation to an AppSync endpoint via IAM.
122
134
  // ~variables accepts any value; undefined/optional fields are omitted by
123
135
  // JSON.stringifyAny (mirrors how JSON.stringify drops undefined object keys).
136
+ // ~selection defaults to `{ __typename }` so CommandResult-union mutations
137
+ // validate (see buildQuery); pass ~selection="" for scalar-returning mutations.
124
138
  // Returns a promise — callers fire-and-forget with `let _ = sendMutation(...)`.
125
139
 
126
140
  // Signs and sends a GraphQL query; returns the `data` object or None on error.
@@ -164,7 +178,13 @@ let sendQuery = async (~endpoint: string, ~region: string, ~queryString: string)
164
178
  }
165
179
  }
166
180
 
167
- let sendMutation = async (~endpoint: string, ~region: string, ~mutation: string, ~variables: 'a) => {
181
+ let sendMutation = async (
182
+ ~endpoint: string,
183
+ ~region: string,
184
+ ~mutation: string,
185
+ ~selection: string="{ __typename }",
186
+ ~variables: 'a,
187
+ ) => {
168
188
  let url = parseUrl(endpoint)
169
189
  let hostname: string = (url)["hostname"]
170
190
  let path: string = (url)["pathname"]
@@ -180,7 +200,7 @@ let sendMutation = async (~endpoint: string, ~region: string, ~mutation: string,
180
200
  | None => Dict.make()
181
201
  }
182
202
 
183
- let query = buildQuery(~mutation, ~variablesDict)
203
+ let query = buildQuery(~mutation, ~selection, ~variablesDict)
184
204
  let body =
185
205
  Dict.fromArray([("query", query->JSON.Encode.string)])
186
206
  ->JSON.Encode.object
@@ -47,9 +47,11 @@ function jsonToLiteral(value) {
47
47
  }
48
48
  }
49
49
 
50
- function buildQuery(mutation, variablesDict) {
50
+ function buildQuery(mutation, selectionOpt, variablesDict) {
51
+ let selection = selectionOpt !== undefined ? selectionOpt : "{ __typename }";
51
52
  let args = Object.entries(variablesDict).map(param => param[0] + `: ` + jsonToLiteral(param[1])).join(", ");
52
- return `mutation { ` + mutation + `(` + args + `) }`;
53
+ let sel = selection === "" ? "" : ` ` + selection;
54
+ return `mutation { ` + mutation + `(` + args + `)` + sel + ` }`;
53
55
  }
54
56
 
55
57
  async function sendQuery(endpoint, region, queryString) {
@@ -104,7 +106,8 @@ async function sendQuery(endpoint, region, queryString) {
104
106
  }
105
107
  }
106
108
 
107
- async function sendMutation(endpoint, region, mutation, variables) {
109
+ async function sendMutation(endpoint, region, mutation, selectionOpt, variables) {
110
+ let selection = selectionOpt !== undefined ? selectionOpt : "{ __typename }";
108
111
  let url = new URL(endpoint);
109
112
  let hostname = url.hostname;
110
113
  let path = url.pathname;
@@ -116,7 +119,7 @@ async function sendMutation(endpoint, region, mutation, variables) {
116
119
  } else {
117
120
  variablesDict = {};
118
121
  }
119
- let query = buildQuery(mutation, variablesDict);
122
+ let query = buildQuery(mutation, selection, variablesDict);
120
123
  let body = JSON.stringify(Object.fromEntries([[
121
124
  "query",
122
125
  query
@@ -316,7 +316,9 @@ describe("Runtime.appendUnconditional", () => {
316
316
  let events = manyTags->Array.map(t => event("Foo", [t]))
317
317
  let result = await Runtime.appendUnconditional(table, events)
318
318
  switch result {
319
- | Error(msg) => expect(msg->String.includes("limit exceeded"))->toBe(true)
319
+ | Error(ReventlessInfra.DcbEventLog.StorageFailure(msg)) =>
320
+ expect(msg->String.includes("limit exceeded"))->toBe(true)
321
+ | Error(Conflict) => expect("expected StorageFailure, got Conflict")->toBe("")
320
322
  | Ok(_) => expect("expected Error, got Ok")->toBe("")
321
323
  }
322
324
  })
@@ -555,8 +557,9 @@ describe("Runtime.appendConditional", () => {
555
557
  }
556
558
  let result = await Runtime.appendConditional(table, [], cond)
557
559
  switch result {
558
- | Error(msg) =>
560
+ | Error(ReventlessInfra.DcbEventLog.StorageFailure(msg)) =>
559
561
  expect(msg->String.includes("tagless"))->toBe(true)
562
+ | Error(Conflict) => expect("expected StorageFailure, got Conflict")->toBe("")
560
563
  | Ok(_) => expect("expected Error, got Ok")->toBe("")
561
564
  }
562
565
  })
@@ -579,8 +582,9 @@ describe("Runtime.appendConditional", () => {
579
582
  }
580
583
  let result = await Runtime.appendConditional(table, [event], cond)
581
584
  switch result {
582
- | Error(msg) =>
585
+ | Error(ReventlessInfra.DcbEventLog.StorageFailure(msg)) =>
583
586
  expect(msg->String.includes("limit exceeded"))->toBe(true)
587
+ | Error(Conflict) => expect("expected StorageFailure, got Conflict")->toBe("")
584
588
  | Ok(_) => expect("expected Error, got Ok")->toBe("")
585
589
  }
586
590
  })
@@ -363,7 +363,12 @@ globalThis.describe("Runtime.appendUnconditional", () => {
363
363
  globalThis.expect("expected Error, got Ok").toBe("");
364
364
  return;
365
365
  }
366
- globalThis.expect(result._0.includes("limit exceeded")).toBe(true);
366
+ let msg = result._0;
367
+ if (typeof msg !== "object") {
368
+ globalThis.expect("expected StorageFailure, got Conflict").toBe("");
369
+ return;
370
+ }
371
+ globalThis.expect(msg._0.includes("limit exceeded")).toBe(true);
367
372
  });
368
373
  });
369
374
 
@@ -683,7 +688,12 @@ globalThis.describe("Runtime.appendConditional", () => {
683
688
  globalThis.expect("expected Error, got Ok").toBe("");
684
689
  return;
685
690
  }
686
- globalThis.expect(result._0.includes("tagless")).toBe(true);
691
+ let msg = result._0;
692
+ if (typeof msg !== "object") {
693
+ globalThis.expect("expected StorageFailure, got Conflict").toBe("");
694
+ return;
695
+ }
696
+ globalThis.expect(msg._0.includes("tagless")).toBe(true);
687
697
  });
688
698
  globalThis.test("rejects appends exceeding 100 items with a clear error", async () => {
689
699
  let manyTags = Stdlib_Array.fromInitializer(100, i => ({
@@ -716,7 +726,12 @@ globalThis.describe("Runtime.appendConditional", () => {
716
726
  globalThis.expect("expected Error, got Ok").toBe("");
717
727
  return;
718
728
  }
719
- globalThis.expect(result._0.includes("limit exceeded")).toBe(true);
729
+ let msg = result._0;
730
+ if (typeof msg !== "object") {
731
+ globalThis.expect("expected StorageFailure, got Conflict").toBe("");
732
+ return;
733
+ }
734
+ globalThis.expect(msg._0.includes("limit exceeded")).toBe(true);
720
735
  });
721
736
  });
722
737
 
@@ -44,7 +44,8 @@ let isOk = r =>
44
44
 
45
45
  let isConflict = r =>
46
46
  switch r {
47
- | Error(msg) => msg->String.includes("Conflict")
47
+ | Error(ReventlessInfra.DcbEventLog.Conflict) => true
48
+ | Error(StorageFailure(_)) => false
48
49
  | Ok(_) => false
49
50
  }
50
51
 
@@ -41,9 +41,9 @@ function isOk(r) {
41
41
  function isConflict(r) {
42
42
  if (r.TAG === "Ok") {
43
43
  return false;
44
- } else {
45
- return r._0.includes("Conflict");
46
44
  }
45
+ let tmp = r._0;
46
+ return typeof tmp !== "object";
47
47
  }
48
48
 
49
49
  async function readAfter(table, query) {