@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
@@ -11,9 +11,11 @@ import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/co
11
11
  import * as DcbBackend$ReventlessAws from "../DcbEventLog/DcbBackend.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 QueryDbBackend$ReventlessAws from "../QueryDb/QueryDbBackend.res.mjs";
16
17
  import * as EventLogBackend$ReventlessAws from "../EventLog/EventLogBackend.res.mjs";
18
+ import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
17
19
  import * as PgProjectionFeed$ReventlessAws from "../Postgres/PgProjectionFeed.res.mjs";
18
20
  import * as RuntimeEnvironment_Lambda$ReventlessAws from "./RuntimeEnvironment_Lambda.res.mjs";
19
21
  import * as EventCollectorChannel_DynamoDbStream$ReventlessAws from "../EventCollector/EventCollectorChannel_DynamoDbStream.res.mjs";
@@ -22,6 +24,14 @@ let log = Logger$ReventlessCore.fromEnv();
22
24
 
23
25
  let readModelInfos = {};
24
26
 
27
+ let eventsApiConfig = {
28
+ contents: undefined
29
+ };
30
+
31
+ function setEventsApiConfig(cfg) {
32
+ eventsApiConfig.contents = cfg;
33
+ }
34
+
25
35
  function registerReadModel(readModelName, specModulePath, mappingsModulePath, queryDbTableName, pgBackedOpt) {
26
36
  let pgBacked = pgBackedOpt !== undefined ? pgBackedOpt : false;
27
37
  readModelInfos[readModelName] = {
@@ -95,31 +105,7 @@ function finish() {
95
105
  let feedArnOutput = feedQueue !== undefined ? feedQueue.arn : Pulumi.output("");
96
106
  let qdbSelection = QueryDbBackend$ReventlessAws.get();
97
107
  let anyPgBacked = Object.values(readModelInfos).some(info => info.pgBacked);
98
- let pgConnectionFragment = qdbSelection !== undefined && anyPgBacked ? qdbSelection.connectionConfig.apply(cc => {
99
- let pgConnectionJson = JSON.stringify(Object.fromEntries([
100
- [
101
- "host",
102
- cc.host
103
- ],
104
- [
105
- "port",
106
- cc.port
107
- ],
108
- [
109
- "database",
110
- cc.database
111
- ],
112
- [
113
- "username",
114
- cc.username
115
- ],
116
- [
117
- "secretArn",
118
- cc.secretArn
119
- ]
120
- ]));
121
- return `,"pgConnection":` + pgConnectionJson;
122
- }) : Pulumi.output("");
108
+ let pgConnectionFragment = qdbSelection !== undefined && anyPgBacked ? qdbSelection.connectionConfig.apply(cc => `,"pgConnection":` + JSON.stringify(PgConnection$ReventlessAws.connectionConfigToJson(undefined, cc))) : Pulumi.output("");
123
109
  let handlerOutputs = [];
124
110
  let packageDirs = {};
125
111
  let sortedSpecs = storedSpecs.toSorted((a, b) => Primitive_string.compare(a.componentName, b.componentName));
@@ -135,6 +121,13 @@ function finish() {
135
121
  let specModule = Stdlib_Option.getOr(JSON.stringify(info.specModulePath), `""`);
136
122
  let mappingsModule = Stdlib_Option.getOr(JSON.stringify(info.mappingsModulePath), `""`);
137
123
  let handlerPgFragment = info.pgBacked ? pgConnectionFragment : Pulumi.output("");
124
+ let stateTopicFragment;
125
+ if (info.pgBacked && QueryDbBackend$ReventlessAws.postgresStreamRegistry.has(spec.componentName) && Stdlib_Option.isSome(eventsApiConfig.contents)) {
126
+ let topic = Stdlib_Option.getOr(Stdlib_Option.map(Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry[spec.componentName], qn => qn.listFieldName), spec.componentName);
127
+ stateTopicFragment = `,"stateTopicName":` + JSON.stringify(topic);
128
+ } else {
129
+ stateTopicFragment = "";
130
+ }
138
131
  let handlerJson = Pulumi.all([
139
132
  info.queryDbTableName,
140
133
  spec.sourceUrns,
@@ -142,13 +135,24 @@ function finish() {
142
135
  handlerPgFragment
143
136
  ]).apply(param => {
144
137
  let sourceUrn = Stdlib_Option.getOr(param[1][0], param[2]);
145
- return `{"specModule":` + specModule + `,"mappingsModule":` + mappingsModule + `,"queryDbTableName":"` + param[0] + `","sourceUrn":"` + sourceUrn + `"` + param[3] + `}`;
138
+ return `{"specModule":` + specModule + `,"mappingsModule":` + mappingsModule + `,"queryDbTableName":"` + param[0] + `","sourceUrn":"` + sourceUrn + `"` + param[3] + stateTopicFragment + `}`;
146
139
  });
147
140
  handlerOutputs.push(handlerJson);
148
141
  });
149
142
  let handlerConfigOutput = Pulumi.all(handlerOutputs).apply(handlers => `{"handlers":[` + handlers.join(",") + `]}`);
150
143
  let envVars = {};
151
144
  envVars["HANDLER_CONFIG"] = handlerConfigOutput;
145
+ let anyPgStream = Object.keys(readModelInfos).some(rmName => {
146
+ if (Stdlib_Option.mapOr(readModelInfos[rmName], false, i => i.pgBacked)) {
147
+ return QueryDbBackend$ReventlessAws.postgresStreamRegistry.has(rmName);
148
+ } else {
149
+ return false;
150
+ }
151
+ });
152
+ let pgStreamConfig = anyPgStream ? eventsApiConfig.contents : undefined;
153
+ if (pgStreamConfig !== undefined) {
154
+ envVars["APPSYNC_ENDPOINT"] = pgStreamConfig.endpoint;
155
+ }
152
156
  let match$1 = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Runtime/ReadModelEntryPoint.mjs", packageDirs, undefined);
153
157
  let vpcConfig = qdbSelection !== undefined && anyPgBacked ? qdbSelection.securityGroupId.apply(sgId => ({
154
158
  subnetIds: qdbSelection.subnetIds,
@@ -166,6 +170,17 @@ function finish() {
166
170
  role: runtime.parts.lambdaRole.id
167
171
  }));
168
172
  }
173
+ if (pgStreamConfig !== undefined) {
174
+ pgStreamConfig.apiArn.apply(apiArn => new (Aws.iam.RolePolicy)("AllReadModels-appsyncPublish", {
175
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, "AllReadModels-appsyncPublishPolicy", [{
176
+ Sid: "AllowPublishAppSyncEvents",
177
+ Effect: "Allow",
178
+ Action: "appsync:EventPublish",
179
+ Resource: apiArn + "/*"
180
+ }])),
181
+ role: runtime.parts.lambdaRole.id
182
+ }));
183
+ }
169
184
  let channelSpecs = storedSpecs.map(param => param.channelSpec);
170
185
  EventCollectorChannel_DynamoDbStream$ReventlessAws.connect("AllReadModels", channelSpecs, runtime, opts);
171
186
  if (feedQueue !== undefined) {
@@ -187,6 +202,8 @@ export {
187
202
  RuntimeEnvironment,
188
203
  log,
189
204
  readModelInfos,
205
+ eventsApiConfig,
206
+ setEventsApiConfig,
190
207
  registerReadModel,
191
208
  storedSpecs,
192
209
  grandParent,
@@ -0,0 +1,40 @@
1
+ // One-shot Postgres schema-migration Lambda entry point (A3).
2
+ //
3
+ // Invoked once during `pulumi up` by an `aws.lambda.Invocation` resource, from
4
+ // INSIDE the DB's VPC, so the deploy runner needs no network path to a private
5
+ // RDS instance. Runs the exact idempotent `PgSchema.ensureSchema` the local
6
+ // backend runs at startup (reventless-local's `postgres` smart constructor), so
7
+ // the AWS schema is byte-identical to local — zero drift. Every statement is
8
+ // `IF NOT EXISTS`, so re-invocation (e.g. after a schema-DDL change bumps the
9
+ // Lambda layer) is safe.
10
+ //
11
+ // HANDLER_CONFIG shape:
12
+ // { "pgConnection": { host, port, database, username, secretArn } }
13
+
14
+ import { poolFor } from "@reventlessdev/reventless-aws/src/adapter/Postgres/PgRuntime.res.mjs";
15
+ import { ensureSchema } from "@reventlessdev/reventless-postgres/src/PgSchema.res.mjs";
16
+ import { log } from "./HandlerFactoryHelpers.mjs";
17
+
18
+ // Config parse → pool → ensureSchema, with the pool source injectable so the
19
+ // orchestration is integration-testable against a real Postgres without the
20
+ // Secrets Manager round-trip (mirrors PgChangeFeedRelay_Runtime's
21
+ // relayWithPool/relay split). `opts.makePool` defaults to the production `poolFor`.
22
+ export async function runMigration(config, opts = {}) {
23
+ if (!config.pgConnection) {
24
+ throw new Error("PgMigrationEntryPoint: HANDLER_CONFIG has no pgConnection");
25
+ }
26
+ // `poolFor` memoises one pool per secret ARN per container and resolves the
27
+ // RDS-managed password from Secrets Manager via a cached provider.
28
+ const makePool = opts.makePool ?? poolFor;
29
+ const pool = makePool(config.pgConnection);
30
+ await ensureSchema(pool);
31
+ log.info("ensureSchema completed for " + config.pgConnection.database, {
32
+ comp: "PgMigration",
33
+ });
34
+ return "ok";
35
+ }
36
+
37
+ export async function handler(_event, _context) {
38
+ const config = JSON.parse(process.env["HANDLER_CONFIG"] || "{}");
39
+ return runMigration(config);
40
+ }
@@ -8,6 +8,7 @@ import { tag as requestContextTag } from "@reventlessdev/reventless-core/src/Req
8
8
  import { Make as readModelCallbackMake } from "@reventlessdev/reventless-core/src/components/ReadModel/ReadModel_Callback.res.mjs";
9
9
  import { load as qdbLoad, loadStream as qdbLoadStream, save as qdbSave, saveBatch as qdbSaveBatch, count as qdbCount, $$delete as qdbDelete, deleteBatch as qdbDeleteBatch } from "@reventlessdev/reventless-aws/src/adapter/QueryDb/QueryDbStorage_DynamoDb_Runtime.res.mjs";
10
10
  import { opsFor as pgQdbOpsFor } from "@reventlessdev/reventless-aws/src/adapter/QueryDb/QueryDbStorage_Postgres_Runtime.res.mjs";
11
+ import { withLiveUpdates } from "./StateTopicPublish.mjs";
11
12
  import { handleStreamEvent } from "@reventlessdev/reventless-aws/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res.mjs";
12
13
 
13
14
  const dynamicImport = (specifier) => import('/var/task/node_modules/' + specifier);
@@ -74,17 +75,28 @@ function groupBySource(records) {
74
75
  // runtime builder injected for a Postgres-backed read model: bind the Postgres
75
76
  // operation set (queryDbTableName is then the read-model spec name, the shared
76
77
  // `qdb_<name>` discriminator). Absent → the DynamoDB path is byte-identical.
77
- function buildReadModelHandler(specModule, mappingsModule, queryDbTableName, pgConnection) {
78
+ function buildReadModelHandler(specModule, mappingsModule, queryDbTableName, pgConnection, stateTopicName) {
78
79
  const patchedSpec = patchSpecId(specModule);
79
80
  let operations;
80
81
  if (pgConnection) {
81
82
  const indexes = (patchedSpec.config && patchedSpec.config.indexes) || [];
82
83
  const subIdField = patchedSpec.subIdConfig ? patchedSpec.subIdConfig.subIdField : undefined;
83
84
  const pgOps = pgQdbOpsFor(pgConnection, queryDbTableName, indexes, subIdField);
85
+ // B3.3: on a subscription-enabled (Stream) Postgres read model, publish a
86
+ // live-update descriptor after each save/delete (no DynamoDB stream exists).
87
+ // `stateTopicName` (present only for stream RMs) + APPSYNC_ENDPOINT gate it;
88
+ // absent → withLiveUpdates returns pgOps unchanged. Wrap BEFORE mkInjectIdSave
89
+ // so the publish sees the id-injected state (id, subId, updatedAt).
90
+ const livePgOps = withLiveUpdates(pgOps, {
91
+ endpoint: process.env.APPSYNC_ENDPOINT,
92
+ region: process.env.AWS_REGION,
93
+ topicName: stateTopicName,
94
+ subIdField,
95
+ });
84
96
  operations = {
85
- ...pgOps,
86
- save: mkInjectIdSave(pgOps.save),
87
- saveBatch: mkInjectIdSaveBatch(pgOps.saveBatch),
97
+ ...livePgOps,
98
+ save: mkInjectIdSave(livePgOps.save),
99
+ saveBatch: mkInjectIdSaveBatch(livePgOps.saveBatch),
88
100
  };
89
101
  } else {
90
102
  const table = makeTableRef(queryDbTableName);
@@ -119,7 +131,7 @@ async function buildAllHandlers() {
119
131
  await Promise.all(config.handlers.map(async h => {
120
132
  const specModule = await dynamicImport(h.specModule);
121
133
  const mappingsModule = await dynamicImport(h.mappingsModule);
122
- const handler = buildReadModelHandler(specModule, mappingsModule, h.queryDbTableName, h.pgConnection);
134
+ const handler = buildReadModelHandler(specModule, mappingsModule, h.queryDbTableName, h.pgConnection, h.stateTopicName);
123
135
  // Multiple read models can share one source stream — e.g. every admin read
124
136
  // model (Plugins, PluginHistory, PlatformEventGraph, UIFragmentRegistry)
125
137
  // projects the Plugin aggregate's EventLog stream. Accumulate ALL handlers
@@ -180,6 +180,16 @@ let makeFromCodeAsset: (
180
180
  variables->Dict.set(key, value)
181
181
  })
182
182
 
183
+ // ESM self-containment (Option C): every code archive built by
184
+ // Util_Bundle.buildCodeArchive ships register-hook.mjs + layer-resolver.mjs at
185
+ // /var/task. --import registers the resolve hook so the ESM entry point (and the
186
+ // user spec/behavior modules it dynamically imports) can resolve bare specifiers
187
+ // — @reventlessdev/*, effect, sury from the layer, @aws-sdk/* from the runtime —
188
+ // that ESM `import` would otherwise not find outside /var/task. Framework
189
+ // invariant: set last so it wins over any caller-supplied value.
190
+ variables->Dict.set("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make)
191
+ variables->Dict.set("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make)
192
+
183
193
  let tags = AWS.Tags.make(~name, ReventlessCore.CommandTopic.componentType)
184
194
  let lambda = Lambda.Function.make(
185
195
  ~name,
@@ -13,6 +13,7 @@ import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
13
13
  import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Adapter.res.mjs";
14
14
  import * as Runtime$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Runtime/Runtime.res.mjs";
15
15
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
16
+ import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
16
17
  import * as Util_Lambda$ReventlessAws from "../../util/Util_Lambda.res.mjs";
17
18
  import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
18
19
  import * as CommandTopic$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic.res.mjs";
@@ -91,6 +92,8 @@ function makeFromCodeAsset(name, code, sourceCodeHash, envVarsOpt, memorySizeOpt
91
92
  Stdlib_Dict.forEachWithKey(additionalEnvVars, (value, key) => {
92
93
  variables[key] = value;
93
94
  });
95
+ variables["NODE_OPTIONS"] = Util_Bundle$ReventlessAws.esmLoaderNodeOptions;
96
+ variables["ESM_FALLBACK_DIRS"] = Util_Bundle$ReventlessAws.esmFallbackDirs;
94
97
  let tags = AWS_Tags$ReventlessAws.make(name, CommandTopic$ReventlessCore.componentType);
95
98
  let lambda = new (Aws.lambda.Function)(name, {
96
99
  handler: "index.handler",
@@ -95,20 +95,15 @@ let forDcbCommandTopic = (
95
95
  // fragment merged into HANDLER_CONFIG below.
96
96
  let pgConnectionFragment = switch pgSelection {
97
97
  | Some(sel) =>
98
- sel.connectionConfig->Pulumi.Output.apply(cc => {
99
- let pgConnectionJson =
100
- [
101
- ("host", cc.host->JSON.Encode.string),
102
- ("port", cc.port->Int.toFloat->JSON.Encode.float),
103
- ("database", cc.database->JSON.Encode.string),
104
- ("username", cc.username->JSON.Encode.string),
105
- ("secretArn", cc.secretArn->JSON.Encode.string),
106
- ]
107
- ->Dict.fromArray
108
- ->JSON.Encode.object
109
- ->JSON.stringify
110
- `,"pgConnection":${pgConnectionJson}`
111
- })
98
+ // C2: the DCB command Lambda is the only Postgres path that takes an append
99
+ // lock, so `lockStrategy` rides in this fragment (not in `connectionConfig`,
100
+ // which the classic/QueryDb paths share). The entry point passes it to
101
+ // `DcbEventLogStorage_Postgres_Runtime.opsFor`.
102
+ sel.connectionConfig->Pulumi.Output.apply(cc =>
103
+ `,"pgConnection":${cc
104
+ ->PgConnection.connectionConfigToJson(~lockStrategy=sel.lockStrategy)
105
+ ->JSON.stringify}`
106
+ )
112
107
  | None => Pulumi.Output.make("")
113
108
  }
114
109
 
@@ -9,6 +9,7 @@ import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/co
9
9
  import * as DcbBackend$ReventlessAws from "../DcbEventLog/DcbBackend.res.mjs";
10
10
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
11
11
  import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
12
+ import * as PgConnection$ReventlessAws from "../Postgres/PgConnection.res.mjs";
12
13
  import * as CommandTopicChannel_SQS$ReventlessAws from "../CommandTopic/CommandTopicChannel_SQS.res.mjs";
13
14
  import * as RuntimeEnvironment_Lambda$ReventlessAws from "./RuntimeEnvironment_Lambda.res.mjs";
14
15
 
@@ -51,31 +52,7 @@ function forDcbCommandTopic(slicePaths, dcbTableName, pluginName, syncConfig, as
51
52
  let b = Stdlib_Option.getOr(JSON.stringify(param[1]), `""`);
52
53
  return `{"spec":` + s + `,"behavior":` + b + `}`;
53
54
  }).join(",");
54
- let pgConnectionFragment = pgSelection !== undefined ? pgSelection.connectionConfig.apply(cc => {
55
- let pgConnectionJson = JSON.stringify(Object.fromEntries([
56
- [
57
- "host",
58
- cc.host
59
- ],
60
- [
61
- "port",
62
- cc.port
63
- ],
64
- [
65
- "database",
66
- cc.database
67
- ],
68
- [
69
- "username",
70
- cc.username
71
- ],
72
- [
73
- "secretArn",
74
- cc.secretArn
75
- ]
76
- ]));
77
- return `,"pgConnection":` + pgConnectionJson;
78
- }) : Pulumi.output("");
55
+ let pgConnectionFragment = pgSelection !== undefined ? pgSelection.connectionConfig.apply(cc => `,"pgConnection":` + JSON.stringify(PgConnection$ReventlessAws.connectionConfigToJson(pgSelection.lockStrategy, cc))) : Pulumi.output("");
79
56
  let handlerConfigJson = Pulumi.all([
80
57
  dcbTableName$1,
81
58
  queue.id,
@@ -0,0 +1,204 @@
1
+ // StateTopicPublish.mjs
2
+ // B3.3: Postgres live-update publisher — the projection-Lambda-side counterpart
3
+ // of the DynamoDB StateTopic Lambda (StateTopic_AppSync.res).
4
+ //
5
+ // On the DynamoDB path a shared Lambda reads the table's stream and POSTs a
6
+ // change descriptor to the AppSync Events API. Postgres read models have no
7
+ // stream, so (design (a), mirroring the reventless-local Sqlite/InMemory
8
+ // backends) the projection Lambda publishes the SAME descriptor itself, right
9
+ // after it writes the `qdb_<name>` row. The wire contract — channel layout,
10
+ // descriptor shape, SigV4 signing — is byte-identical to StateTopic_AppSync so
11
+ // the browser/AutoLive side cannot tell the two backends apart.
12
+ //
13
+ // Channel: /default/{pathSegment(topicName)}/{pathSegment(entityKey)}
14
+ // Descriptor: { changeKind: "Updated" | "Removed", id: <entityKey>,
15
+ // sortKeyValue?: <updatedAt | createdAt if present> }
16
+ //
17
+ // changeKind is fixed per operation (save→"Updated", delete→"Removed"): a
18
+ // Postgres upsert doesn't distinguish insert vs update, and the Sqlite/InMemory
19
+ // backends already collapse save→"Updated" (LocalBus.res:213) — the UI treats a
20
+ // first-seen "Updated" row as an insert, so no Added detection is needed.
21
+ //
22
+ // Publish failures are LOGGED AND SWALLOWED: the projection row is already
23
+ // committed when this runs, and the projection Lambda is at-least-once — throwing
24
+ // would re-run the projection and duplicate work. A dropped live notification
25
+ // only means the UI refreshes the list on its next load, not data loss.
26
+ //
27
+ // Uses native Node.js crypto + fetch with SigV4; credentials come from the
28
+ // Lambda execution role via the process.env AWS_* variables.
29
+
30
+ import { createHmac, createHash } from "node:crypto";
31
+
32
+ function sha256hex(data) {
33
+ return createHash("sha256").update(typeof data === "string" ? data : JSON.stringify(data)).digest("hex");
34
+ }
35
+ function hmacBuf(key, data) {
36
+ return createHmac("sha256", key).update(data).digest();
37
+ }
38
+
39
+ async function signedHeaders(host, path, body, region) {
40
+ const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
41
+ const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
42
+ const sessionToken = process.env.AWS_SESSION_TOKEN;
43
+ const now = new Date();
44
+ const amzDate = now.toISOString().replace(/[:\-]|\..../g, "").slice(0, 15) + "Z";
45
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, "");
46
+ const headers = { host, "x-amz-date": amzDate, ...(sessionToken ? { "x-amz-security-token": sessionToken } : {}) };
47
+ const canonH = Object.entries(headers).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}\n`).join("");
48
+ const signH = Object.keys(headers).sort().join(";");
49
+ const cr = ["POST", path, "", canonH, signH, sha256hex(body)].join("\n");
50
+ const scope = `${dateStamp}/${region}/appsync/aws4_request`;
51
+ const sts = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(cr)].join("\n");
52
+ const kDate = hmacBuf("AWS4" + secretAccessKey, dateStamp);
53
+ const kSigning = hmacBuf(hmacBuf(hmacBuf(kDate, region), "appsync"), "aws4_request");
54
+ const sig = createHmac("sha256", kSigning).update(sts).digest("hex");
55
+ return { ...headers, Authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signH}, Signature=${sig}` };
56
+ }
57
+
58
+ // AppSync Events channel segments allow only [A-Za-z0-9-]; anything else
59
+ // collapses to `-`. The descriptor body keeps the ORIGINAL entityKey so the UI
60
+ // can match it against GraphQL row ids. Same rule as StateTopic_AppSync and the
61
+ // UI's AutoLive.normalizeSegment.
62
+ function pathSegment(value) {
63
+ return String(value).replace(/[^A-Za-z0-9-]/g, "-");
64
+ }
65
+
66
+ // Pick the natural sort timestamp from the saved state (updatedAt preferred,
67
+ // createdAt fallback). Mirrors StateTopic_AppSync.pickSortKeyValue and
68
+ // LocalBus.pickSortKeyValue.
69
+ function pickSortKeyValue(state) {
70
+ if (state && typeof state === "object" && !Array.isArray(state)) {
71
+ if (typeof state.updatedAt === "string") return state.updatedAt;
72
+ if (typeof state.createdAt === "string") return state.createdAt;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ // Build the descriptor + channel and POST to {endpoint}/event.
78
+ // - endpoint: base AppSync Events HTTP endpoint (no trailing /event).
79
+ // - region: AWS region for the SigV4 credential scope.
80
+ // - topicName: the plugin-prefixed query LIST field name (channel root).
81
+ // - entityKey: ORIGINAL entity key (id or `id-subKey`) — descriptor body + path.
82
+ // - changeKind: "Updated" (save) | "Removed" (delete).
83
+ // - sortKeyValue: optional; included when defined.
84
+ // - dedupeId: AppSync publish `id` (idempotency hint).
85
+ export async function publishStateChange({ endpoint, region, topicName, entityKey, changeKind, sortKeyValue, dedupeId }) {
86
+ if (!endpoint || !topicName) return; // not live-enabled — no-op
87
+ try {
88
+ const url = new URL(endpoint);
89
+ const channel = "/default/" + pathSegment(topicName) + "/" + pathSegment(entityKey);
90
+ const descriptor = { changeKind, id: entityKey };
91
+ if (sortKeyValue !== undefined) descriptor.sortKeyValue = sortKeyValue;
92
+ const body = JSON.stringify({
93
+ id: dedupeId || (topicName + ":" + entityKey + ":" + changeKind),
94
+ channel,
95
+ events: [JSON.stringify(descriptor)],
96
+ });
97
+ const auth = await signedHeaders(url.hostname, "/event", body, region || process.env.AWS_REGION || "eu-west-1");
98
+ const res = await fetch(endpoint + "/event", {
99
+ method: "POST",
100
+ headers: {
101
+ accept: "application/json, text/javascript",
102
+ "content-encoding": "amz-1.0",
103
+ "content-type": "application/json; charset=UTF-8",
104
+ ...auth,
105
+ },
106
+ body,
107
+ });
108
+ if (!res.ok) {
109
+ const txt = await res.text();
110
+ // Swallow (see module docstring): the row is already committed. Log
111
+ // structurally so a CloudWatch metric filter on
112
+ // "PG_STATE_TOPIC_PUBLISH_FAILED" can surface persistent breakage.
113
+ console.error(
114
+ "PG_STATE_TOPIC_PUBLISH_FAILED status=" + res.status +
115
+ " channel=" + channel +
116
+ " body=" + txt.slice(0, 500)
117
+ );
118
+ }
119
+ } catch (e) {
120
+ console.error("PG_STATE_TOPIC_PUBLISH_FAILED status=network err=" + (e && e.message));
121
+ }
122
+ }
123
+
124
+ // Compose the entity key from a save/delete's id + optional sub-key. Matches
125
+ // StateTopic_AppSync.entityKeyFromRecord and QueryDbStorage_Sqlite.entityKeyFor:
126
+ // single-key tables → id; composite tables → `id-subKeyValue`.
127
+ export function entityKeyFor(id, state, subIdField) {
128
+ if (subIdField === undefined || subIdField === null) return String(id);
129
+ const subValue =
130
+ state && typeof state === "object" && !Array.isArray(state) ? state[subIdField] : undefined;
131
+ return subValue === undefined || subValue === null
132
+ ? String(id)
133
+ : String(id) + "-" + String(subValue);
134
+ }
135
+
136
+ // Wrap a Postgres QueryDb operation set so every save/delete also publishes a
137
+ // live-update descriptor. `liveConfig` = { endpoint, region, topicName,
138
+ // subIdField }. When endpoint/topicName are absent the ops pass through
139
+ // unchanged (non-stream / non-live read models pay nothing).
140
+ //
141
+ // Note on batch ops: saveBatch/deleteBatch fan out one publish per item, after
142
+ // the batch write resolves — the row set is committed as a unit, and each row
143
+ // gets its own descriptor exactly as N single writes would.
144
+ export function withLiveUpdates(ops, liveConfig) {
145
+ const { endpoint, topicName, region, subIdField } = liveConfig || {};
146
+ if (!endpoint || !topicName) return ops;
147
+ // `publish` is injectable for headless testing (default = the real SigV4
148
+ // publisher); tests pass a capturing stub so the wrapping logic can be
149
+ // asserted without fetch/crypto.
150
+ const publish = (liveConfig && liveConfig.publish) || publishStateChange;
151
+
152
+ const publishSaved = async (id, state) =>
153
+ publish({
154
+ endpoint, region, topicName,
155
+ entityKey: entityKeyFor(id, state, subIdField),
156
+ changeKind: "Updated",
157
+ sortKeyValue: pickSortKeyValue(state),
158
+ });
159
+
160
+ const publishRemovedKey = async (entityKey) =>
161
+ publish({ endpoint, region, topicName, entityKey, changeKind: "Removed" });
162
+
163
+ // Resolve the entity keys a delete will remove — computed BEFORE the delete so
164
+ // a partition-wide delete on a composite table can still enumerate its rows
165
+ // (mirrors QueryDbStorage_Sqlite.delete's rowKeysForPartition). `subIdOpt` is
166
+ // the adapter's `option<(field, subValue)>`: undefined (partition-wide) or a
167
+ // `[field, subValue]` array (one specific row).
168
+ const removedKeysFor = async (id, subIdOpt) => {
169
+ if (subIdOpt) return [String(id) + "-" + String(subIdOpt[1])];
170
+ if (subIdField === undefined || subIdField === null) return [String(id)];
171
+ // Composite table, whole-partition delete (`Delete(id)` / `DeleteMany`):
172
+ // read the live rows so each removed sub-row gets its own descriptor,
173
+ // exactly as N DynamoDB stream REMOVE records would.
174
+ const res = await ops.load(id);
175
+ const rows = res && res.TAG === "Ok" ? res._0 : [];
176
+ return rows.map((row) => entityKeyFor(id, row, subIdField));
177
+ };
178
+
179
+ return {
180
+ ...ops,
181
+ save: async (id, state, saveMode, ttl) => {
182
+ const r = await ops.save(id, state, saveMode, ttl);
183
+ await publishSaved(id, state);
184
+ return r;
185
+ },
186
+ saveBatch: async (items) => {
187
+ const r = await ops.saveBatch(items);
188
+ await Promise.all(items.map(([id, state]) => publishSaved(id, state)));
189
+ return r;
190
+ },
191
+ delete: async (id, subIdOpt) => {
192
+ const keys = await removedKeysFor(id, subIdOpt);
193
+ const r = await ops.delete(id, subIdOpt);
194
+ await Promise.all(keys.map(publishRemovedKey));
195
+ return r;
196
+ },
197
+ deleteBatch: async (entries) => {
198
+ const keyGroups = await Promise.all(entries.map(([id, subIdOpt]) => removedKeysFor(id, subIdOpt)));
199
+ const r = await ops.deleteBatch(entries);
200
+ await Promise.all(keyGroups.flat().map(publishRemovedKey));
201
+ return r;
202
+ },
203
+ };
204
+ }
@@ -11,6 +11,7 @@ import { tag as requestContextTag } from "@reventlessdev/reventless-core/src/Req
11
11
  import { handleAction } from "@reventlessdev/reventless-core/src/Projection.res.mjs";
12
12
  import { load, loadStream, save, saveBatch, count, $$delete, deleteBatch } from "@reventlessdev/reventless-aws/src/adapter/QueryDb/QueryDbStorage_DynamoDb_Runtime.res.mjs";
13
13
  import { opsFor as pgQdbOpsFor } from "@reventlessdev/reventless-aws/src/adapter/QueryDb/QueryDbStorage_Postgres_Runtime.res.mjs";
14
+ import { withLiveUpdates } from "./StateTopicPublish.mjs";
14
15
  import { handleStreamEvent } from "@reventlessdev/reventless-aws/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res.mjs";
15
16
 
16
17
  const dynamicImport = (specifier) => import('/var/task/node_modules/' + specifier);
@@ -44,12 +45,22 @@ function groupBySource(records) {
44
45
  // `pgConnection`, when present, selects the Postgres QueryDb runtime for this
45
46
  // slice's view table (`queryDbTableName` is then the slice spec name, the shared
46
47
  // `qdb_<name>` discriminator). Absent → the DynamoDB path is byte-identical.
47
- function buildJsonEventsHandler(specModule, projectionModule, queryDbTableName, pgConnection) {
48
+ function buildJsonEventsHandler(specModule, projectionModule, queryDbTableName, pgConnection, stateTopicName) {
48
49
  let queryDbOps;
49
50
  if (pgConnection) {
50
51
  const indexes = (specModule.config && specModule.config.indexes) || [];
51
52
  const subIdField = specModule.subIdConfig ? specModule.subIdConfig.subIdField : undefined;
52
- queryDbOps = pgQdbOpsFor(pgConnection, queryDbTableName, indexes, subIdField);
53
+ const pgOps = pgQdbOpsFor(pgConnection, queryDbTableName, indexes, subIdField);
54
+ // B3.3: subscription-enabled (Stream) Postgres view slice → publish a
55
+ // live-update descriptor after save/delete. `stateTopicName` + APPSYNC_ENDPOINT
56
+ // gate it; absent → withLiveUpdates passes pgOps through. The view slice's id
57
+ // is the save's first arg (no id injection), so no wrap-ordering concern.
58
+ queryDbOps = withLiveUpdates(pgOps, {
59
+ endpoint: process.env.APPSYNC_ENDPOINT,
60
+ region: process.env.AWS_REGION,
61
+ topicName: stateTopicName,
62
+ subIdField,
63
+ });
53
64
  } else {
54
65
  const table = makeTableRef(queryDbTableName);
55
66
  queryDbOps = {
@@ -105,6 +116,9 @@ function expandHandlers(config) {
105
116
  // Shared across a plugin's slices (all follow the platform backend toggle);
106
117
  // hoisted to the top level of the compressed config like base/sourceUrn.
107
118
  pgConnection: config.pgConnection,
119
+ // B3.3: per-slice AppSync Events channel root (list field name). Present
120
+ // only for subscription-enabled (Stream) Postgres view slices.
121
+ stateTopicName: h.t,
108
122
  };
109
123
  });
110
124
  }
@@ -118,7 +132,7 @@ async function buildAllHandlers() {
118
132
  dynamicImport(h.specModule),
119
133
  dynamicImport(h.projectionModule),
120
134
  ]);
121
- const jsonEventsHandler = buildJsonEventsHandler(specModule, projectionModule, h.queryDbTableName, h.pgConnection);
135
+ const jsonEventsHandler = buildJsonEventsHandler(specModule, projectionModule, h.queryDbTableName, h.pgConnection, h.stateTopicName);
122
136
  const streamHandler = (event, context) => handleStreamEvent(jsonEventsHandler, event, context);
123
137
  const existing = handlers[h.sourceUrn] || [];
124
138
  existing.push(streamHandler);