@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 +20 -0
- package/package.json +6 -5
- package/rescript.json +2 -1
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +12 -6
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs +17 -5
- package/src/adapter/Postgres/PgConnection.res +169 -0
- package/src/adapter/Postgres/PgConnection.res.mjs +112 -0
- package/src/adapter/Postgres/PgRuntime.res +71 -0
- package/src/adapter/Postgres/PgRuntime.res.mjs +71 -0
- package/src/util/Util_AppSync_Caller.res +24 -4
- package/src/util/Util_AppSync_Caller.res.mjs +7 -4
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +7 -3
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +18 -3
- package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res +2 -1
- package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res.mjs +2 -2
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.
|
|
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-
|
|
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.
|
|
18
|
-
"@reventlessdev/
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
`
|
|
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 (
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|