@reventlessdev/reventless-aws 3.0.0-alpha.177 → 3.0.0-alpha.178

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 (68) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/docker-compose.postgres.yml +21 -0
  3. package/package.json +6 -5
  4. package/scripts/run-pg-integration-tests.sh +58 -0
  5. package/src/Platform.res +37 -0
  6. package/src/Platform.res.mjs +41 -4
  7. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +7 -1
  8. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +10 -1
  9. package/src/adapter/Api/Platform_UIFragments_Lambda.res +7 -1
  10. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +10 -1
  11. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +6 -21
  12. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res.mjs +2 -17
  13. package/src/adapter/DcbEventLog/DcbBackend.res +4 -0
  14. package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res +2 -2
  15. package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res.mjs +7 -1
  16. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res +1 -11
  17. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res.mjs +2 -24
  18. package/src/adapter/Postgres/PgConnection.res +70 -10
  19. package/src/adapter/Postgres/PgConnection.res.mjs +48 -3
  20. package/src/adapter/Postgres/PgMigration_Builder.res +127 -0
  21. package/src/adapter/Postgres/PgMigration_Builder.res.mjs +51 -0
  22. package/src/adapter/QueryDb/PgQueryResolver_Builder.res +11 -13
  23. package/src/adapter/QueryDb/PgQueryResolver_Builder.res.mjs +5 -24
  24. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +51 -3
  25. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +48 -18
  26. package/src/adapter/QueryDb/QueryDbBackend.res +8 -0
  27. package/src/adapter/QueryDb/QueryDbBackend.res.mjs +4 -1
  28. package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +1 -1
  29. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +31 -7
  30. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +16 -10
  31. package/src/adapter/QueryDb/QueryDbStorage.res +6 -1
  32. package/src/adapter/QueryDb/QueryDbStorage.res.mjs +2 -1
  33. package/src/adapter/QueryDb/QueryDbStorage_Postgres.res.mjs +1 -1
  34. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +3 -14
  35. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +2 -25
  36. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +3 -14
  37. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +2 -25
  38. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +7 -1
  39. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +78 -15
  40. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +43 -26
  41. package/src/adapter/Runtime/PgMigrationEntryPoint.mjs +40 -0
  42. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +17 -5
  43. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +10 -0
  44. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +3 -0
  45. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +9 -14
  46. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +2 -25
  47. package/src/adapter/Runtime/StateTopicPublish.mjs +204 -0
  48. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +17 -3
  49. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +86 -17
  50. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +50 -33
  51. package/src/adapter/StateTopic/StateTopic_AppSync.res +7 -1
  52. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +10 -1
  53. package/src/plugin/cloner/ClonerRunner_Fargate.res +7 -1
  54. package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +10 -1
  55. package/src/util/Util_Bundle.res +71 -1
  56. package/src/util/Util_Bundle.res.mjs +49 -1
  57. package/tests/PgChangeFeedRelay_IntegrationTest.res +3 -3
  58. package/tests/PgConnectionJsonTest.res +84 -0
  59. package/tests/PgConnectionJsonTest.res.mjs +68 -0
  60. package/tests/PgMigrationEntryPoint_IntegrationTest.res +82 -0
  61. package/tests/PgMigrationEntryPoint_IntegrationTest.res.mjs +63 -0
  62. package/tests/PgPipeline_IntegrationTest.res +3 -1
  63. package/tests/PgQueryResolver_LambdaTest.res +59 -0
  64. package/tests/PgQueryResolver_LambdaTest.res.mjs +95 -16
  65. package/tests/StateTopicPublishTest.res +162 -0
  66. package/tests/StateTopicPublishTest.res.mjs +158 -0
  67. package/tests/Util_BundleEsmLoaderTest.res +57 -0
  68. package/tests/Util_BundleEsmLoaderTest.res.mjs +44 -0
@@ -35,6 +35,32 @@ type connectionConfig = {
35
35
  secretArn: string,
36
36
  }
37
37
 
38
+ /** Serialize a resolved `connectionConfig` into the `pgConnection` JSON object
39
+ the runtime entry points read from `HANDLER_CONFIG` (and `PgRuntime.poolFor`
40
+ reconstructs). One shared producer so all seven deploy-time builders emit a
41
+ byte-identical shape — the field set is a cold-start contract with the entry
42
+ points, so a drift here silently breaks a Lambda. `~lockStrategy` is included
43
+ only on the DCB command path (the sole Postgres append that takes a lock);
44
+ omit it everywhere else. Kept pure (no Pulumi) so it is directly unit-testable. */
45
+ let connectionConfigToJson = (
46
+ ~lockStrategy: option<ReventlessPostgres.DcbEventLogStorage_Postgres.lockStrategy>=?,
47
+ cc: connectionConfig,
48
+ ): JSON.t => {
49
+ let fields = [
50
+ ("host", cc.host->JSON.Encode.string),
51
+ ("port", cc.port->Int.toFloat->JSON.Encode.float),
52
+ ("database", cc.database->JSON.Encode.string),
53
+ ("username", cc.username->JSON.Encode.string),
54
+ ("secretArn", cc.secretArn->JSON.Encode.string),
55
+ ]
56
+ switch lockStrategy {
57
+ | Some(#AdvisoryLocks) => fields->Array.push(("lockStrategy", "AdvisoryLocks"->JSON.Encode.string))
58
+ | Some(#RowLocks) => fields->Array.push(("lockStrategy", "RowLocks"->JSON.Encode.string))
59
+ | None => ()
60
+ }
61
+ fields->Dict.fromArray->JSON.Encode.object
62
+ }
63
+
38
64
  type t = {
39
65
  /** Deploy-time resources (currently the RDS instance) for dependency wiring —
40
66
  e.g. the A3 migration Lambda and the B-phase adapters order after these. */
@@ -46,6 +72,14 @@ type t = {
46
72
  /** The DB's private subnets, echoed back for the Lambda `vpcConfig` so
47
73
  handlers land in the same subnets as the database. */
48
74
  subnetIds: array<Pulumi.Input.t<string>>,
75
+ /** DCB append lock strategy (C2). `#AdvisoryLocks` (default) is
76
+ transaction-scoped and lowest-overhead when Lambdas hold a few long-lived
77
+ pools **directly** against RDS, but it **pins connections on RDS Proxy**
78
+ (advisory locks are session state Proxy cannot multiplex). Choose
79
+ `#RowLocks` when fronting the DB with RDS Proxy so the Proxy can share
80
+ connections across invocations. Threaded to the DCB command Lambda via
81
+ `DcbBackend`; classic EventLog and QueryDb appends take no such lock. */
82
+ lockStrategy: ReventlessPostgres.DcbEventLogStorage_Postgres.lockStrategy,
49
83
  }
50
84
 
51
85
  /** Postgres wire port. */
@@ -65,8 +99,13 @@ let make = (
65
99
  ~backupRetentionPeriod=7,
66
100
  ~deletionProtection=true,
67
101
  ~skipFinalSnapshot=false,
102
+ ~lockStrategy: ReventlessPostgres.DcbEventLogStorage_Postgres.lockStrategy=#AdvisoryLocks,
68
103
  ~opts: option<Pulumi.ComponentResource.options>=?,
69
104
  ): t => {
105
+ // Keep the caller's ComponentResource.options for sub-builders that take it
106
+ // directly (PgMigration_Builder → RuntimeEnvironment_Lambda); the RDS/SG/subnet
107
+ // resources below want the CustomResourceOptions form.
108
+ let componentOpts = opts
70
109
  let opts =
71
110
  opts->Option.map(ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions)
72
111
 
@@ -151,19 +190,40 @@ let make = (
151
190
  },
152
191
  })
153
192
 
193
+ // A3: run PgSchema.ensureSchema against the fresh instance via a one-shot
194
+ // in-VPC migration Lambda invoked during `pulumi up`. Its resources join the
195
+ // instance's so the B-phase storage adapters can order after the schema exists.
196
+ // The connection is passed as pre-serialized pieces to avoid a module cycle
197
+ // (PgMigration_Builder must not reference this module's `connectionConfig`).
198
+ let migrationHandlerConfig = connectionConfig->Pulumi.Output.apply(cc =>
199
+ `{"pgConnection":${cc->connectionConfigToJson->JSON.stringify}}`
200
+ )
201
+ let migrationResources = PgMigration_Builder.make(
202
+ ~name=`${name}-pg-migrate`,
203
+ ~handlerConfig=migrationHandlerConfig,
204
+ ~secretArn=connectionConfig->Pulumi.Output.apply(cc => cc.secretArn),
205
+ ~securityGroupId=sg.id,
206
+ ~subnetIds,
207
+ ~opts=?componentOpts,
208
+ )
209
+
154
210
  {
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
- ],
211
+ resources: Array.concat(
212
+ [
213
+ ReventlessInfra.Adapter.make(
214
+ ~name=name->Pulumi.Output.make,
215
+ ~id=instance.id,
216
+ ~urn=instance.arn,
217
+ ~service="aws:rds"->Pulumi.Output.make,
218
+ ~role="postgres"->Pulumi.Output.make,
219
+ ~resourceType="aws:rds:Instance"->Pulumi.Output.make,
220
+ ),
221
+ ],
222
+ migrationResources,
223
+ ),
165
224
  connectionConfig,
166
225
  securityGroupId: sg.id,
167
226
  subnetIds,
227
+ lockStrategy,
168
228
  }
169
229
  }
@@ -9,6 +9,7 @@ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js
9
9
  import * as Adapter$ReventlessInfra from "@reventlessdev/reventless-infra/src/adapter/Adapter.res.mjs";
10
10
  import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
11
11
  import * as EC2_SecurityGroup$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/EC2/EC2_SecurityGroup.res.mjs";
12
+ import * as PgMigration_Builder$ReventlessAws from "./PgMigration_Builder.res.mjs";
12
13
 
13
14
  let connectionConfigSchema = S.schema(s => ({
14
15
  host: s.m(S.string),
@@ -18,7 +19,46 @@ let connectionConfigSchema = S.schema(s => ({
18
19
  secretArn: s.m(S.string)
19
20
  }));
20
21
 
21
- function make(name, vpcId, subnetIds, databaseNameOpt, usernameOpt, engineVersionOpt, instanceClassOpt, allocatedStorageOpt, multiAzOpt, storageEncryptedOpt, backupRetentionPeriodOpt, deletionProtectionOpt, skipFinalSnapshotOpt, opts) {
22
+ function connectionConfigToJson(lockStrategy, cc) {
23
+ let fields = [
24
+ [
25
+ "host",
26
+ cc.host
27
+ ],
28
+ [
29
+ "port",
30
+ cc.port
31
+ ],
32
+ [
33
+ "database",
34
+ cc.database
35
+ ],
36
+ [
37
+ "username",
38
+ cc.username
39
+ ],
40
+ [
41
+ "secretArn",
42
+ cc.secretArn
43
+ ]
44
+ ];
45
+ if (lockStrategy !== undefined) {
46
+ if (lockStrategy === "AdvisoryLocks") {
47
+ fields.push([
48
+ "lockStrategy",
49
+ "AdvisoryLocks"
50
+ ]);
51
+ } else {
52
+ fields.push([
53
+ "lockStrategy",
54
+ "RowLocks"
55
+ ]);
56
+ }
57
+ }
58
+ return Object.fromEntries(fields);
59
+ }
60
+
61
+ function make(name, vpcId, subnetIds, databaseNameOpt, usernameOpt, engineVersionOpt, instanceClassOpt, allocatedStorageOpt, multiAzOpt, storageEncryptedOpt, backupRetentionPeriodOpt, deletionProtectionOpt, skipFinalSnapshotOpt, lockStrategyOpt, opts) {
22
62
  let databaseName = databaseNameOpt !== undefined ? databaseNameOpt : "reventless";
23
63
  let username = usernameOpt !== undefined ? usernameOpt : "reventless_admin";
24
64
  let engineVersion = engineVersionOpt !== undefined ? engineVersionOpt : "16";
@@ -29,6 +69,7 @@ function make(name, vpcId, subnetIds, databaseNameOpt, usernameOpt, engineVersio
29
69
  let backupRetentionPeriod = backupRetentionPeriodOpt !== undefined ? backupRetentionPeriodOpt : 7;
30
70
  let deletionProtection = deletionProtectionOpt !== undefined ? deletionProtectionOpt : true;
31
71
  let skipFinalSnapshot = skipFinalSnapshotOpt !== undefined ? skipFinalSnapshotOpt : false;
72
+ let lockStrategy = lockStrategyOpt !== undefined ? lockStrategyOpt : "AdvisoryLocks";
32
73
  let opts$1 = Stdlib_Option.map(opts, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions);
33
74
  let tags = Object.fromEntries([
34
75
  [
@@ -94,11 +135,14 @@ function make(name, vpcId, subnetIds, databaseNameOpt, usernameOpt, engineVersio
94
135
  secretArn: secret !== undefined ? secret.secretArn : Stdlib_JsError.throwWithMessage("RDS instance exposes no master user secret — manageMasterUserPassword must be enabled")
95
136
  };
96
137
  });
138
+ let migrationHandlerConfig = connectionConfig.apply(cc => `{"pgConnection":` + JSON.stringify(connectionConfigToJson(undefined, cc)) + `}`);
139
+ let migrationResources = PgMigration_Builder$ReventlessAws.make(name + `-pg-migrate`, migrationHandlerConfig, connectionConfig.apply(cc => cc.secretArn), sg.id, subnetIds, opts);
97
140
  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)],
141
+ 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)].concat(migrationResources),
99
142
  connectionConfig: connectionConfig,
100
143
  securityGroupId: sg.id,
101
- subnetIds: subnetIds
144
+ subnetIds: subnetIds,
145
+ lockStrategy: lockStrategy
102
146
  };
103
147
  }
104
148
 
@@ -106,6 +150,7 @@ let port = 5432;
106
150
 
107
151
  export {
108
152
  connectionConfigSchema,
153
+ connectionConfigToJson,
109
154
  port,
110
155
  make,
111
156
  }
@@ -0,0 +1,127 @@
1
+ // Deploy-time builder for the one-shot Postgres schema migration (A3).
2
+ //
3
+ // Provisions an in-VPC Lambda whose only job is to run `PgSchema.ensureSchema`
4
+ // against the freshly-provisioned RDS instance, and an `aws.lambda.Invocation`
5
+ // resource that invokes it once during `pulumi up`. Because the invocation runs
6
+ // the Lambda *inside* the DB's VPC, the deploy runner itself needs no network
7
+ // path to the private instance — the reason the plan (A3) chose a migration
8
+ // Lambda over a Pulumi `command`/dynamic resource that would have to connect
9
+ // from the runner.
10
+ //
11
+ // `PgConnection.make` calls this after creating the instance so every
12
+ // `PgConnection` provisions its own schema; the returned resources are appended
13
+ // to `PgConnection.t.resources` so the B-phase storage adapters order after the
14
+ // migration. `ensureSchema` is idempotent (all `IF NOT EXISTS`), so the
15
+ // invocation re-runs safely whenever the migration code or the Reventless Lambda
16
+ // layer (which ships the DDL) changes.
17
+
18
+ // Takes the connection as already-serialized pieces rather than the
19
+ // `PgConnection.connectionConfig` record: `PgConnection` calls this builder, so
20
+ // referencing its type here would form a module cycle. The caller passes:
21
+ // ~handlerConfig — the full HANDLER_CONFIG JSON (`{"pgConnection":{…}}`),
22
+ // ~secretArn — the DB's RDS-managed master secret ARN (for the IAM grant).
23
+ // ~securityGroupId: DB-access SG the migration Lambda attaches (PgConnection.securityGroupId).
24
+ // ~subnetIds: private subnets for the in-VPC migration Lambda (PgConnection.subnetIds).
25
+ let make = (
26
+ ~name: string,
27
+ ~handlerConfig: Pulumi.Output.t<string>,
28
+ ~secretArn: Pulumi.Output.t<string>,
29
+ ~securityGroupId: Pulumi.Output.t<string>,
30
+ ~subnetIds: array<Pulumi.Input.t<string>>,
31
+ ~opts: option<Pulumi.ComponentResource.options>=?,
32
+ ): array<ReventlessInfra.Adapter.resource> => {
33
+ open PulumiAws
34
+
35
+ let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
36
+ ~entryPointModule="@reventlessdev/reventless-aws/src/adapter/Runtime/PgMigrationEntryPoint.mjs",
37
+ ~packageDirs=Dict.make(),
38
+ )
39
+
40
+ let envVars = Dict.fromArray([("HANDLER_CONFIG", handlerConfig->Pulumi.Output.asInput)])
41
+
42
+ let vpcConfig =
43
+ securityGroupId
44
+ ->Pulumi.Output.apply(sgId =>
45
+ (
46
+ {
47
+ Lambda.Function.subnetIds: subnetIds->Pulumi.Input.make,
48
+ securityGroupIds: [sgId->Pulumi.Input.make]->Pulumi.Input.make,
49
+ }: Lambda.Function.vpcConfig
50
+ )
51
+ )
52
+ ->Pulumi.Output.asInput
53
+
54
+ let runtime = RuntimeEnvironment_Lambda.makeFromCodeAsset(
55
+ ~name,
56
+ ~code,
57
+ ~sourceCodeHash,
58
+ ~envVars,
59
+ ~vpcConfig,
60
+ ~opts?,
61
+ )
62
+
63
+ // IAM: GetSecretValue on the DB's RDS-managed master secret. Built outside an
64
+ // Output.apply so the RolePolicy is a top-level resource the invocation can
65
+ // `dependsOn` (invoke only after the Lambda can read the secret).
66
+ let rolePolicy = {
67
+ open PulumiAws.PolicyDocument
68
+ IAM.RolePolicy.make(
69
+ ~name=`${name}MigrationAccess`,
70
+ ~args={
71
+ policy: secretArn
72
+ ->Pulumi.Output.apply(arn =>
73
+ PulumiAws.PolicyDocument.make(
74
+ ~id=`${name}MigrationAccessPolicy`,
75
+ ~statements=[
76
+ {
77
+ sid: "AllowGetSecret",
78
+ effect: Allow,
79
+ actions: Action("secretsmanager:GetSecretValue"),
80
+ resources: Resource(arn),
81
+ },
82
+ ],
83
+ )->PulumiAws.PolicyDocument.toJsonString
84
+ )
85
+ ->Pulumi.Output.asInput,
86
+ role: runtime.parts.lambdaRole.id->Pulumi.Output.asInput,
87
+ },
88
+ )
89
+ }
90
+
91
+ // Re-invoke whenever the migration code or the Reventless layer (which ships
92
+ // PgSchema's DDL) changes; idempotent DDL makes an unchanged re-run a no-op.
93
+ let layerArn = PulumiAws.Lambda.reventlessLayerArn->Option.getOr("no-layer")
94
+ let inputJson =
95
+ [("trigger", `${sourceCodeHash}:${layerArn}`->JSON.Encode.string)]
96
+ ->Dict.fromArray
97
+ ->JSON.Encode.object
98
+ ->JSON.stringify
99
+
100
+ let invocation = Lambda.Invocation.make(
101
+ ~name=`${name}Migrate`,
102
+ ~args={
103
+ // Ordering: functionName ties to the Lambda, whose env depends on
104
+ // connectionConfig (the instance's resolved outputs), so the invocation
105
+ // waits for the DB to be available. dependsOn adds the secret-access policy.
106
+ functionName: runtime.parts.lambda->Pulumi.Output.flatMap(l => l.name)->Pulumi.Output.asInput,
107
+ input: inputJson->Pulumi.Input.make,
108
+ },
109
+ ~opts={
110
+ dependsOn: [rolePolicy->Pulumi.Resource.makeFromJs]->Pulumi.Input.make,
111
+ },
112
+ )
113
+
114
+ Array.concat(
115
+ runtime.resources,
116
+ [
117
+ ReventlessInfra.Adapter.make(
118
+ ~name=`${name}Migration`->Pulumi.Output.make,
119
+ ~id=invocation.id,
120
+ ~urn=invocation.id,
121
+ ~service="aws:lambda"->Pulumi.Output.make,
122
+ ~role="postgres-migration"->Pulumi.Output.make,
123
+ ~resourceType="aws:lambda:Invocation"->Pulumi.Output.make,
124
+ ),
125
+ ],
126
+ )
127
+ }
@@ -0,0 +1,51 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Aws from "@pulumi/aws";
4
+ import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Pulumi from "@pulumi/pulumi";
7
+ import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
8
+ import * as Adapter$ReventlessInfra from "@reventlessdev/reventless-infra/src/adapter/Adapter.res.mjs";
9
+ import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
10
+ import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
11
+ import * as RuntimeEnvironment_Lambda$ReventlessAws from "../Runtime/RuntimeEnvironment_Lambda.res.mjs";
12
+
13
+ function make(name, handlerConfig, secretArn, securityGroupId, subnetIds, opts) {
14
+ let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Runtime/PgMigrationEntryPoint.mjs", {}, undefined);
15
+ let sourceCodeHash = match.sourceCodeHash;
16
+ let envVars = Object.fromEntries([[
17
+ "HANDLER_CONFIG",
18
+ handlerConfig
19
+ ]]);
20
+ let vpcConfig = securityGroupId.apply(sgId => ({
21
+ subnetIds: subnetIds,
22
+ securityGroupIds: [sgId]
23
+ }));
24
+ let runtime = RuntimeEnvironment_Lambda$ReventlessAws.makeFromCodeAsset(name, match.code, sourceCodeHash, envVars, undefined, undefined, undefined, undefined, undefined, undefined, vpcConfig, opts);
25
+ let rolePolicy = new (Aws.iam.RolePolicy)(name + `MigrationAccess`, {
26
+ policy: secretArn.apply(arn => PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + `MigrationAccessPolicy`, [{
27
+ Sid: "AllowGetSecret",
28
+ Effect: "Allow",
29
+ Action: "secretsmanager:GetSecretValue",
30
+ Resource: arn
31
+ }]))),
32
+ role: runtime.parts.lambdaRole.id
33
+ });
34
+ let layerArn = Stdlib_Option.getOr(Lambda$PulumiAws.reventlessLayerArn, "no-layer");
35
+ let inputJson = JSON.stringify(Object.fromEntries([[
36
+ "trigger",
37
+ sourceCodeHash + `:` + layerArn
38
+ ]]));
39
+ let invocation = new (Aws.lambda.Invocation)(name + `Migrate`, {
40
+ functionName: Output$Pulumi.flatMap(runtime.parts.lambda, l => l.name),
41
+ input: inputJson
42
+ }, {
43
+ dependsOn: [rolePolicy]
44
+ });
45
+ return runtime.resources.concat([Adapter$ReventlessInfra.make(Pulumi.output(name + `Migration`), invocation.id, invocation.id, Pulumi.output("aws:lambda"), undefined, Pulumi.output("postgres-migration"), undefined, Pulumi.output("aws:lambda:Invocation"), undefined, undefined)]);
46
+ }
47
+
48
+ export {
49
+ make,
50
+ }
51
+ /* @pulumi/aws Not a pure module */
@@ -81,21 +81,19 @@ export function response(ctx) {
81
81
  // Serialize the shared connection config into the env-config JSON (same shape
82
82
  // EventCollectorRuntime_Builder_Single bakes into HANDLER_CONFIG).
83
83
  let pgConnectionJson = (cc: PgConnection.connectionConfig): string =>
84
- [
85
- ("host", cc.host->JSON.Encode.string),
86
- ("port", cc.port->Int.toFloat->JSON.Encode.float),
87
- ("database", cc.database->JSON.Encode.string),
88
- ("username", cc.username->JSON.Encode.string),
89
- ("secretArn", cc.secretArn->JSON.Encode.string),
90
- ]
91
- ->Dict.fromArray
92
- ->JSON.Encode.object
93
- ->JSON.stringify
84
+ cc->PgConnection.connectionConfigToJson->JSON.stringify
94
85
 
95
86
  let provision = (
96
87
  ~api: Pulumi.Output.t<AppSync.GraphQLApi.t>,
97
88
  ~selection: QueryDbBackend.selection,
98
89
  ~opts: Pulumi.ComponentResource.options,
90
+ // The shared node(id) resolver is a single resolver on the API's Query.node
91
+ // field. In plugin-stack mode each plugin stack deploys independently onto one
92
+ // shared API, so only ONE stack may own that field — and a per-plugin node
93
+ // resolver only knows its own plugin's types anyway. So node is provisioned in
94
+ // monolithic mode only; plugin stacks pass false. (Node is not wired on the
95
+ // DynamoDB path either — it stays a monolithic-only capability for now.)
96
+ ~createNodeResolver: bool=true,
99
97
  ): unit => {
100
98
  // Join the resolver-binding registry (labelField/includeIdParam) with the
101
99
  // ReadModel runtime registry (specModulePath, pgBacked). Only read models
@@ -260,9 +258,9 @@ let provision = (
260
258
 
261
259
  // Shared node(id) resolver (B3.2c) — one for the whole API, dispatched by
262
260
  // typeName in the Lambda. Only when some entity registered a node type
263
- // (else the `node` field isn't in the schema). Mirrors NodeResolver_AppSync,
264
- // which likewise creates the node resolver directly at deploy time.
265
- if nodeTypes->Dict.toArray->Array.length > 0 {
261
+ // (else the `node` field isn't in the schema) AND in monolithic mode
262
+ // (plugin stacks would collide on the single Query.node field).
263
+ if createNodeResolver && nodeTypes->Dict.toArray->Array.length > 0 {
266
264
  let _nodeResolver = AppSync_Resolver_Retrying.makeUnitJsResolver(
267
265
  ~name=name ++ "NodeResolver",
268
266
  ~api,
@@ -11,6 +11,7 @@ import * as AWS$ReventlessAws from "../AWS.res.mjs";
11
11
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
12
12
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
13
13
  import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
14
+ import * as PgConnection$ReventlessAws from "../Postgres/PgConnection.res.mjs";
14
15
  import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
15
16
  import * as AppSync_Resolver_Retrying$ReventlessAws from "../Api/AppSync_Resolver_Retrying.res.mjs";
16
17
  import * as RuntimeEnvironment_Lambda$ReventlessAws from "../Runtime/RuntimeEnvironment_Lambda.res.mjs";
@@ -70,31 +71,11 @@ export function response(ctx) {
70
71
  `;
71
72
 
72
73
  function pgConnectionJson(cc) {
73
- return JSON.stringify(Object.fromEntries([
74
- [
75
- "host",
76
- cc.host
77
- ],
78
- [
79
- "port",
80
- cc.port
81
- ],
82
- [
83
- "database",
84
- cc.database
85
- ],
86
- [
87
- "username",
88
- cc.username
89
- ],
90
- [
91
- "secretArn",
92
- cc.secretArn
93
- ]
94
- ]));
74
+ return JSON.stringify(PgConnection$ReventlessAws.connectionConfigToJson(undefined, cc));
95
75
  }
96
76
 
97
- function provision(api, selection, opts) {
77
+ function provision(api, selection, opts, createNodeResolverOpt) {
78
+ let createNodeResolver = createNodeResolverOpt !== undefined ? createNodeResolverOpt : true;
98
79
  let handlers = Stdlib_Array.filterMap(Object.values(entries), entry => {
99
80
  let info = EventCollectorRuntime_Builder_Single$ReventlessAws.readModelInfos[entry.readModelName];
100
81
  if (info !== undefined && info.pgBacked) {
@@ -163,7 +144,7 @@ function provision(api, selection, opts) {
163
144
  },
164
145
  serviceRoleArn: dataSourceRole.arn
165
146
  }, customOpts);
166
- if (Object.entries(nodeTypes).length !== 0) {
147
+ if (createNodeResolver && Object.entries(nodeTypes).length !== 0) {
167
148
  AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name + "NodeResolver", api, dataSource.name, "Query", "node", nodeResolverCode, customOpts);
168
149
  }
169
150
  dataSource.name.apply(n => resolveDataSourceName.contents(n));
@@ -41,10 +41,22 @@ type payload = {
41
41
  targetIndex?: string,
42
42
  targetIndexIdField?: string,
43
43
  multi?: bool,
44
+ // Auth-table pipeline (B3.2c): a group-restricted index. Group members must
45
+ // own the resource (the auth table maps index value → {<group>Id: username});
46
+ // non-members pass through (mirrors the DynamoDB pipeline's earlyReturn).
47
+ authTable?: string,
48
+ authGroup?: string,
44
49
  arguments: JSON.t,
45
50
  identity: Reventless.Identity.t,
46
51
  }
47
52
 
53
+ // "Owner" → "ownerId" (auth-table owner field name, matching authorizeIndexedAccess).
54
+ let authIdField = (group: string): string =>
55
+ switch group->String.get(0) {
56
+ | Some(c) => c->String.toLowerCase ++ group->String.slice(~start=1, ~end=group->String.length) ++ "Id"
57
+ | None => "Id"
58
+ }
59
+
48
60
  // The Postgres query push-downs a binding needs (QueryEnginePostgres.Make
49
61
  // provides these; the mock in tests provides in-memory equivalents).
50
62
  type pushdowns = {
@@ -130,7 +142,14 @@ let runInterceptor = async (~binding, ~payload): ReventlessCore.QueryDb_Callback
130
142
  }
131
143
  }
132
144
 
133
- let dispatch = async (~binding: binding, ~payload: payload): JSON.t => {
145
+ // `lookupBinding` resolves another read model's binding by key needed only for
146
+ // the auth-table pipeline (the auth table is another read model, loaded by the
147
+ // index value). The handler passes the module-level registry; tests pass a mock.
148
+ let dispatch = async (
149
+ ~binding: binding,
150
+ ~lookupBinding: string => option<binding>=_ => None,
151
+ ~payload: payload,
152
+ ): JSON.t => {
134
153
  let rm = payload.readModelName
135
154
  switch await runInterceptor(~binding, ~payload) {
136
155
  | Deny(_) =>
@@ -183,7 +202,35 @@ let dispatch = async (~binding: binding, ~payload: payload): JSON.t => {
183
202
  ->Option.flatMap(ic => ic.idField)
184
203
  ->Option.getOr(indexName)
185
204
  let value = payload.arguments->argStr(indexName)->Option.getOr("")
186
- JSON.Encode.array(await binding.pushdowns.indexLookup(~readModelName=rm, field, value))
205
+ // Auth-table pipeline: a group-restricted index. Non-members pass through;
206
+ // members must own the resource (auth table maps index value →
207
+ // {<group>Id: username}). Auth table is another read model, loaded via the
208
+ // registry (unsupported → deny if it isn't Postgres-registered).
209
+ let authorized = switch (payload.authGroup, payload.authTable) {
210
+ | (Some(group), Some(authTable)) =>
211
+ if !Reventless.Identity.hasGroup(payload.identity, group) {
212
+ true
213
+ } else {
214
+ switch lookupBinding(authTable) {
215
+ | Some(authBinding) =>
216
+ switch await authBinding.ops.load(value) {
217
+ | Ok(items) =>
218
+ items
219
+ ->Array.get(0)
220
+ ->Option.flatMap(row => row->argStr(authIdField(group)))
221
+ ->Option.mapOr(false, owner => owner == payload.identity.username)
222
+ | Error(_) => false
223
+ }
224
+ | None => false
225
+ }
226
+ }
227
+ | _ => true
228
+ }
229
+ if authorized {
230
+ JSON.Encode.array(await binding.pushdowns.indexLookup(~readModelName=rm, field, value))
231
+ } else {
232
+ JSON.Encode.array([])
233
+ }
187
234
 
188
235
  | "list" =>
189
236
  let argsDict = payload.arguments->argObj
@@ -327,7 +374,8 @@ let handler = async (payload: payload, _context) => {
327
374
  | "node" => await handleNode(~payload)
328
375
  | _ =>
329
376
  switch bindings->Dict.get(bindingKey) {
330
- | Some(binding) => await dispatch(~binding, ~payload)
377
+ | Some(binding) =>
378
+ await dispatch(~binding, ~lookupBinding=name => bindings->Dict.get(name), ~payload)
331
379
  | None =>
332
380
  JsError.throwWithMessage("PgQueryResolver: no binding registered for read model " ++ bindingKey)
333
381
  }