@reventlessdev/reventless-aws 3.0.0-alpha.319 → 3.0.0-alpha.321

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 (47) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +12 -8
  3. package/rescript.json +4 -0
  4. package/run-provision-identity.mjs +3 -0
  5. package/scripts/ProvisionIdentity.res +303 -0
  6. package/scripts/ProvisionIdentity.res.mjs +376 -0
  7. package/src/Platform.res +54 -30
  8. package/src/Platform.res.mjs +34 -32
  9. package/src/Platform_Stack.res +102 -19
  10. package/src/Platform_Stack.res.mjs +43 -7
  11. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +267 -32
  12. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +124 -14
  13. package/src/adapter/Auth/Auth_ActiveRoleStore.res +93 -5
  14. package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +53 -4
  15. package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res +50 -4
  16. package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs +24 -3
  17. package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res +92 -0
  18. package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res.mjs +49 -0
  19. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +21 -7
  20. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +15 -7
  21. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +34 -18
  22. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +14 -4
  23. package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res +29 -14
  24. package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs +11 -1
  25. package/src/util/Util_AwsError.res +56 -0
  26. package/src/util/Util_AwsError.res.mjs +59 -0
  27. package/src/util/Util_ShellConfig.res +28 -0
  28. package/src/util/Util_ShellConfig.res.mjs +22 -0
  29. package/tests/AppSync_RetirementNarrowingTest.res +10 -12
  30. package/tests/AppSync_RetirementNarrowingTest.res.mjs +4 -2
  31. package/tests/Auth_ActiveRolePoolAttachmentTest.res +175 -0
  32. package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +136 -0
  33. package/tests/Auth_ActiveRoleStoreTest.res +65 -0
  34. package/tests/Auth_ActiveRoleStoreTest.res.mjs +48 -0
  35. package/tests/Auth_ActiveRoleStore_SchemaTest.res +98 -0
  36. package/tests/Auth_ActiveRoleStore_SchemaTest.res.mjs +97 -0
  37. package/tests/AwsErrorFixtures.mjs +16 -0
  38. package/tests/ProvisionIdentityTest.res +109 -0
  39. package/tests/ProvisionIdentityTest.res.mjs +132 -0
  40. package/tests/QueryDbOwnerIndexTableTest.res +53 -0
  41. package/tests/QueryDbOwnerIndexTableTest.res.mjs +50 -0
  42. package/tests/QueryDbResolvers_AppSyncTest.res +19 -26
  43. package/tests/QueryDbResolvers_AppSyncTest.res.mjs +15 -17
  44. package/tests/Util_AwsErrorTest.res +127 -0
  45. package/tests/Util_AwsErrorTest.res.mjs +76 -0
  46. package/tests/Util_ShellConfigTest.res +51 -0
  47. package/tests/Util_ShellConfigTest.res.mjs +42 -0
@@ -0,0 +1,92 @@
1
+ // The active-role store's identity: what it is called, and how its rows are
2
+ // keyed. See [docs/plans/active-role-store-scoped-to-the-pool.md].
3
+ //
4
+ // 🚨 **One definition, four consumers, and that is the whole reason this file
5
+ // exists.** The deploy creates or looks the table up ([Auth_ActiveRoleStore]),
6
+ // the write door writes rows ([Auth_ActiveRoleStore_Ops]), the pre-token trigger
7
+ // reads them ([Auth_ActiveRoleTrigger_Ops]), and the provisioning script creates
8
+ // the table on a provider no stack owns (`scripts/provision-identity.mjs`). Any
9
+ // two of those disagreeing produces the same failure: a row written where nothing
10
+ // looks for it, so a role switch reports success and does nothing.
11
+ //
12
+ // Deliberately free of Pulumi and of the AWS SDK, so the script and both Lambda
13
+ // bundles can import it without dragging a deploy-time dependency into a runtime
14
+ // graph — the hazard that once broke command-handler cold starts.
15
+
16
+ /**
17
+ The store belonging to an identity provider this framework does not own.
18
+
19
+ Derived rather than configured. The objection to deriving was that two stacks
20
+ would both try to *create* the table and the second would fail or adopt a
21
+ resource it does not own — true while stacks create it, and no stack creates this
22
+ one. What derivation buys is worth more than the config key it removes: two
23
+ platforms on one provider **cannot** name different stores, so the defect stops
24
+ being something to detect and becomes something that cannot be expressed.
25
+
26
+ The provider id carries its own region (`eu-west-1_CQTwafSeX`), which is the
27
+ region the store must live in too — a pre-token-generation trigger has to sit in
28
+ its pool's region, so every stack that can attach one derives the same name in
29
+ the same place.
30
+ */
31
+ let derivedStoreName = (~identityProviderId: string): string =>
32
+ `ReventlessActiveRoleStore-${identityProviderId}`
33
+
34
+ /** The caller's Cognito `sub`. Stable across a rename, unlike the username. */
35
+ let partitionKey = "id"
36
+
37
+ /**
38
+ The app client the token was minted for.
39
+
40
+ A sort key rather than nothing, because one identity provider can serve several
41
+ platforms: each platform stack declares its own app client, so keying on the pair
42
+ gives every platform its own active role over one shared set of rows. Keyed on
43
+ the subject alone, narrowing to a role in one platform would narrow the caller's
44
+ session in every other platform on that provider — defensible as "one identity,
45
+ one session", but not what an operator wants, since a role with surfaces in one
46
+ platform and none in another leaves the second showing nothing.
47
+ */
48
+ let sortKey = "clientId"
49
+
50
+ /** The stored choice itself. */
51
+ let roleAttribute = "activeRole"
52
+
53
+ /** The key schema as DynamoDB describes one: `(attribute, keyType)` pairs.
54
+
55
+ Plain tuples rather than the SDK's `keySchemaElement` so this module keeps its
56
+ "no side effect" footer — importing the AWS SDK here would put it in both Lambda
57
+ bundles, which is the dependency leak that once broke command-handler cold
58
+ starts. Callers map their own shapes into this. */
59
+ let expectedKeySchema: array<(string, string)> = [(partitionKey, "HASH"), (sortKey, "RANGE")]
60
+
61
+ let describeKeySchema = (elements: array<(string, string)>): string =>
62
+ elements
63
+ ->Array.map(((attribute, keyType)) => `${attribute}:${keyType}`)
64
+ ->Array.toSorted(String.compare)
65
+ ->Array.join(",")
66
+
67
+ /**
68
+ Why a table that already exists cannot serve as the store, if it cannot.
69
+
70
+ 🚨 **A table under the right name with the wrong key is worse than no table.** The
71
+ deploy finds it, the handlers write into it, and every read misses — a role switch
72
+ that reports success and does nothing, which is the defect this whole store was
73
+ repaired for. So adoption checks the schema and refuses, rather than reporting
74
+ success.
75
+
76
+ The pre-`clientId` store is exactly this case: keyed on the subject alone. An
77
+ operator upgrading meets a sentence instead of a silent misbehaviour.
78
+
79
+ Order-insensitive, because `DescribeTable` does not promise one.
80
+ */
81
+ let keySchemaRefusal = (
82
+ ~tableName: string,
83
+ ~actual: array<(string, string)>,
84
+ ): option<string> => {
85
+ let actualKey = describeKeySchema(actual)
86
+ let wantedKey = describeKeySchema(expectedKeySchema)
87
+ actualKey == wantedKey
88
+ ? None
89
+ : Some(
90
+ `table "${tableName}" already exists with key schema [${actualKey}], but the active-role store needs [${wantedKey}]. A row written under one key is invisible to a read under the other, so role switching would report success and do nothing. Delete the table if its rows are disposable — they are preferences, and every caller re-chooses on their next switch.`,
91
+ )
92
+ }
@@ -0,0 +1,49 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
4
+
5
+ function derivedStoreName(identityProviderId) {
6
+ return `ReventlessActiveRoleStore-` + identityProviderId;
7
+ }
8
+
9
+ let partitionKey = "id";
10
+
11
+ let sortKey = "clientId";
12
+
13
+ let expectedKeySchema = [
14
+ [
15
+ partitionKey,
16
+ "HASH"
17
+ ],
18
+ [
19
+ sortKey,
20
+ "RANGE"
21
+ ]
22
+ ];
23
+
24
+ function describeKeySchema(elements) {
25
+ return elements.map(param => param[0] + `:` + param[1]).toSorted(Primitive_string.compare).join(",");
26
+ }
27
+
28
+ function keySchemaRefusal(tableName, actual) {
29
+ let actualKey = describeKeySchema(actual);
30
+ let wantedKey = describeKeySchema(expectedKeySchema);
31
+ if (actualKey === wantedKey) {
32
+ return;
33
+ } else {
34
+ return `table "` + tableName + `" already exists with key schema [` + actualKey + `], but the active-role store needs [` + wantedKey + `]. A row written under one key is invisible to a read under the other, so role switching would report success and do nothing. Delete the table if its rows are disposable — they are preferences, and every caller re-chooses on their next switch.`;
35
+ }
36
+ }
37
+
38
+ let roleAttribute = "activeRole";
39
+
40
+ export {
41
+ derivedStoreName,
42
+ partitionKey,
43
+ sortKey,
44
+ roleAttribute,
45
+ expectedKeySchema,
46
+ describeKeySchema,
47
+ keySchemaRefusal,
48
+ }
49
+ /* No side effect */
@@ -60,6 +60,11 @@ type triggerRequest = {
60
60
  groupConfiguration?: groupConfiguration,
61
61
  }
62
62
 
63
+ /** The app client this token is being minted for — the second half of the store's
64
+ row key, and what makes the active role per-platform on a provider serving
65
+ several. Cognito supplies it on every pre-token-generation event. */
66
+ type callerContext = {clientId?: string}
67
+
63
68
  type groupOverrideDetails = {
64
69
  groupsToOverride: array<string>,
65
70
  iamRolesToOverride: array<string>,
@@ -77,6 +82,7 @@ type event = {
77
82
  request?: triggerRequest,
78
83
  response?: triggerResponse,
79
84
  userName?: string,
85
+ callerContext?: callerContext,
80
86
  }
81
87
 
82
88
  // ── The decision ────────────────────────────────────────────────────────────
@@ -171,15 +177,18 @@ sign-in outright, and failing a login because a *preference* could not be read
171
177
  trades a working session for a cosmetic one. The caller lands on full membership —
172
178
  their existing privileges, not more — which is the safe direction to fail.
173
179
  */
174
- let storedRoleFor = async (~sub: string, ~table: string): option<string> =>
180
+ let storedRoleFor = async (~sub: string, ~clientId: string, ~table: string): option<string> =>
175
181
  try {
176
182
  let out = await DynamoDb_DocumentClient.GetCommand.make({
177
183
  tableName: table,
178
- key: Dict.fromArray([("id", JSON.Encode.string(sub))]),
184
+ key: Dict.fromArray([
185
+ (Auth_ActiveRoleStore_Schema.partitionKey, JSON.Encode.string(sub)),
186
+ (Auth_ActiveRoleStore_Schema.sortKey, JSON.Encode.string(clientId)),
187
+ ]),
179
188
  })->DynamoDb_DocumentClient.GetCommand.send
180
189
  out.item
181
190
  ->Option.flatMap(JSON.Decode.object)
182
- ->Option.flatMap(o => o->Dict.get("activeRole"))
191
+ ->Option.flatMap(o => o->Dict.get(Auth_ActiveRoleStore_Schema.roleAttribute))
183
192
  ->Option.flatMap(JSON.Decode.string)
184
193
  } catch {
185
194
  | _ => None
@@ -197,12 +206,17 @@ let handler = async (event: event): event => {
197
206
  let sub =
198
207
  event.request->Option.flatMap(r => r.userAttributes)->Option.flatMap(u => u.sub)->Option.getOr("")
199
208
 
200
- // No subject means no row to look up. Returning the event untouched keeps the
201
- // sign-in working on exactly the membership the pool granted.
202
- if sub == "" {
209
+ let clientId = event.callerContext->Option.flatMap(c => c.clientId)->Option.getOr("")
210
+
211
+ // Neither half of the key means no row to look up. Returning the event
212
+ // untouched keeps the sign-in working on exactly the membership the pool
213
+ // granted — the safe direction, and the same one a read failure takes. An
214
+ // absent client id is not worth failing a login over: the caller lands on their
215
+ // full membership, which is their existing privileges and not more.
216
+ if sub == "" || clientId == "" {
203
217
  event
204
218
  } else {
205
- let storedRole = await storedRoleFor(~sub, ~table=tableName())
219
+ let storedRole = await storedRoleFor(~sub, ~clientId, ~table=tableName())
206
220
  respond(~event, ~decision=decide(~membership, ~storedRole))
207
221
  }
208
222
  }
@@ -6,6 +6,7 @@ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
6
6
  import * as LibDynamodb from "@aws-sdk/lib-dynamodb";
7
7
  import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
8
8
  import * as DynamoDb_DocumentClient$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_DocumentClient.res.mjs";
9
+ import * as Auth_ActiveRoleStore_Schema$ReventlessAws from "./Auth_ActiveRoleStore_Schema.res.mjs";
9
10
 
10
11
  function getEnv(k) {
11
12
  let v = process.env[k];
@@ -90,16 +91,22 @@ function respond(event, decision) {
90
91
  ]));
91
92
  }
92
93
 
93
- async function storedRoleFor(sub, table) {
94
+ async function storedRoleFor(sub, clientId, table) {
94
95
  try {
95
96
  let out = await DynamoDb_DocumentClient$AwsSdk.GetCommand.send(new LibDynamodb.GetCommand({
96
97
  TableName: table,
97
- Key: Object.fromEntries([[
98
- "id",
98
+ Key: Object.fromEntries([
99
+ [
100
+ Auth_ActiveRoleStore_Schema$ReventlessAws.partitionKey,
99
101
  sub
100
- ]])
102
+ ],
103
+ [
104
+ Auth_ActiveRoleStore_Schema$ReventlessAws.sortKey,
105
+ clientId
106
+ ]
107
+ ])
101
108
  }));
102
- return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(out.Item, Stdlib_JSON.Decode.object), o => o["activeRole"]), Stdlib_JSON.Decode.string);
109
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(out.Item, Stdlib_JSON.Decode.object), o => o[Auth_ActiveRoleStore_Schema$ReventlessAws.roleAttribute]), Stdlib_JSON.Decode.string);
103
110
  } catch (exn) {
104
111
  return;
105
112
  }
@@ -108,10 +115,11 @@ async function storedRoleFor(sub, table) {
108
115
  async function handler(event) {
109
116
  let membership = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.groupConfiguration), g => g.groupsToOverride), []);
110
117
  let sub = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.userAttributes), u => u.sub), "");
111
- if (sub === "") {
118
+ let clientId = Stdlib_Option.getOr(Stdlib_Option.flatMap(event.callerContext, c => c.clientId), "");
119
+ if (sub === "" || clientId === "") {
112
120
  return event;
113
121
  }
114
- let storedRole = await storedRoleFor(sub, tableName());
122
+ let storedRole = await storedRoleFor(sub, clientId, tableName());
115
123
  return respond(event, decide(membership, storedRole));
116
124
  }
117
125
 
@@ -283,13 +283,6 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
283
283
  // (prefix-agnostic: real plugin rows always carry `name`, internal rows never do).
284
284
  // See docs/plans/done/platform-plugins-admin-connection-null-rows.md.
285
285
  let requireAttribute = internalRowRequiredAttr(name)
286
- // A DynamoDB FilterExpression is applied AFTER the page is read, so a scoped
287
- // list over a table with no index on the owner field returns short pages —
288
- // correct, but pathological once a caller owns a small fraction of the rows.
289
- // Warned rather than refused: the resolver does serve the query, and a
290
- // deployment may legitimately accept the cost on a small table. Mirrors the
291
- // `@scanSort` alignment warning above, which exists for the same class of
292
- // "works, but scans" mistake.
293
286
  ReventlessCore.OwnerScopeDiagnostics.warnIfNoElevatedGroups(
294
287
  ~comp="QueryDbResolvers_AppSync",
295
288
  ~view=name,
@@ -298,17 +291,33 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
298
291
  let isIndexed = f =>
299
292
  indexes->Array.some(ic => ic.idField->Option.getOr(ic.index) == f) ||
300
293
  subIdField->Option.getOr("") == f
301
- switch ownerField {
294
+ // The index `@owner` derives, and the sort key that orders one caller's rows
295
+ // inside it. Its absence means the author declined it — the list then falls
296
+ // back to the Scan-and-filter this used to prescribe an `@index` for.
297
+ let ownerIndexConfig = switch ownerField {
302
298
  | Some(f) =>
303
- if !isIndexed(f) {
304
- log.warn(
305
- ~comp="QueryDbResolvers_AppSync",
306
- `${name}: @owner field "${f}" is not the key of any index on this table. ` ++
307
- "Owner-scoped reads will Scan and filter, so pages shrink as the caller's " ++
308
- "share of the rows falls. Add an @index on that field before this read model grows.",
309
- )
310
- }
311
- | None => ()
299
+ indexes->Array.find(ic =>
300
+ Reventless.ReadModel.isDerivedIndex(ic) && ic.idField->Option.getOr(ic.index) == f
301
+ )
302
+ | None => None
303
+ }
304
+ let ownerIndex = ownerIndexConfig->Option.map(ic => ic.index)
305
+ let ownerIndexSortField = ownerIndexConfig->Option.flatMap(ic => ic.subIdField)
306
+ // Only reachable through `@owner({index: false})` now that the index is
307
+ // derived by default, so this states the cost of that choice rather than
308
+ // prescribing an `@index` — which would provision a second index on the same
309
+ // key and still not be the one the list reads.
310
+ switch (ownerField, ownerIndex) {
311
+ | (Some(f), None) if !isIndexed(f) =>
312
+ log.warn(
313
+ ~comp="QueryDbResolvers_AppSync",
314
+ `${name}: @owner field "${f}" keys no index on this table, so owner-scoped ` ++
315
+ "reads Scan the table and filter after the page is read — cost grows with the " ++
316
+ "table while the answer shrinks with the caller's share of it. Drop " ++
317
+ "`@owner({index: false})` to let the framework derive the index, or accept " ++
318
+ "the cost on a view that stays small.",
319
+ )
320
+ | _ => ()
312
321
  }
313
322
  // The same class of "works, but scans" mistake as the owner warning above,
314
323
  // and the retirement case degrades the same way: the FilterExpression is
@@ -346,12 +355,19 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
346
355
  ~elevatedGroups,
347
356
  ~retiredField?,
348
357
  ~retiredValues?,
358
+ ~ownerIndex?,
359
+ ~ownerIndexSortField?,
349
360
  )
350
361
  } else {
351
362
  Resolver.Functions.listAllItems
352
363
  },
353
364
  )
354
- let resolversByIndex = indexes->Array.map(({index} as indexConfig) => {
365
+ // Derived indexes are absent from the SDL (`GraphQL_FragmentGenerator` skips
366
+ // them), so a resolver here would attach to a field that does not exist and
367
+ // fail the deploy. The list resolver above is the only thing that reads one.
368
+ let resolversByIndex = indexes
369
+ ->Array.filter(ic => !Reventless.ReadModel.isDerivedIndex(ic))
370
+ ->Array.map(({index} as indexConfig) => {
355
371
  // Name and key field both come from `GraphQL_FragmentGenerator`, which is
356
372
  // where the SDL field this attaches to is derived. Deriving them here as
357
373
  // well is how the two came to disagree: the emitted field declared `id`
@@ -4,6 +4,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
6
6
  import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
7
+ import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
7
8
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
8
9
  import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
9
10
  import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Adapter.res.mjs";
@@ -123,14 +124,23 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
123
124
  return Stdlib_Option.getOr(subIdField, "") === f;
124
125
  }
125
126
  };
126
- if (ownerField !== undefined && !isIndexed(ownerField)) {
127
- log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @owner field "` + ownerField + `" is not the key of any index on this table. ` + "Owner-scoped reads will Scan and filter, so pages shrink as the caller's share of the rows falls. Add an @index on that field before this read model grows.");
127
+ let ownerIndexConfig = ownerField !== undefined ? indexes.find(ic => {
128
+ if (ReadModel$Reventless.isDerivedIndex(ic)) {
129
+ return Stdlib_Option.getOr(ic.idField, ic.index) === ownerField;
130
+ } else {
131
+ return false;
132
+ }
133
+ }) : undefined;
134
+ let ownerIndex = Stdlib_Option.map(ownerIndexConfig, ic => ic.index);
135
+ let ownerIndexSortField = Stdlib_Option.flatMap(ownerIndexConfig, ic => ic.subIdField);
136
+ if (ownerField !== undefined && !(ownerIndex !== undefined || isIndexed(ownerField))) {
137
+ log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @owner field "` + ownerField + `" keys no index on this table, so owner-scoped ` + "reads Scan the table and filter after the page is read — cost grows with the table while the answer shrinks with the caller's share of it. Drop `@owner({index: false})` to let the framework derive the index, or accept the cost on a view that stays small.");
128
138
  }
129
139
  if (retiredField !== undefined && !isIndexed(retiredField)) {
130
140
  log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @retired field "` + retiredField + `" is not the key of any index on this table. ` + "Reads that exclude retired rows will Scan and filter, so pages shrink as the archive's share of the rows grows. Add an @index on that field before this read model grows.");
131
141
  }
132
- let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute, ownerField, elevatedGroups, retiredField, retiredValues) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
133
- let resolversByIndex = indexes.map(indexConfig => {
142
+ let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute, ownerField, elevatedGroups, retiredField, retiredValues, ownerIndex, ownerIndexSortField) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
143
+ let resolversByIndex = indexes.filter(ic => !ReadModel$Reventless.isDerivedIndex(ic)).map(indexConfig => {
134
144
  let index = indexConfig.index;
135
145
  let fieldName = GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName(fieldNameForSingle, index);
136
146
  let resolverName = Stdlib_String.capitalize(fieldName);
@@ -29,20 +29,35 @@ let globalSecondaryIndexes = (indexes: array<Reventless.ReadModel.indexConfig>)
29
29
  })
30
30
  ->Pulumi.Input.make
31
31
 
32
- let attributes = (sortField, indexes: array<Reventless.ReadModel.indexConfig>) =>
33
- [
34
- [{name: "id", type_: "S"}],
35
- sortField->Option.mapOr([], sortField => [{name: sortField, type_: "S"}]),
36
- indexes
37
- ->Array.map((indexConfig: Reventless.ReadModel.indexConfig) => {
38
- let {index, type_} = indexConfig
39
- [
40
- [{name: indexConfig.idField->Option.getOr(index), type_}],
41
- indexConfig.subIdField->Option.mapOr([], sortField => [{name: sortField, type_: "S"}]),
42
- ]->Array.flat
43
- })
44
- ->Array.flat,
45
- ]->Array.flat
32
+ // Pulumi rejects a table that defines the same attribute twice, and an index may
33
+ // legitimately key on one the table already declares — the derived `@owner` index
34
+ // sorts on `id`, and any index may sort on the table's own sort key. First
35
+ // declaration wins; they agree on the type because both name the same column.
36
+ let attributes = (sortField, indexes: array<Reventless.ReadModel.indexConfig>) => {
37
+ let all =
38
+ [
39
+ [{name: "id", type_: "S"}],
40
+ sortField->Option.mapOr([], sortField => [{name: sortField, type_: "S"}]),
41
+ indexes
42
+ ->Array.map((indexConfig: Reventless.ReadModel.indexConfig) => {
43
+ let {index, type_} = indexConfig
44
+ [
45
+ [{name: indexConfig.idField->Option.getOr(index), type_}],
46
+ indexConfig.subIdField->Option.mapOr([], sortField => [{name: sortField, type_: "S"}]),
47
+ ]->Array.flat
48
+ })
49
+ ->Array.flat,
50
+ ]->Array.flat
51
+ let seen = Set.make()
52
+ all->Array.filter(({name}) =>
53
+ if seen->Set.has(name) {
54
+ false
55
+ } else {
56
+ seen->Set.add(name)
57
+ true
58
+ }
59
+ )
60
+ }
46
61
 
47
62
  let dataSource = (name, table, api, apiRole, opts) => {
48
63
  let _dataSourceRolePolicy = {
@@ -38,7 +38,7 @@ function globalSecondaryIndexes(indexes) {
38
38
  }
39
39
 
40
40
  function attributes(sortField, indexes) {
41
- return [
41
+ let all = [
42
42
  [{
43
43
  name: "id",
44
44
  type: "S"
@@ -58,6 +58,16 @@ function attributes(sortField, indexes) {
58
58
  }])
59
59
  ].flat()).flat()
60
60
  ].flat();
61
+ let seen = new Set();
62
+ return all.filter(param => {
63
+ let name = param.name;
64
+ if (seen.has(name)) {
65
+ return false;
66
+ } else {
67
+ seen.add(name);
68
+ return true;
69
+ }
70
+ });
61
71
  }
62
72
 
63
73
  function dataSource(name, table, api, apiRole, opts) {
@@ -0,0 +1,56 @@
1
+ // Classifying an AWS SDK failure, for callers that must tell one apart from the
2
+ // rest rather than treat every throw alike.
3
+ //
4
+ // 🚨 **The error's `name` carries the code; its `message` does not have to.** A
5
+ // v3 SDK `DescribeTable` on a missing table throws `name:
6
+ // "ResourceNotFoundException"` with the message "Requested resource not found:
7
+ // Table: X not found" — which does not contain the code anywhere. A guard written
8
+ // against the message alone therefore never matches, and the ordinary case it was
9
+ // meant to absorb escapes as an unhandled rejection instead.
10
+ //
11
+ // That is not hypothetical: it is why this module exists rather than the check
12
+ // living inline. A predicate inside a script that runs on import cannot be tested,
13
+ // so the version that could never match shipped.
14
+ //
15
+ // `Auth_ActiveRolePoolAttachment` keeps its own inline copy on purpose — Pulumi
16
+ // serialises a dynamic provider's whole closure into stack state, and a helper
17
+ // reached through a module import is a dependency that serialisation cannot carry.
18
+
19
+ @get @return(nullable) external name: JsExn.t => option<string> = "name"
20
+
21
+ /** Whether a failure carries this AWS error code, by `name` first and message as
22
+ a fallback for wrapped or re-thrown shapes. */
23
+ let hasCode = (exn: exn, ~code: string): bool =>
24
+ switch exn->JsExn.fromException {
25
+ | Some(jsErr) =>
26
+ switch (jsErr->name, JsExn.message(jsErr)) {
27
+ | (Some(actual), _) if actual == code => true
28
+ | (_, Some(message)) => message->String.includes(code)
29
+ | _ => false
30
+ }
31
+ | None => false
32
+ }
33
+
34
+ /** The resource named by the call does not exist — the shape both DynamoDB and
35
+ Cognito use, and normally an absence to handle rather than a failure to report. */
36
+ let isNotFound = (exn: exn): bool => exn->hasCode(~code="ResourceNotFoundException")
37
+
38
+ /**
39
+ An escaped exception as one line an operator can act on.
40
+
41
+ For the top of a CLI: without it Node reports `UnhandledPromiseRejection ...
42
+ "#<Object>"`, which names neither the call that failed nor why. Every branch
43
+ returns something, because a describer that itself throws replaces one unreadable
44
+ failure with another.
45
+ */
46
+ let describe = (exn: exn): string =>
47
+ switch exn->JsExn.fromException {
48
+ | Some(jsErr) =>
49
+ switch (jsErr->name, JsExn.message(jsErr)) {
50
+ | (Some(n), Some(m)) => `${n}: ${m}`
51
+ | (Some(n), None) => n
52
+ | (None, Some(m)) => m
53
+ | (None, None) => "an AWS call failed with no message"
54
+ }
55
+ | None => "an unexpected error escaped"
56
+ }
@@ -0,0 +1,59 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
4
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
5
+
6
+ function hasCode(exn, code) {
7
+ let jsErr = Stdlib_JsExn.fromException(exn);
8
+ if (jsErr === undefined) {
9
+ return false;
10
+ }
11
+ let jsErr$1 = Primitive_option.valFromOption(jsErr);
12
+ let match = jsErr$1.name;
13
+ let match$1 = Stdlib_JsExn.message(jsErr$1);
14
+ if (match == null) {
15
+ if (match$1 !== undefined) {
16
+ return match$1.includes(code);
17
+ } else {
18
+ return false;
19
+ }
20
+ } else if (match === code) {
21
+ return true;
22
+ } else if (match$1 !== undefined) {
23
+ return match$1.includes(code);
24
+ } else {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function isNotFound(exn) {
30
+ return hasCode(exn, "ResourceNotFoundException");
31
+ }
32
+
33
+ function describe(exn) {
34
+ let jsErr = Stdlib_JsExn.fromException(exn);
35
+ if (jsErr === undefined) {
36
+ return "an unexpected error escaped";
37
+ }
38
+ let jsErr$1 = Primitive_option.valFromOption(jsErr);
39
+ let match = jsErr$1.name;
40
+ let match$1 = Stdlib_JsExn.message(jsErr$1);
41
+ if (match == null) {
42
+ if (match$1 !== undefined) {
43
+ return match$1;
44
+ } else {
45
+ return "an AWS call failed with no message";
46
+ }
47
+ } else if (match$1 !== undefined) {
48
+ return match + `: ` + match$1;
49
+ } else {
50
+ return match;
51
+ }
52
+ }
53
+
54
+ export {
55
+ hasCode,
56
+ isNotFound,
57
+ describe,
58
+ }
59
+ /* Stdlib_JsExn Not a pure module */
@@ -31,6 +31,34 @@ let modeOptions = (mode: Platform.viewMode): array<(string, JSON.t)> =>
31
31
  }
32
32
  }
33
33
 
34
+ /**
35
+ The identity fields a shell reads, under **both** spellings.
36
+
37
+ 🚨 **Both, and not as a sequenced rename.** `identityProvider*` is what these
38
+ become — the concept is not AWS-specific, so a second cloud's adapter should meet
39
+ the same names. `cognito*` is what every shipped shell reads today.
40
+
41
+ A shell reads `config.json` at runtime and CloudFront serves the previous bundle
42
+ until someone invalidates it, so switching the keys in one deploy leaves a window
43
+ where the served bundle and the served config disagree. `cognitoClientId` is read
44
+ into an `option`: a bundle that cannot find its key does not error, it gets
45
+ `None`, and login, silent refresh and token refresh all quietly fall through —
46
+ the window is a total auth outage that reports nothing.
47
+
48
+ Writing both removes the ordering dependency rather than managing it: any bundle,
49
+ old or new or stale in a CDN, finds one it understands. The `cognito*` pair goes
50
+ once the shell that prefers the other is the pinned one.
51
+
52
+ Here rather than inline at the call site so the pairing is one fact in one place,
53
+ and so the property can be asserted without a deploy.
54
+ */
55
+ let identityFields = (~providerId: string, ~clientId: string): array<(string, JSON.t)> => [
56
+ ("identityProviderId", JSON.Encode.string(providerId)),
57
+ ("identityProviderClientId", JSON.Encode.string(clientId)),
58
+ ("cognitoUserPoolId", JSON.Encode.string(providerId)),
59
+ ("cognitoClientId", JSON.Encode.string(clientId)),
60
+ ]
61
+
34
62
  /**
35
63
  The config.json field set.
36
64
 
@@ -31,6 +31,27 @@ function modeOptions(mode) {
31
31
  }
32
32
  }
33
33
 
34
+ function identityFields(providerId, clientId) {
35
+ return [
36
+ [
37
+ "identityProviderId",
38
+ providerId
39
+ ],
40
+ [
41
+ "identityProviderClientId",
42
+ clientId
43
+ ],
44
+ [
45
+ "cognitoUserPoolId",
46
+ providerId
47
+ ],
48
+ [
49
+ "cognitoClientId",
50
+ clientId
51
+ ]
52
+ ];
53
+ }
54
+
34
55
  function fields(computed, viewModes, bakedManifest, shellConfig) {
35
56
  let out = Object.fromEntries(computed);
36
57
  Stdlib_Option.forEach(bakedManifest, bake => {
@@ -70,6 +91,7 @@ export {
70
91
  Platform,
71
92
  journeyManifestsKey,
72
93
  modeOptions,
94
+ identityFields,
73
95
  fields,
74
96
  }
75
97
  /* No side effect */