@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.
- package/CHANGELOG.md +14 -0
- package/docker-compose.postgres.yml +21 -0
- package/package.json +6 -5
- package/scripts/run-pg-integration-tests.sh +58 -0
- package/src/Platform.res +37 -0
- package/src/Platform.res.mjs +41 -4
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +7 -1
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +10 -1
- package/src/adapter/Api/Platform_UIFragments_Lambda.res +7 -1
- package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +10 -1
- package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +6 -21
- package/src/adapter/Counter/CounterHandler_DynamoDbStream.res.mjs +2 -17
- package/src/adapter/DcbEventLog/DcbBackend.res +4 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res +2 -2
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res.mjs +7 -1
- package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res +1 -11
- package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res.mjs +2 -24
- package/src/adapter/Postgres/PgConnection.res +70 -10
- package/src/adapter/Postgres/PgConnection.res.mjs +48 -3
- package/src/adapter/Postgres/PgMigration_Builder.res +127 -0
- package/src/adapter/Postgres/PgMigration_Builder.res.mjs +51 -0
- package/src/adapter/QueryDb/PgQueryResolver_Builder.res +11 -13
- package/src/adapter/QueryDb/PgQueryResolver_Builder.res.mjs +5 -24
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +51 -3
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +48 -18
- package/src/adapter/QueryDb/QueryDbBackend.res +8 -0
- package/src/adapter/QueryDb/QueryDbBackend.res.mjs +4 -1
- package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +1 -1
- package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +31 -7
- package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +16 -10
- package/src/adapter/QueryDb/QueryDbStorage.res +6 -1
- package/src/adapter/QueryDb/QueryDbStorage.res.mjs +2 -1
- package/src/adapter/QueryDb/QueryDbStorage_Postgres.res.mjs +1 -1
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +3 -14
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +2 -25
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +3 -14
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +2 -25
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +7 -1
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +78 -15
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +43 -26
- package/src/adapter/Runtime/PgMigrationEntryPoint.mjs +40 -0
- package/src/adapter/Runtime/ReadModelEntryPoint.mjs +17 -5
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +10 -0
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +3 -0
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +9 -14
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +2 -25
- package/src/adapter/Runtime/StateTopicPublish.mjs +204 -0
- package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +17 -3
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +86 -17
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +50 -33
- package/src/adapter/StateTopic/StateTopic_AppSync.res +7 -1
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +10 -1
- package/src/plugin/cloner/ClonerRunner_Fargate.res +7 -1
- package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +10 -1
- package/src/util/Util_Bundle.res +71 -1
- package/src/util/Util_Bundle.res.mjs +49 -1
- package/tests/PgChangeFeedRelay_IntegrationTest.res +3 -3
- package/tests/PgConnectionJsonTest.res +84 -0
- package/tests/PgConnectionJsonTest.res.mjs +68 -0
- package/tests/PgMigrationEntryPoint_IntegrationTest.res +82 -0
- package/tests/PgMigrationEntryPoint_IntegrationTest.res.mjs +63 -0
- package/tests/PgPipeline_IntegrationTest.res +3 -1
- package/tests/PgQueryResolver_LambdaTest.res +59 -0
- package/tests/PgQueryResolver_LambdaTest.res.mjs +95 -16
- package/tests/StateTopicPublishTest.res +162 -0
- package/tests/StateTopicPublishTest.res.mjs +158 -0
- package/tests/Util_BundleEsmLoaderTest.res +57 -0
- 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
|
-
...
|
|
86
|
-
save: mkInjectIdSave(
|
|
87
|
-
saveBatch: mkInjectIdSaveBatch(
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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);
|