@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
@@ -4,6 +4,7 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
7
+ import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
7
8
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
8
9
  import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
9
10
  import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
@@ -11,6 +12,15 @@ import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core
11
12
 
12
13
  let log = Logger$ReventlessCore.fromEnv();
13
14
 
15
+ function authIdField(group) {
16
+ let c = group[0];
17
+ if (c !== undefined) {
18
+ return c.toLowerCase() + group.slice(1, group.length) + "Id";
19
+ } else {
20
+ return "Id";
21
+ }
22
+ }
23
+
14
24
  function argObj(args) {
15
25
  return Stdlib_Option.getOr(Stdlib_JSON.Decode.object(args), {});
16
26
  }
@@ -55,7 +65,8 @@ async function runInterceptor(binding, payload) {
55
65
  }
56
66
  }
57
67
 
58
- async function dispatch(binding, payload) {
68
+ async function dispatch(binding, lookupBindingOpt, payload) {
69
+ let lookupBinding = lookupBindingOpt !== undefined ? lookupBindingOpt : param => {};
59
70
  let rm = payload.readModelName;
60
71
  let match = await runInterceptor(binding, payload);
61
72
  if (typeof match !== "object") {
@@ -84,7 +95,25 @@ async function dispatch(binding, payload) {
84
95
  let indexName = Stdlib_Option.getOr(payload.index, "");
85
96
  let field = Stdlib_Option.getOr(Stdlib_Option.flatMap(binding.indexes.find(ic => ic.index === indexName), ic => ic.idField), indexName);
86
97
  let value = Stdlib_Option.getOr(argStr(payload.arguments, indexName), "");
87
- return await binding.pushdowns.indexLookup(rm, field, value);
98
+ let match$1 = payload.authGroup;
99
+ let match$2 = payload.authTable;
100
+ let authorized;
101
+ if (match$1 !== undefined && match$2 !== undefined && Identity$Reventless.hasGroup(payload.identity, match$1)) {
102
+ let authBinding = lookupBinding(match$2);
103
+ if (authBinding !== undefined) {
104
+ let items$1 = await authBinding.ops.load(value);
105
+ authorized = items$1.TAG === "Ok" ? Stdlib_Option.mapOr(Stdlib_Option.flatMap(items$1._0[0], row => argStr(row, authIdField(match$1))), false, owner => owner === payload.identity.username) : false;
106
+ } else {
107
+ authorized = false;
108
+ }
109
+ } else {
110
+ authorized = true;
111
+ }
112
+ if (authorized) {
113
+ return await binding.pushdowns.indexLookup(rm, field, value);
114
+ } else {
115
+ return [];
116
+ }
88
117
  case "items" :
89
118
  let subIdField = binding.subIdField;
90
119
  if (subIdField === undefined) {
@@ -98,8 +127,8 @@ async function dispatch(binding, payload) {
98
127
  if (conn !== undefined) {
99
128
  return conn;
100
129
  }
101
- let items$1 = await binding.pushdowns.scanAll(rm);
102
- return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, binding.capability, binding.labelField, param => {});
130
+ let items$2 = await binding.pushdowns.scanAll(rm);
131
+ return QueryDbListQuery$ReventlessCore.run(items$2, argsDict, binding.capability, binding.labelField, param => {});
103
132
  case "resolveMany" :
104
133
  let target = Stdlib_Option.getOr(payload.target, rm);
105
134
  let source = Stdlib_Option.getOr(payload.source, null);
@@ -110,23 +139,23 @@ async function dispatch(binding, payload) {
110
139
  let source$1 = Stdlib_Option.getOr(payload.source, null);
111
140
  let key = Stdlib_Option.getOr(argStr(source$1, Stdlib_Option.getOr(payload.sourceIdField, "")), "");
112
141
  let ix = payload.targetIndex;
113
- let items$2;
142
+ let items$3;
114
143
  if (ix !== undefined) {
115
- items$2 = await binding.pushdowns.indexLookup(target$1, Stdlib_Option.getOr(payload.targetIndexIdField, ix), key);
144
+ items$3 = await binding.pushdowns.indexLookup(target$1, Stdlib_Option.getOr(payload.targetIndexIdField, ix), key);
116
145
  } else {
117
- let items$3 = await binding.ops.load(key);
118
- items$2 = items$3.TAG === "Ok" ? items$3._0 : [];
146
+ let items$4 = await binding.ops.load(key);
147
+ items$3 = items$4.TAG === "Ok" ? items$4._0 : [];
119
148
  }
120
- let match$1 = payload.sourceSubId;
121
- let match$2 = binding.subIdField;
149
+ let match$3 = payload.sourceSubId;
150
+ let match$4 = binding.subIdField;
122
151
  let filtered;
123
- if (match$1 !== undefined && match$2 !== undefined) {
124
- let name = match$1.name;
125
- let tmp = match$1.kind === "arg" ? argStr(payload.arguments, name) : argStr(source$1, name);
152
+ if (match$3 !== undefined && match$4 !== undefined) {
153
+ let name = match$3.name;
154
+ let tmp = match$3.kind === "arg" ? argStr(payload.arguments, name) : argStr(source$1, name);
126
155
  let subVal = Stdlib_Option.getOr(tmp, "");
127
- filtered = items$2.filter(it => Stdlib_Option.getOr(argStr(it, match$2), "") === subVal);
156
+ filtered = items$3.filter(it => Stdlib_Option.getOr(argStr(it, match$4), "") === subVal);
128
157
  } else {
129
- filtered = items$2;
158
+ filtered = items$3;
130
159
  }
131
160
  if (Stdlib_Option.getOr(payload.multi, false)) {
132
161
  return filtered;
@@ -143,8 +172,8 @@ async function dispatch(binding, payload) {
143
172
  }
144
173
  } else {
145
174
  let isMulti = Stdlib_Option.getOr(payload.multi, false);
146
- let match$3 = payload.kind;
147
- switch (match$3) {
175
+ let match$5 = payload.kind;
176
+ switch (match$5) {
148
177
  case "getById" :
149
178
  return null;
150
179
  case "items" :
@@ -227,7 +256,7 @@ async function handler(payload, _context) {
227
256
  }
228
257
  let binding = bindings[bindingKey];
229
258
  if (binding !== undefined) {
230
- return await dispatch(binding, payload);
259
+ return await dispatch(binding, name => bindings[name], payload);
231
260
  } else {
232
261
  return Stdlib_JsError.throwWithMessage("PgQueryResolver: no binding registered for read model " + bindingKey);
233
262
  }
@@ -235,6 +264,7 @@ async function handler(payload, _context) {
235
264
 
236
265
  export {
237
266
  log,
267
+ authIdField,
238
268
  argObj,
239
269
  argStr,
240
270
  argStrs,
@@ -50,3 +50,11 @@ let isPostgresFor = (name: string): bool =>
50
50
  /** Whether ANY read model on this platform is Postgres-backed (used to decide
51
51
  whether the projection Lambdas need VPC/secret access). */
52
52
  let isPostgres = (): bool => selectionRef.contents->Option.isSome
53
+
54
+ // B3.3: Postgres read models routed through `QueryDbStorage.SelectableStream`
55
+ // (i.e. subscription-enabled). Lives here — a leaf both the storage selector and
56
+ // the projection-Lambda runtime builders already import — so the runtime builder
57
+ // can consult it without pulling in QueryDbStorage (which would close a build
58
+ // cycle via QueryDbStorage_Postgres → PgQueryResolver_Builder). Membership means
59
+ // "publish live updates from the projection Lambda after save/delete".
60
+ let postgresStreamRegistry: Set.t<string> = Set.make()
@@ -32,6 +32,8 @@ function isPostgres() {
32
32
  return Stdlib_Option.isSome(selectionRef.contents);
33
33
  }
34
34
 
35
+ let postgresStreamRegistry = new Set();
36
+
35
37
  export {
36
38
  selectionRef,
37
39
  set,
@@ -40,5 +42,6 @@ export {
40
42
  exempt,
41
43
  isPostgresFor,
42
44
  isPostgres,
45
+ postgresStreamRegistry,
43
46
  }
44
- /* No side effect */
47
+ /* postgresStreamRegistry Not a pure module */
@@ -28,4 +28,4 @@ export {
28
28
  Lambda,
29
29
  Selectable,
30
30
  }
31
- /* QueryDbResolvers_Lambda-ReventlessAws Not a pure module */
31
+ /* QueryDbBackend-ReventlessAws Not a pure module */
@@ -26,11 +26,21 @@ let identityBlock = `id != null && id.sub != null
26
26
  ? { userArn: id.userArn ?? null, accountId: id.accountId ?? null, username: id.username ?? null, provider: 'IAM' }
27
27
  : null`
28
28
 
29
- let invokeTemplate = (~readModelName: string, ~kind: string, ~index: option<string>=?) => {
29
+ let invokeTemplate = (
30
+ ~readModelName: string,
31
+ ~kind: string,
32
+ ~index: option<string>=?,
33
+ ~authTable: option<string>=?,
34
+ ~authGroup: option<string>=?,
35
+ ) => {
30
36
  let indexFrag = switch index {
31
37
  | Some(ix) => `\n index: '${ix}',`
32
38
  | None => ""
33
39
  }
40
+ let authFrag = switch (authTable, authGroup) {
41
+ | (Some(t), Some(g)) => `\n authTable: '${t}',\n authGroup: '${g}',`
42
+ | _ => ""
43
+ }
34
44
  `import { util } from '@aws-appsync/utils';
35
45
  export function request(ctx) {
36
46
  const id = ctx.identity;
@@ -38,7 +48,7 @@ export function request(ctx) {
38
48
  operation: 'Invoke',
39
49
  payload: {
40
50
  readModelName: '${readModelName}',
41
- kind: '${kind}',${indexFrag}
51
+ kind: '${kind}',${indexFrag}${authFrag}
42
52
  arguments: ctx.args,
43
53
  identity: ${identityBlock}
44
54
  }
@@ -129,14 +139,14 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
129
139
  PgQueryResolver_Builder.registerNodeType(~typeName=returnTypeName, ~readModelName=name)
130
140
  }
131
141
 
132
- let mkResolver = (~resolverName, ~field, ~kind, ~index=?) =>
142
+ let mkResolver = (~resolverName, ~field, ~kind, ~index=?, ~authTable=?, ~authGroup=?) =>
133
143
  Resolver.makeUnitJsResolver(
134
144
  ~name=resolverName,
135
145
  ~api,
136
146
  ~dataSourceName,
137
147
  ~type_="Query"->Pulumi.Input.make,
138
148
  ~field=field->Pulumi.Input.make,
139
- ~code=invokeTemplate(~readModelName=name, ~kind, ~index?),
149
+ ~code=invokeTemplate(~readModelName=name, ~kind, ~index?, ~authTable?, ~authGroup?),
140
150
  ~opts,
141
151
  )
142
152
 
@@ -192,12 +202,26 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
192
202
  []
193
203
  }
194
204
 
195
- // Per-index equality queries: {single}By{Index}.
196
- let byIndex = indexes->Array.map(({index}) => {
205
+ // Per-index equality queries: {single}By{Index}. A group-restricted index
206
+ // (indexConfig.authorization) bakes the auth-table pipeline params — the
207
+ // Lambda verifies group membership + ownership against the auth read model.
208
+ let byIndex = indexes->Array.map((ic: indexConfig) => {
209
+ let index = ic.index
197
210
  let resolverName =
198
211
  fieldNameForSingle->String.capitalize ++ "By" ++ index->stripLeadingBy->String.capitalize
199
212
  let field = fieldNameForSingle ++ "By" ++ index->stripLeadingBy->String.capitalize
200
- mkResolver(~resolverName, ~field, ~kind="index", ~index)
213
+ switch ic.authorization {
214
+ | Some({tableName, group}) =>
215
+ mkResolver(
216
+ ~resolverName,
217
+ ~field,
218
+ ~kind="index",
219
+ ~index,
220
+ ~authTable=tableName->String.capitalize,
221
+ ~authGroup=group,
222
+ )
223
+ | None => mkResolver(~resolverName, ~field, ~kind="index", ~index)
224
+ }
201
225
  })
202
226
 
203
227
  // @resolves — single cross-table field resolver on this (parent) type. The
@@ -14,8 +14,9 @@ let identityBlock = `id != null && id.sub != null
14
14
  ? { userArn: id.userArn ?? null, accountId: id.accountId ?? null, username: id.username ?? null, provider: 'IAM' }
15
15
  : null`;
16
16
 
17
- function invokeTemplate(readModelName, kind, index) {
17
+ function invokeTemplate(readModelName, kind, index, authTable, authGroup) {
18
18
  let indexFrag = index !== undefined ? `\n index: '` + index + `',` : "";
19
+ let authFrag = authTable !== undefined && authGroup !== undefined ? `\n authTable: '` + authTable + `',\n authGroup: '` + authGroup + `',` : "";
19
20
  return `import { util } from '@aws-appsync/utils';
20
21
  export function request(ctx) {
21
22
  const id = ctx.identity;
@@ -23,7 +24,7 @@ export function request(ctx) {
23
24
  operation: 'Invoke',
24
25
  payload: {
25
26
  readModelName: '` + readModelName + `',
26
- kind: '` + kind + `',` + indexFrag + `
27
+ kind: '` + kind + `',` + indexFrag + authFrag + `
27
28
  arguments: ctx.args,
28
29
  identity: ` + identityBlock + `
29
30
  }
@@ -75,7 +76,7 @@ function make(name, api, param, dataSourceName, indexes, subIdField, idResolverC
75
76
  if (includeIdParam) {
76
77
  PgQueryResolver_Builder$ReventlessAws.registerNodeType(returnTypeName, name$1);
77
78
  }
78
- let mkResolver = (resolverName, field, kind, index) => AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(resolverName, api, dataSourceName, "Query", field, invokeTemplate(name$1, kind, index), opts);
79
+ let mkResolver = (resolverName, field, kind, index, authTable, authGroup) => AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(resolverName, api, dataSourceName, "Query", field, invokeTemplate(name$1, kind, index, authTable, authGroup), opts);
79
80
  let stripLeadingBy = s => {
80
81
  if (s.startsWith("by") && s.length > 2) {
81
82
  return s.slice(2, s.length);
@@ -84,21 +85,26 @@ function make(name, api, param, dataSourceName, indexes, subIdField, idResolverC
84
85
  }
85
86
  };
86
87
  let resourcesMaker = _allQueryDbs => {
87
- let byId = mkResolver(Stdlib_String.capitalize(fieldNameForSingle), fieldNameForSingle, includeIdParam ? "getById" : "list", undefined);
88
- let all = mkResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, "list", undefined);
89
- let items = subIdField !== undefined ? [mkResolver(Stdlib_String.capitalize(fieldNameForSingle) + "Items", fieldNameForSingle + "Items", "items", undefined)] : [];
88
+ let byId = mkResolver(Stdlib_String.capitalize(fieldNameForSingle), fieldNameForSingle, includeIdParam ? "getById" : "list", undefined, undefined, undefined);
89
+ let all = mkResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, "list", undefined, undefined, undefined);
90
+ let items = subIdField !== undefined ? [mkResolver(Stdlib_String.capitalize(fieldNameForSingle) + "Items", fieldNameForSingle + "Items", "items", undefined, undefined, undefined)] : [];
90
91
  let byIds;
91
92
  if (includeIdParam && subIdField === undefined) {
92
93
  let byIdsField = fieldNameForAll + "ByIds";
93
- byIds = [mkResolver(Stdlib_String.capitalize(byIdsField), byIdsField, "byIds", undefined)];
94
+ byIds = [mkResolver(Stdlib_String.capitalize(byIdsField), byIdsField, "byIds", undefined, undefined, undefined)];
94
95
  } else {
95
96
  byIds = [];
96
97
  }
97
- let byIndex = indexes.map(param => {
98
- let index = param.index;
98
+ let byIndex = indexes.map(ic => {
99
+ let index = ic.index;
99
100
  let resolverName = Stdlib_String.capitalize(fieldNameForSingle) + "By" + Stdlib_String.capitalize(stripLeadingBy(index));
100
101
  let field = fieldNameForSingle + "By" + Stdlib_String.capitalize(stripLeadingBy(index));
101
- return mkResolver(resolverName, field, "index", index);
102
+ let match = ic.authorization;
103
+ if (match !== undefined) {
104
+ return mkResolver(resolverName, field, "index", index, Stdlib_String.capitalize(match.tableName), match.group);
105
+ } else {
106
+ return mkResolver(resolverName, field, "index", index, undefined, undefined);
107
+ }
102
108
  });
103
109
  let idResolvers = idResolverConfigs.map(config => {
104
110
  let target = config.target;
@@ -9,7 +9,11 @@ module Postgres = QueryDbStorage_Postgres
9
9
  // platform when a `PgConnection` is supplied — and dispatch per read model:
10
10
  // Postgres (no table, no data source) unless the name is admin-exempt. The Stream
11
11
  // selector falls back to the streamed DynamoDB maker; on Postgres there is no
12
- // stream live updates for Postgres-backed read models are deferred (B3.3).
12
+ // stream, so live updates (B3.3) are published from the projection Lambda after
13
+ // each save/delete instead. SelectableStream records subscription-enabled
14
+ // Postgres read models in `QueryDbBackend.postgresStreamRegistry` (a leaf module,
15
+ // to avoid a build cycle) so the projection-Lambda runtime builder knows which
16
+ // handlers need the AppSync-Events publish wiring.
13
17
  module Selectable = {
14
18
  type api = QueryDbStorage_Postgres.api
15
19
  type role = QueryDbStorage_Postgres.role
@@ -42,6 +46,7 @@ module SelectableStream = {
42
46
  ~opts,
43
47
  ) =>
44
48
  if QueryDbBackend.isPostgresFor(name) {
49
+ QueryDbBackend.postgresStreamRegistry->Set.add(name)
45
50
  Postgres.make(~name, ~indexes, ~subIdField?, ~ttl?, ~api, ~apiRole, ~opts)
46
51
  } else {
47
52
  DynamoDbStream.make(~name, ~indexes, ~subIdField?, ~ttl?, ~api, ~apiRole, ~opts)
@@ -19,6 +19,7 @@ let Selectable = {
19
19
 
20
20
  function make$1(name, indexes, subIdField, ttl, api, apiRole, opts) {
21
21
  if (QueryDbBackend$ReventlessAws.isPostgresFor(name)) {
22
+ QueryDbBackend$ReventlessAws.postgresStreamRegistry.add(name);
22
23
  return QueryDbStorage_Postgres$ReventlessAws.make(name, indexes, subIdField, ttl, api, apiRole, opts);
23
24
  } else {
24
25
  return QueryDbStorage_DynamoDbStream$ReventlessAws.make(name, indexes, subIdField, ttl, api, apiRole, opts);
@@ -48,4 +49,4 @@ export {
48
49
  Selectable,
49
50
  SelectableStream,
50
51
  }
51
- /* QueryDbStorage_DynamoDb-ReventlessAws Not a pure module */
52
+ /* QueryDbBackend-ReventlessAws Not a pure module */
@@ -18,4 +18,4 @@ function make(name, indexes, subIdField, param, param$1, param$2, param$3) {
18
18
  export {
19
19
  make,
20
20
  }
21
- /* PgQueryResolver_Builder-ReventlessAws Not a pure module */
21
+ /* QueryDbBackend-ReventlessAws Not a pure module */
@@ -214,20 +214,9 @@ let finish = () =>
214
214
  let pgSelection = EventLogBackend.get()
215
215
  let pgConnectionFragment = switch pgSelection {
216
216
  | Some(sel) =>
217
- sel.connectionConfig->Pulumi.Output.apply(cc => {
218
- let pgConnectionJson =
219
- [
220
- ("host", cc.host->JSON.Encode.string),
221
- ("port", cc.port->Int.toFloat->JSON.Encode.float),
222
- ("database", cc.database->JSON.Encode.string),
223
- ("username", cc.username->JSON.Encode.string),
224
- ("secretArn", cc.secretArn->JSON.Encode.string),
225
- ]
226
- ->Dict.fromArray
227
- ->JSON.Encode.object
228
- ->JSON.stringify
229
- `,"pgConnection":${pgConnectionJson}`
230
- })
217
+ sel.connectionConfig->Pulumi.Output.apply(cc =>
218
+ `,"pgConnection":${cc->PgConnection.connectionConfigToJson->JSON.stringify}`
219
+ )
231
220
  | None => Pulumi.Output.make("")
232
221
  }
233
222
 
@@ -10,6 +10,7 @@ import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/
10
10
  import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
11
11
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
12
12
  import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
13
+ import * as PgConnection$ReventlessAws from "../Postgres/PgConnection.res.mjs";
13
14
  import * as EventLogBackend$ReventlessAws from "../EventLog/EventLogBackend.res.mjs";
14
15
  import * as RuntimeEnvironment_Lambda$ReventlessAws from "./RuntimeEnvironment_Lambda.res.mjs";
15
16
  import * as CommandTopicChannel_SQS_Sync$ReventlessAws from "../CommandTopic/CommandTopicChannel_SQS_Sync.res.mjs";
@@ -156,31 +157,7 @@ function finish() {
156
157
  let handlerOutputs = [];
157
158
  let packageDirs = {};
158
159
  let pgSelection = EventLogBackend$ReventlessAws.get();
159
- let pgConnectionFragment = pgSelection !== undefined ? pgSelection.connectionConfig.apply(cc => {
160
- let pgConnectionJson = JSON.stringify(Object.fromEntries([
161
- [
162
- "host",
163
- cc.host
164
- ],
165
- [
166
- "port",
167
- cc.port
168
- ],
169
- [
170
- "database",
171
- cc.database
172
- ],
173
- [
174
- "username",
175
- cc.username
176
- ],
177
- [
178
- "secretArn",
179
- cc.secretArn
180
- ]
181
- ]));
182
- return `,"pgConnection":` + pgConnectionJson;
183
- }) : Pulumi.output("");
160
+ let pgConnectionFragment = pgSelection !== undefined ? pgSelection.connectionConfig.apply(cc => `,"pgConnection":` + JSON.stringify(PgConnection$ReventlessAws.connectionConfigToJson(undefined, cc))) : Pulumi.output("");
184
161
  specs.forEach(spec => {
185
162
  let info = aggregateInfos[spec.aggregateName];
186
163
  if (info === undefined) {
@@ -201,20 +201,9 @@ let finish = () =>
201
201
  let pgSelection = EventLogBackend.get()
202
202
  let pgConnectionFragment = switch pgSelection {
203
203
  | Some(sel) =>
204
- sel.connectionConfig->Pulumi.Output.apply(cc => {
205
- let pgConnectionJson =
206
- [
207
- ("host", cc.host->JSON.Encode.string),
208
- ("port", cc.port->Int.toFloat->JSON.Encode.float),
209
- ("database", cc.database->JSON.Encode.string),
210
- ("username", cc.username->JSON.Encode.string),
211
- ("secretArn", cc.secretArn->JSON.Encode.string),
212
- ]
213
- ->Dict.fromArray
214
- ->JSON.Encode.object
215
- ->JSON.stringify
216
- `,"pgConnection":${pgConnectionJson}`
217
- })
204
+ sel.connectionConfig->Pulumi.Output.apply(cc =>
205
+ `,"pgConnection":${cc->PgConnection.connectionConfigToJson->JSON.stringify}`
206
+ )
218
207
  | None => Pulumi.Output.make("")
219
208
  }
220
209
 
@@ -10,6 +10,7 @@ import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/
10
10
  import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
11
11
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
12
12
  import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
13
+ import * as PgConnection$ReventlessAws from "../Postgres/PgConnection.res.mjs";
13
14
  import * as EventLogBackend$ReventlessAws from "../EventLog/EventLogBackend.res.mjs";
14
15
  import * as RuntimeEnvironment_Lambda$ReventlessAws from "./RuntimeEnvironment_Lambda.res.mjs";
15
16
  import * as CommandTopicChannel_SQS_Async$ReventlessAws from "../CommandTopic/CommandTopicChannel_SQS_Async.res.mjs";
@@ -148,31 +149,7 @@ function finish() {
148
149
  let handlerOutputs = [];
149
150
  let packageDirs = {};
150
151
  let pgSelection = EventLogBackend$ReventlessAws.get();
151
- let pgConnectionFragment = pgSelection !== undefined ? pgSelection.connectionConfig.apply(cc => {
152
- let pgConnectionJson = JSON.stringify(Object.fromEntries([
153
- [
154
- "host",
155
- cc.host
156
- ],
157
- [
158
- "port",
159
- cc.port
160
- ],
161
- [
162
- "database",
163
- cc.database
164
- ],
165
- [
166
- "username",
167
- cc.username
168
- ],
169
- [
170
- "secretArn",
171
- cc.secretArn
172
- ]
173
- ]));
174
- return `,"pgConnection":` + pgConnectionJson;
175
- }) : Pulumi.output("");
152
+ let pgConnectionFragment = pgSelection !== undefined ? pgSelection.connectionConfig.apply(cc => `,"pgConnection":` + JSON.stringify(PgConnection$ReventlessAws.connectionConfigToJson(undefined, cc))) : Pulumi.output("");
176
153
  specs.forEach(spec => {
177
154
  let info = aggregateInfos[spec.aggregateName];
178
155
  if (info === undefined) {
@@ -99,7 +99,13 @@ export async function buildHandlersForConfig(config, opts = {}) {
99
99
  // (see docs/plans/aws-postgres-change-feed-bridge.md), not by this Lambda.
100
100
  const rawStorageOps = config.pgConnection
101
101
  ? (() => {
102
- const pgOps = pgDcbEventLogOpsFor(config.pgConnection, config.dcbEventLogTableName);
102
+ // C2: `lockStrategy` ("AdvisoryLocks" | "RowLocks") is the polyvariant's
103
+ // string name; `opsFor` defaults to "AdvisoryLocks" when it's absent.
104
+ const pgOps = pgDcbEventLogOpsFor(
105
+ config.pgConnection,
106
+ config.dcbEventLogTableName,
107
+ config.pgConnection.lockStrategy,
108
+ );
103
109
  return { read: pgOps.read, append: pgOps.append, readStream: pgOps.readStream };
104
110
  })()
105
111
  : (() => {
@@ -18,6 +18,18 @@ type readModelInfo = {
18
18
 
19
19
  let readModelInfos: dict<readModelInfo> = Dict.make()
20
20
 
21
+ // B3.3: the AppSync Events API a Postgres-backed stream read model publishes live
22
+ // updates to. Set once by `Platform.makePlatform` (monolithic mode) before the
23
+ // plugins build, so `finish` can add the APPSYNC_ENDPOINT env + appsync:EventPublish
24
+ // IAM to the projection Lambda. None → no live updates (no events API, or the
25
+ // platform runs without one).
26
+ type eventsApiConfig = {
27
+ endpoint: Pulumi.Output.t<string>,
28
+ apiArn: Pulumi.Output.t<string>,
29
+ }
30
+ let eventsApiConfig: ref<option<eventsApiConfig>> = ref(None)
31
+ let setEventsApiConfig = (cfg: eventsApiConfig) => eventsApiConfig := Some(cfg)
32
+
21
33
  let registerReadModel = (
22
34
  ~readModelName,
23
35
  ~specModulePath,
@@ -147,20 +159,9 @@ let finish = () =>
147
159
  readModelInfos->Dict.valuesToArray->Array.some(info => info.pgBacked)
148
160
  let pgConnectionFragment = switch (qdbSelection, anyPgBacked) {
149
161
  | (Some(sel), true) =>
150
- sel.connectionConfig->Pulumi.Output.apply(cc => {
151
- let pgConnectionJson =
152
- [
153
- ("host", cc.host->JSON.Encode.string),
154
- ("port", cc.port->Int.toFloat->JSON.Encode.float),
155
- ("database", cc.database->JSON.Encode.string),
156
- ("username", cc.username->JSON.Encode.string),
157
- ("secretArn", cc.secretArn->JSON.Encode.string),
158
- ]
159
- ->Dict.fromArray
160
- ->JSON.Encode.object
161
- ->JSON.stringify
162
- `,"pgConnection":${pgConnectionJson}`
163
- })
162
+ sel.connectionConfig->Pulumi.Output.apply(cc =>
163
+ `,"pgConnection":${cc->PgConnection.connectionConfigToJson->JSON.stringify}`
164
+ )
164
165
  | _ => Pulumi.Output.make("")
165
166
  }
166
167
 
@@ -192,6 +193,23 @@ let finish = () =>
192
193
  let handlerPgFragment = info.pgBacked
193
194
  ? pgConnectionFragment
194
195
  : Pulumi.Output.make("")
196
+ // B3.3: a subscription-enabled Postgres read model publishes live
197
+ // updates from the projection Lambda — bake its AppSync Events channel
198
+ // root (the plural LIST field name, same source as the DynamoDB
199
+ // subscriptionInfraHook). Deploy-time resolved, so a plain string.
200
+ let stateTopicFragment =
201
+ info.pgBacked &&
202
+ QueryDbBackend.postgresStreamRegistry->Set.has(spec.componentName) &&
203
+ eventsApiConfig.contents->Option.isSome
204
+ ? {
205
+ let topic =
206
+ ReventlessCore.Plugin_Helpers.queryFieldNamesRegistry
207
+ ->Dict.get(spec.componentName)
208
+ ->Option.map(qn => qn.listFieldName)
209
+ ->Option.getOr(spec.componentName)
210
+ `,"stateTopicName":${topic->JSON.Encode.string->JSON.stringify}`
211
+ }
212
+ : ""
195
213
  let handlerJson =
196
214
  Pulumi.Output.all4((
197
215
  info.queryDbTableName,
@@ -203,7 +221,7 @@ let finish = () =>
203
221
  // No stream resource (Postgres-backed source) → dispatch on the
204
222
  // feed queue's ARN instead of a stream URN.
205
223
  let sourceUrn = urns->Array.get(0)->Option.getOr(feedArn)
206
- `{"specModule":${specModule},"mappingsModule":${mappingsModule},"queryDbTableName":"${tableName}","sourceUrn":"${sourceUrn}"${pgFragment}}`
224
+ `{"specModule":${specModule},"mappingsModule":${mappingsModule},"queryDbTableName":"${tableName}","sourceUrn":"${sourceUrn}"${pgFragment}${stateTopicFragment}}`
207
225
  })
208
226
  let _ = handlerOutputs->Array.push(handlerJson)
209
227
  | None =>
@@ -223,6 +241,22 @@ let finish = () =>
223
241
  let envVars: dict<Pulumi.Input.t<string>> = Dict.make()
224
242
  envVars->Dict.set("HANDLER_CONFIG", handlerConfigOutput->Pulumi.Output.asInput)
225
243
 
244
+ // B3.3: when any Postgres-backed read model is subscription-enabled, the
245
+ // projection Lambda publishes live updates to the AppSync Events API —
246
+ // give it the endpoint (env) and appsync:EventPublish (IAM, below).
247
+ let anyPgStream =
248
+ readModelInfos
249
+ ->Dict.keysToArray
250
+ ->Array.some(rmName =>
251
+ (readModelInfos->Dict.get(rmName)->Option.mapOr(false, i => i.pgBacked)) &&
252
+ QueryDbBackend.postgresStreamRegistry->Set.has(rmName)
253
+ )
254
+ let pgStreamConfig = anyPgStream ? eventsApiConfig.contents : None
255
+ switch pgStreamConfig {
256
+ | Some(cfg) => envVars->Dict.set("APPSYNC_ENDPOINT", cfg.endpoint->Pulumi.Output.asInput)
257
+ | None => ()
258
+ }
259
+
226
260
  let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
227
261
  ~entryPointModule="@reventlessdev/reventless-aws/src/adapter/Runtime/ReadModelEntryPoint.mjs",
228
262
  ~packageDirs,
@@ -287,6 +321,35 @@ let finish = () =>
287
321
  | _ => ()
288
322
  }
289
323
 
324
+ // B3.3: grant the projection Lambda appsync:EventPublish on the events API
325
+ // so a Postgres-backed stream read model can push live-update descriptors.
326
+ switch pgStreamConfig {
327
+ | Some(cfg) =>
328
+ let _ = cfg.apiArn->Pulumi.Output.apply(apiArn => {
329
+ open PulumiAws.PolicyDocument
330
+ PulumiAws.IAM.RolePolicy.make(
331
+ ~name="AllReadModels-appsyncPublish",
332
+ ~args={
333
+ policy: PulumiAws.PolicyDocument.make(
334
+ ~id="AllReadModels-appsyncPublishPolicy",
335
+ ~statements=[
336
+ {
337
+ sid: "AllowPublishAppSyncEvents",
338
+ effect: Allow,
339
+ actions: Action("appsync:EventPublish"),
340
+ resources: Resource(apiArn ++ "/*"),
341
+ },
342
+ ],
343
+ )
344
+ ->PulumiAws.PolicyDocument.toJsonString
345
+ ->Pulumi.Input.make,
346
+ role: runtime.parts.lambdaRole.id->Pulumi.Output.asInput,
347
+ },
348
+ )
349
+ })
350
+ | None => ()
351
+ }
352
+
290
353
  let channelSpecs = storedSpecs->Array.map(({channelSpec}) => channelSpec)
291
354
  let _connectResources = EventCollectorChannel.connect(
292
355
  ~name="AllReadModels",