@reventlessdev/reventless-aws 3.0.0-alpha.305 → 3.0.0-alpha.306

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 (58) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/package.json +10 -10
  3. package/src/Platform.res +1 -1
  4. package/src/adapter/Api/AppSync_Resolver_Native.res +1 -1
  5. package/src/adapter/Api/AppSync_Resolver_Retrying.res +2 -2
  6. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +69 -11
  7. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +19 -1
  8. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +2 -2
  9. package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res +1 -1
  10. package/src/adapter/EventLog/EventLogStorage_DynamoDb_Runtime.res +1 -1
  11. package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res +1 -1
  12. package/src/adapter/Postgres/PgProjectionFeed.res +1 -1
  13. package/src/adapter/QueryDb/QueryDbBackend.res +1 -1
  14. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +29 -15
  15. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +6 -5
  16. package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res +1 -1
  17. package/src/adapter/Runtime/AggregateEntryPoint.mjs +1 -1
  18. package/src/adapter/Runtime/AggregateEntryPoint_Ops.res +1 -1
  19. package/src/adapter/Runtime/AutomationSliceEntryPoint.mjs +1 -1
  20. package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +1 -1
  21. package/src/adapter/Runtime/CounterEntryPoint.mjs +1 -1
  22. package/src/adapter/Runtime/CounterEntryPoint_Ops.res +1 -1
  23. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +44 -6
  24. package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res +1 -1
  25. package/src/adapter/Runtime/EventCollectorEntryPoint.mjs +1 -1
  26. package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res +1 -1
  27. package/src/adapter/Runtime/EventMapperEntryPoint.mjs +1 -1
  28. package/src/adapter/Runtime/EventMapperEntryPoint_Ops.res +1 -1
  29. package/src/adapter/Runtime/ExtensionPointEntryPoint.mjs +1 -1
  30. package/src/adapter/Runtime/ExtensionPointEntryPoint_Ops.res +1 -1
  31. package/src/adapter/Runtime/PgQueryResolverEntryPoint.mjs +1 -1
  32. package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res +1 -1
  33. package/src/adapter/Runtime/PluginExtensionPointEntryPoint.res +1 -1
  34. package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res +1 -1
  35. package/src/adapter/Runtime/QueryDbEntryPoint_Ops.res +1 -1
  36. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +1 -1
  37. package/src/adapter/Runtime/ReadModelEntryPoint_Ops.res +1 -1
  38. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +1 -1
  39. package/src/adapter/Runtime/SideEffectEntryPoint.mjs +1 -1
  40. package/src/adapter/Runtime/SideEffectEntryPoint_Ops.res +1 -1
  41. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +1 -1
  42. package/src/adapter/Runtime/StateViewSliceEntryPoint_Ops.res +1 -1
  43. package/src/adapter/Runtime/StreamRoutedEntryPoint_Ops.res +1 -1
  44. package/src/adapter/Runtime/TaskBucketEntryPoint.mjs +1 -1
  45. package/src/adapter/Runtime/TaskBucketEntryPoint_Ops.res +1 -1
  46. package/src/util/Util_Bundle.res +1 -1
  47. package/src/util/Util_DynamoDbStream_Runtime.res +1 -1
  48. package/tests/AWS_TagsTest.res +2 -2
  49. package/tests/Auth_ActiveRolePoolAttachmentTest.res +140 -1
  50. package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +117 -2
  51. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +2 -2
  52. package/tests/EventLogStorage_DynamoDb_RuntimeTest.res +1 -1
  53. package/tests/QueryDbResolvers_AppSyncTest.res +2 -2
  54. package/tests/Util_BundleEsmLoaderTest.res +1 -1
  55. package/tests/Util_BundleRuntimeExtensionTest.res +1 -1
  56. package/tests/integration/AggregateEntryPoint_IntegrationTest.res +1 -1
  57. package/tests/integration/DcbCommandTopicEntryPoint_IntegrationTest.res +1 -1
  58. package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res +2 -2
package/CHANGELOG.md CHANGED
@@ -3,6 +3,15 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.306 (2026-08-16)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** attach the pre-token trigger through both fields Cognito keeps ([6d6e2e9](https://github.com/ReventlessDev/reventless-core/commit/6d6e2e90e1d6cb3defabaab8ae7f3c9d1955a5df))
11
+ * **aws:** scope by-key reads to the owner, not only lists ([8232fd4](https://github.com/ReventlessDev/reventless-core/commit/8232fd4c09c1098c7265e4a17882ee44884f3bec))
12
+ * **core:** stamp the owner from each DCB slice's own command schema ([6edbdf4](https://github.com/ReventlessDev/reventless-core/commit/6edbdf4680f2535597c3b560215aa514ceed27eb))
13
+
14
+
6
15
  # 3.0.0-alpha.305 (2026-08-16)
7
16
 
8
17
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.305",
3
+ "version": "3.0.0-alpha.306",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -12,18 +12,18 @@
12
12
  "@aws-sdk/s3-request-presigner": "3.970.0",
13
13
  "sury": "11.0.0-alpha.4",
14
14
  "uuid": "^13.0.0",
15
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.11",
16
- "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
15
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
16
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.11",
18
18
  "@reventlessdev/rescript-node": "2.0.0-alpha.7",
19
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.77",
20
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
19
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.78",
21
20
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
- "@reventlessdev/reventless-core": "3.0.0-alpha.237",
23
- "@reventlessdev/reventless-infra": "3.0.0-alpha.143",
24
- "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
25
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.101",
26
- "@reventlessdev/reventless-spec": "3.0.0-alpha.115"
21
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
22
+ "@reventlessdev/reventless-core": "3.0.0-alpha.238",
23
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.144",
24
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.102",
25
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.116",
26
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.31"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -656,7 +656,7 @@ module MakeWithConfig = (
656
656
  }
657
657
  module InboundTranslationSlice = InboundTranslationSlice_Builder.Make(ApiConfig)
658
658
 
659
- // Admin UI-fragment registry slices (docs/plans/event-sourced-fragment-registries.md): the
659
+ // Admin UI-fragment registry slices (docs/plans/done/event-sourced-fragment-registries.md): the
660
660
  // platform UI-fragment registry hosted as admin DCB slices sharing the admin DcbEventLog.
661
661
  // Built once at platform-module level and passed into Admin.construct below (registers the
662
662
  // slice's command handler exactly once).
@@ -1,7 +1,7 @@
1
1
  /** AppSync_Resolver_Native — drop-in replacement for
2
2
  `PulumiAws.AppSync.Resolver` that uses `@pulumi/aws-native` (Cloud Control
3
3
  API) so the CFN handler can internally wait out schema -> resolver
4
- propagation. See docs/plans/appsync-resolver-aws-native.md. */
4
+ propagation. See docs/plans/done/appsync-resolver-aws-native.md. */
5
5
 
6
6
  module Native = PulumiAws.AwsNative.AppSync.Resolver
7
7
  module Functions = PulumiAws.AppSync.Resolver.Functions
@@ -16,7 +16,7 @@
16
16
  `aws-native:appsync/resolver:Resolver` type so existing resources are
17
17
  adopted in-place on first deploy (no delete+recreate).
18
18
 
19
- See docs/plans/appsync-resolver-aws-native-retry.md. */
19
+ See docs/plans/done/appsync-resolver-aws-native-retry.md. */
20
20
 
21
21
  // ── AWS SDK bindings ─────────────────────────────────────────────────────────
22
22
  //
@@ -198,7 +198,7 @@ let jsThrow: JsExn.t => 'a = %raw(`e => { throw e }`)
198
198
 
199
199
  - `NotFoundException / No field named` — schema-propagation race after a
200
200
  successful `StartSchemaCreation`. Backstop for the per-plugin dedup
201
- flow (see `docs/plans/appsync-schema-push-dedup.md`).
201
+ flow (see `docs/plans/done/appsync-schema-push-dedup.md`).
202
202
  - `ConcurrentModificationException / Schema is currently being altered` —
203
203
  API-level lock contention with an in-flight `StartSchemaCreation` on
204
204
  the same API (admin resolvers racing the platform schema push from
@@ -62,8 +62,23 @@ let readOnlyKeys = [
62
62
  "EmailConfigurationFailure",
63
63
  ]
64
64
 
65
- /** The pool's own configuration, sent back whole with `PreTokenGeneration` set
66
- (or, when `preTokenGenerationArn` is `None`, removed).
65
+ /** The version of the pre-token-generation contract this resource attaches at.
66
+
67
+ [Auth_ActiveRoleTrigger_Ops] implements `V1_0` — it answers with
68
+ `claimsOverrideDetails` — and does so deliberately, because `V2_0`/`V3_0` buy
69
+ access-token customisation it does not need and are gated behind the
70
+ Essentials and Plus feature plans.
71
+
72
+ 🚨 **A pool left at a version the handler does not implement does not fail —
73
+ it silently does nothing.** Cognito sends a `V2_0` event, the handler answers
74
+ in `V1_0` shape, and the service ignores the answer. Sign-in succeeds, tokens
75
+ mint, and the role narrowing never happens. So the version is not the pool's
76
+ to keep: the trigger and the contract it speaks are one unit, and attaching
77
+ the trigger means attaching its version too. */
78
+ let triggerVersion = "V1_0"
79
+
80
+ /** The pool's own configuration, sent back whole with the pre-token-generation
81
+ trigger set (or, when `preTokenGenerationArn` is `None`, removed).
67
82
 
68
83
  Pure and total so the property that matters — a pool carrying non-default
69
84
  settings still carries them afterwards — is checkable without a pool. */
@@ -89,21 +104,64 @@ let mergedUpdateInput = (
89
104
  ->Dict.get("LambdaConfig")
90
105
  ->Option.flatMap(JSON.Decode.object)
91
106
  ->Option.mapOr(Dict.make(), existing => Dict.fromArray(existing->Dict.toArray))
107
+ // 🚨 Cognito carries this one trigger in TWO fields: the legacy
108
+ // `PreTokenGeneration` string and the `PreTokenGenerationConfig` object.
109
+ // `UpdateUserPool` rejects the call outright when both are present naming
110
+ // different functions, so both are written, together, naming the same one.
111
+ //
112
+ // Writing only one and letting the other ride through on the copy above cannot
113
+ // work, and not merely because a customer might have set it: **AWS
114
+ // materialises the field we did not write.** Attach via the legacy field alone
115
+ // and the next `DescribeUserPool` reports a `PreTokenGenerationConfig` the
116
+ // service populated, which the merge would then feed back on the following
117
+ // deploy — so an attachment that succeeded once fails the next time with
118
+ // nothing changed but AWS normalising its own record. Writing the pair in
119
+ // agreement is the only form stable under repeated describe → merge → update.
92
120
  switch preTokenGenerationArn {
93
- | Some(arn) => lambdaConfig->Dict.set("PreTokenGeneration", JSON.Encode.string(arn))
94
- | None => lambdaConfig->Dict.delete("PreTokenGeneration")
121
+ | Some(arn) =>
122
+ let config = Dict.make()
123
+ config->Dict.set("LambdaArn", JSON.Encode.string(arn))
124
+ config->Dict.set("LambdaVersion", JSON.Encode.string(triggerVersion))
125
+ lambdaConfig->Dict.set("PreTokenGeneration", JSON.Encode.string(arn))
126
+ lambdaConfig->Dict.set("PreTokenGenerationConfig", JSON.Encode.object(config))
127
+ | None =>
128
+ // Both, or the destroyed function stays attached through whichever field was
129
+ // left behind — the "every sign-in fails on a pool nothing here would fix"
130
+ // hazard [delete_] exists to prevent.
131
+ lambdaConfig->Dict.delete("PreTokenGeneration")
132
+ lambdaConfig->Dict.delete("PreTokenGenerationConfig")
95
133
  }
96
134
  out->Dict.set("LambdaConfig", lambdaConfig->JSON.Encode.object)
97
135
  out
98
136
  }
99
137
 
100
- /** The trigger currently attached to a described pool, if any. */
101
- let attachedTrigger = (~described: dict<JSON.t>): option<string> =>
102
- described
103
- ->Dict.get("LambdaConfig")
104
- ->Option.flatMap(JSON.Decode.object)
105
- ->Option.flatMap(c => c->Dict.get("PreTokenGeneration"))
106
- ->Option.flatMap(JSON.Decode.string)
138
+ /** The trigger currently attached to a described pool, if any.
139
+
140
+ Prefers `PreTokenGenerationConfig`, since that is the field AWS populates and
141
+ the one that carries the version.
142
+
143
+ A pool whose `LambdaVersion` names a contract [triggerVersion] does not
144
+ implement reports `None` — *not attached* — even when it points at our own
145
+ function. That is what turns the silent-no-op case into ordinary drift:
146
+ refresh records nothing attached, and the next `up` rewrites the pair at the
147
+ right version. Reporting the ARN instead would leave a pool that mints
148
+ un-narrowed tokens looking correct forever. */
149
+ let attachedTrigger = (~described: dict<JSON.t>): option<string> => {
150
+ let lambdaConfig = described->Dict.get("LambdaConfig")->Option.flatMap(JSON.Decode.object)
151
+ switch lambdaConfig
152
+ ->Option.flatMap(c => c->Dict.get("PreTokenGenerationConfig"))
153
+ ->Option.flatMap(JSON.Decode.object) {
154
+ | Some(config) =>
155
+ switch config->Dict.get("LambdaVersion")->Option.flatMap(JSON.Decode.string) {
156
+ | Some(version) if version != triggerVersion => None
157
+ | _ => config->Dict.get("LambdaArn")->Option.flatMap(JSON.Decode.string)
158
+ }
159
+ | None =>
160
+ lambdaConfig
161
+ ->Option.flatMap(c => c->Dict.get("PreTokenGeneration"))
162
+ ->Option.flatMap(JSON.Decode.string)
163
+ }
164
+ }
107
165
 
108
166
  // ── AWS SDK bindings (lazily imported — see AppSync_SourceApiAssociation_Retrying) ──
109
167
 
@@ -30,6 +30,8 @@ let readOnlyKeys = [
30
30
  "EmailConfigurationFailure"
31
31
  ];
32
32
 
33
+ let triggerVersion = "V1_0";
34
+
33
35
  function mergedUpdateInput(described, userPoolId, preTokenGenerationArn) {
34
36
  let out = {};
35
37
  Stdlib_Dict.forEachWithKey(described, (value, key) => {
@@ -44,16 +46,31 @@ function mergedUpdateInput(described, userPoolId, preTokenGenerationArn) {
44
46
  out["UserPoolId"] = userPoolId;
45
47
  let lambdaConfig = Stdlib_Option.mapOr(Stdlib_Option.flatMap(described["LambdaConfig"], Stdlib_JSON.Decode.object), {}, existing => Object.fromEntries(Object.entries(existing)));
46
48
  if (preTokenGenerationArn !== undefined) {
49
+ let config = {};
50
+ config["LambdaArn"] = preTokenGenerationArn;
51
+ config["LambdaVersion"] = triggerVersion;
47
52
  lambdaConfig["PreTokenGeneration"] = preTokenGenerationArn;
53
+ lambdaConfig["PreTokenGenerationConfig"] = config;
48
54
  } else {
49
55
  Stdlib_Dict.$$delete(lambdaConfig, "PreTokenGeneration");
56
+ Stdlib_Dict.$$delete(lambdaConfig, "PreTokenGenerationConfig");
50
57
  }
51
58
  out["LambdaConfig"] = lambdaConfig;
52
59
  return out;
53
60
  }
54
61
 
55
62
  function attachedTrigger(described) {
56
- return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(described["LambdaConfig"], Stdlib_JSON.Decode.object), c => c["PreTokenGeneration"]), Stdlib_JSON.Decode.string);
63
+ let lambdaConfig = Stdlib_Option.flatMap(described["LambdaConfig"], Stdlib_JSON.Decode.object);
64
+ let config = Stdlib_Option.flatMap(Stdlib_Option.flatMap(lambdaConfig, c => c["PreTokenGenerationConfig"]), Stdlib_JSON.Decode.object);
65
+ if (config === undefined) {
66
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(lambdaConfig, c => c["PreTokenGeneration"]), Stdlib_JSON.Decode.string);
67
+ }
68
+ let version = Stdlib_Option.flatMap(config["LambdaVersion"], Stdlib_JSON.Decode.string);
69
+ if (version !== undefined && version !== triggerVersion) {
70
+ return;
71
+ } else {
72
+ return Stdlib_Option.flatMap(config["LambdaArn"], Stdlib_JSON.Decode.string);
73
+ }
57
74
  }
58
75
 
59
76
  let newOf1 = ((C, x) => new C(x));
@@ -310,6 +327,7 @@ function make(nameOpt, props, opts) {
310
327
  export {
311
328
  log,
312
329
  readOnlyKeys,
330
+ triggerVersion,
313
331
  mergedUpdateInput,
314
332
  attachedTrigger,
315
333
  newOf1,
@@ -704,7 +704,7 @@ let collectEventTags = (
704
704
  // local backends' true DCB query semantics (and fixing analysis Issue 4 — the
705
705
  // one-event-type-per-attribute deadlock). It also folds the create guard into the
706
706
  // fence (see `buildConditionalFenceUpdate`), retiring the `create#` rows.
707
- // Plan: docs/plans/dcb-fence-event-type-granularity.md.
707
+ // Plan: docs/plans/done/dcb-fence-event-type-granularity.md.
708
708
  let fenceTypeAttr = (eventType: string) => `pos#${eventType}`
709
709
 
710
710
  let dedupStrings = (xs: array<string>): array<string> => {
@@ -975,7 +975,7 @@ let appendUnconditional = async (
975
975
  // fan-out sharing those prefixes turns into a hot partition → `TransactionConflict`
976
976
  // → `retries exhausted`; it also over-fenced (two DISTINCT composite entities
977
977
  // sharing a member value serialized needlessly). Plan:
978
- // docs/plans/Backlog/dcb-hot-tag-fence-contention.md § "Root-cause correction".
978
+ // docs/plans/done/dcb-hot-tag-fence-contention.md § "Root-cause correction".
979
979
  let compositeFenceTagKey = "__dcb_composite__"
980
980
 
981
981
  let makeCompositeFenceTag = (
@@ -11,7 +11,7 @@
11
11
  // The pool is lazy (`PgRuntime.poolFor`), so binding ops inside the deploy-time
12
12
  // `Output.apply` opens no connection during `pulumi up` — a real query only runs
13
13
  // inside the DcbCommandTopic Lambda at runtime. See
14
- // docs/plans/aws-postgres-change-feed-bridge.md.
14
+ // docs/plans/done/aws-postgres-change-feed-bridge.md.
15
15
 
16
16
  let make: ReventlessCore.DcbEventLog_Adapter.storageMaker = (
17
17
  ~name,
@@ -71,7 +71,7 @@ let append = table =>
71
71
  )
72
72
  ->Effect.runPromise
73
73
 
74
- // ─── Aggregate snapshots (docs/plans/aggregate-snapshotting.md) ───
74
+ // ─── Aggregate snapshots (docs/plans/done/aggregate-snapshotting.md) ───
75
75
  // A snapshot lives in the SAME table as the events, on the reserved sort key
76
76
  // `position = "SNAPSHOT"`. Event positions are 9-digit zero-padded numbers, so
77
77
  // bounding the replay query to the all-digit range excludes the snapshot row
@@ -5,7 +5,7 @@
5
5
  // consumes — onto that queue. The plugin EventCollector (`EventCollectorEntryPoint`)
6
6
  // then drives the full fan-out: read-model projections, aggregate command topics,
7
7
  // and the cross-plugin SNS EventTopic. See
8
- // docs/plans/aws-postgres-change-feed-bridge.md.
8
+ // docs/plans/done/aws-postgres-change-feed-bridge.md.
9
9
  //
10
10
  // The transform reuses the canonical DynamoDB shape producers
11
11
  // (`derivePartitionKey` + `buildJsonEvent'`) so the emitted body is byte-identical
@@ -1,5 +1,5 @@
1
1
  // B3.0 — projection delivery on Postgres platforms
2
- // (docs/plans/aws-postgres-querydb-adapter.md).
2
+ // (docs/plans/done/aws-postgres-querydb-adapter.md).
3
3
  //
4
4
  // On DynamoDB platforms the ReadModel / StateViewSlice Lambdas consume their
5
5
  // source event logs via DynamoDB-stream event-source mappings. Postgres logs have
@@ -1,6 +1,6 @@
1
1
  // AWS-side selection for the QueryDb (read model) storage backend — the QueryDb
2
2
  // analogue of `EventLogBackend` / `DcbBackend` (B3.1,
3
- // docs/plans/aws-postgres-querydb-adapter.md).
3
+ // docs/plans/done/aws-postgres-querydb-adapter.md).
4
4
  //
5
5
  // When `Platform.MakeWithConfig` is given a `~pgConnection`, it records the
6
6
  // resolved selection here BEFORE any admin/plugin `construct` runs. Read by:
@@ -4,7 +4,7 @@ open PulumiAws.AppSync
4
4
  // `NotFoundException: No field named X` (the schema-propagation race) with
5
5
  // exponential backoff. Replaces AppSync_Resolver_Native (aws-native provider)
6
6
  // which exhibited the same race in production.
7
- // See docs/plans/appsync-resolver-aws-native-retry.md.
7
+ // See docs/plans/done/appsync-resolver-aws-native-retry.md.
8
8
  module Resolver = AppSync_Resolver_Retrying
9
9
  open Reventless.ReadModel
10
10
 
@@ -55,7 +55,7 @@ export function response(ctx) {
55
55
  that carry no `name`. Returning `Some(attr)` makes the Connection Scan emit an
56
56
  `attribute_exists(#attr)` filter so those rows never reach the non-null GraphQL schema.
57
57
  `name` is the already-capitalized read-model name (spec name, e.g. "Plugins").
58
- See docs/plans/platform-plugins-admin-connection-null-rows.md. */
58
+ See docs/plans/done/platform-plugins-admin-connection-null-rows.md. */
59
59
  let internalRowRequiredAttr = (name: string): option<string> =>
60
60
  name == "Plugins" ? Some("name") : None
61
61
 
@@ -69,7 +69,7 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
69
69
  ~idResolverConfigs: array<idResolverConfig>,
70
70
  ~idsResolverConfigs: array<idsResolverConfig>,
71
71
  // AWS path enforces authorization via `@aws_cognito_user_pools(cognito_groups: …)` on the
72
- // SDL fields (see Stage E in docs/plans/host-ui-login-core.md). Accepted but
72
+ // SDL fields (see Stage E in docs/plans/done/host-ui-login-core.md). Accepted but
73
73
  // ignored here so the in-memory and AWS resolver maker signatures stay aligned.
74
74
  ~authorization as _: Reventless.Authorization.permission,
75
75
  ~opts,
@@ -167,13 +167,30 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
167
167
  // This prevents a race condition where AppSync resolvers are deployed before
168
168
  // the schema push completes and the new fields are ACTIVE in AppSync.
169
169
  let resourcesMaker: ReventlessCore.QueryDb.resolversResourcesMaker = allQueryDbs => {
170
+ // The state's `@owner` field, derived from the same schema `capability` is
171
+ // derived from, so the two cannot disagree about this read model's fields.
172
+ //
173
+ // Read HERE, at the top, rather than beside the list resolver that first
174
+ // needed it. The by-id resolvers are built a few lines below and the lookup
175
+ // used to sit well after them, so they were generated without it: a list
176
+ // narrowed to the caller beside a by-id read that hands over any row it is
177
+ // asked for. That is not a partial delivery — the row is reachable by
178
+ // anyone who can name one, and the list gives no sign of it, because the
179
+ // list is correct.
180
+ let stateSchemaOpt = ReventlessCore.Plugin_Helpers.stateSchemaRegistry->Dict.get(name)
181
+ let ownerField = switch stateSchemaOpt {
182
+ | Some(s) => Reventless.Owner.fieldNames(s)->Array.get(0)
183
+ | None => None
184
+ }
185
+ let elevatedGroups = Reventless.OwnerScope.elevatedGroups()
186
+
170
187
  let resolverByIdSingle = if includeIdParam {
171
188
  makeQueryResolver(
172
189
  ~resolverName=fieldNameForSingle->String.capitalize,
173
190
  ~field=fieldNameForSingle->Pulumi.Input.make,
174
191
  ~code=switch subIdField {
175
- | Some(sortField) => Resolver.Functions.queryByIdSort(sortField)
176
- | None => Resolver.Functions.getItemById
192
+ | Some(sortField) => Resolver.Functions.queryByIdSort(sortField, ~ownerField?, ~elevatedGroups)
193
+ | None => Resolver.Functions.getItemById(~ownerField?, ~elevatedGroups)
177
194
  },
178
195
  )
179
196
  } else {
@@ -188,7 +205,11 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
188
205
  makeQueryResolver(
189
206
  ~resolverName=fieldNameForSingle->String.capitalize ++ "Items",
190
207
  ~field=(fieldNameForSingle ++ "Items")->Pulumi.Input.make,
191
- ~code=Resolver.Functions.queryItemsWithSortConditions(sortField),
208
+ ~code=Resolver.Functions.queryItemsWithSortConditions(
209
+ sortField,
210
+ ~ownerField?,
211
+ ~elevatedGroups,
212
+ ),
192
213
  )
193
214
  )
194
215
  } else {
@@ -203,7 +224,6 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
203
224
  // registered or has no indexable fields — the resolver template emits the same
204
225
  // SDL the in-memory adapter does, so the AWS Filter / OrderBy stays in lockstep
205
226
  // with the SDL emitted by GraphQL_FragmentGenerator at runtime.
206
- let stateSchemaOpt = ReventlessCore.Plugin_Helpers.stateSchemaRegistry->Dict.get(name)
207
227
  let capability = switch stateSchemaOpt {
208
228
  | Some(s) => ReventlessCore.GraphQL_FragmentGenerator.deriveServerCapability(~entityName=name, s)
209
229
  | None => ReventlessCore.GraphQL_FragmentGenerator.emptyCapability
@@ -236,14 +256,8 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
236
256
  // resolves `name: String!` to null → non-null violation that nulls the entire
237
257
  // Platform_PluginConnection. Exclude them with an `attribute_exists(#name)` filter
238
258
  // (prefix-agnostic: real plugin rows always carry `name`, internal rows never do).
239
- // See docs/plans/platform-plugins-admin-connection-null-rows.md.
259
+ // See docs/plans/done/platform-plugins-admin-connection-null-rows.md.
240
260
  let requireAttribute = internalRowRequiredAttr(name)
241
- // Derived from the same schema `capability` came from, so the two cannot
242
- // disagree about this read model's fields.
243
- let ownerField = switch stateSchemaOpt {
244
- | Some(s) => Reventless.Owner.fieldNames(s)->Array.get(0)
245
- | None => None
246
- }
247
261
  // A DynamoDB FilterExpression is applied AFTER the page is read, so a scoped
248
262
  // list over a table with no index on the owner field returns short pages —
249
263
  // correct, but pathological once a caller owns a small fraction of the rows.
@@ -310,7 +324,7 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
310
324
  ~sortFields=sortFieldNames,
311
325
  ~requireAttribute?,
312
326
  ~ownerField?,
313
- ~elevatedGroups=Reventless.OwnerScope.elevatedGroups(),
327
+ ~elevatedGroups,
314
328
  ~retiredField?,
315
329
  ~retiredValues?,
316
330
  )
@@ -91,10 +91,12 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
91
91
  };
92
92
  let fieldNameForAll = registryEntry !== undefined ? registryEntry.listFieldName : name$1 + "s";
93
93
  let resourcesMaker = allQueryDbs => {
94
- let resolverByIdSingle = includeIdParam ? makeQueryResolver(Stdlib_String.capitalize(fieldNameForSingle), fieldNameForSingle, subIdField !== undefined ? AppSync_Resolver_Functions$PulumiAws.queryByIdSort(subIdField) : AppSync_Resolver_Functions$PulumiAws.getItemById) : makeQueryResolver(Stdlib_String.capitalize(fieldNameForSingle), fieldNameForSingle, AppSync_Resolver_Functions$PulumiAws.listAllItems);
95
- let resolverByIdMultiple = includeIdParam ? Stdlib_Option.map(subIdField, sortField => makeQueryResolver(Stdlib_String.capitalize(fieldNameForSingle) + "Items", fieldNameForSingle + "Items", AppSync_Resolver_Functions$PulumiAws.queryItemsWithSortConditions(sortField))) : undefined;
96
- let labelField = registryEntry !== undefined ? Stdlib_Option.getOr(registryEntry.labelField, "id") : "id";
97
94
  let stateSchemaOpt = Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name$1];
95
+ let ownerField = stateSchemaOpt !== undefined ? Owner$Reventless.fieldNames(stateSchemaOpt)[0] : undefined;
96
+ let elevatedGroups = OwnerScope$Reventless.elevatedGroups();
97
+ let resolverByIdSingle = includeIdParam ? makeQueryResolver(Stdlib_String.capitalize(fieldNameForSingle), fieldNameForSingle, subIdField !== undefined ? AppSync_Resolver_Functions$PulumiAws.queryByIdSort(subIdField, ownerField, elevatedGroups) : AppSync_Resolver_Functions$PulumiAws.getItemById(ownerField, elevatedGroups)) : makeQueryResolver(Stdlib_String.capitalize(fieldNameForSingle), fieldNameForSingle, AppSync_Resolver_Functions$PulumiAws.listAllItems);
98
+ let resolverByIdMultiple = includeIdParam ? Stdlib_Option.map(subIdField, sortField => makeQueryResolver(Stdlib_String.capitalize(fieldNameForSingle) + "Items", fieldNameForSingle + "Items", AppSync_Resolver_Functions$PulumiAws.queryItemsWithSortConditions(sortField, ownerField, elevatedGroups))) : undefined;
99
+ let labelField = registryEntry !== undefined ? Stdlib_Option.getOr(registryEntry.labelField, "id") : "id";
98
100
  let capability = stateSchemaOpt !== undefined ? GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(name$1, stateSchemaOpt) : GraphQL_FragmentGenerator$ReventlessCore.emptyCapability;
99
101
  let filterFieldNames = capability.filterFields.map(f => f.name);
100
102
  let rangeFieldNames = Stdlib_Array.filterMap(capability.filterFields, f => {
@@ -110,7 +112,6 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
110
112
  GraphQL_FragmentGenerator$ReventlessCore.validateScanSortAlignment(stateSchemaOpt, name$1, knownSortFields).forEach(msg => log.warn("QueryDbResolvers_AppSync", undefined, msg));
111
113
  }
112
114
  let requireAttribute = internalRowRequiredAttr(name$1);
113
- let ownerField = stateSchemaOpt !== undefined ? Owner$Reventless.fieldNames(stateSchemaOpt)[0] : undefined;
114
115
  OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("QueryDbResolvers_AppSync", name$1, ownerField);
115
116
  let isIndexed = f => {
116
117
  if (indexes.some(ic => Stdlib_Option.getOr(ic.idField, ic.index) === f)) {
@@ -128,7 +129,7 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
128
129
  if (retiredField !== undefined && !isIndexed(retiredField)) {
129
130
  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.");
130
131
  }
131
- let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute, ownerField, OwnerScope$Reventless.elevatedGroups(), retiredField, retiredValues) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
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);
132
133
  let resolversByIndex = indexes.map(indexConfig => {
133
134
  let index = indexConfig.index;
134
135
  let stripLeadingBy = s => {
@@ -147,7 +147,7 @@ let make: ReventlessCore.QueryDb_Adapter.queryEngineMaker = allQueryDbs => {
147
147
  // B3.1: Postgres-backed read models have NO DynamoDB resource — exclude them
148
148
  // instead of failing findResource at deploy time. They are not reachable via
149
149
  // this engine (getRuntimeResource raises its explicit not-found error at
150
- // query time); see docs/plans/aws-postgres-querydb-adapter.md § B3.1b.
150
+ // query time); see docs/plans/done/aws-postgres-querydb-adapter.md § B3.1b.
151
151
  let dynamoDbQueryDbs =
152
152
  allQueryDbs
153
153
  ->Dict.toArray
@@ -22,7 +22,7 @@ import { commandOutcomeToJson, runInlineAndCollect } from "@reventlessdev/revent
22
22
  import { handleQueueEvent, publishJsons as sqsPublishJsons } from "@reventlessdev/reventless-aws/src/adapter/CommandTopic/CommandTopicChannel_SQS_Runtime.res.mjs";
23
23
  // Typed cold-start core — EventLog storage-ops selection/wiring, compiler-checked
24
24
  // against the framework signatures (see the module header and
25
- // docs/plans/minimize-lambda-entrypoint-mjs-shell.md).
25
+ // docs/plans/done/minimize-lambda-entrypoint-mjs-shell.md).
26
26
  import { makeStorageOps } from "./AggregateEntryPoint_Ops.res.mjs";
27
27
  import { DynamoDBClient, ScanCommand } from "@aws-sdk/client-dynamodb";
28
28
  import { unmarshall } from "@aws-sdk/util-dynamodb";
@@ -1,6 +1,6 @@
1
1
  // Typed cold-start core for the Aggregate Lambda entry point.
2
2
  //
3
- // The "typed core, thin shell" split (docs/plans/minimize-lambda-entrypoint-mjs-shell.md).
3
+ // The "typed core, thin shell" split (docs/plans/done/minimize-lambda-entrypoint-mjs-shell.md).
4
4
  // The `.mjs` shell owns the untyped seams — reading `HANDLER_CONFIG`, the dynamic
5
5
  // `import()` of user Spec/Behavior modules, and the functor wiring that consumes
6
6
  // those runtime-loaded modules (`EventLog_Operations.Make`, `Aggregate_Callback.Make`,
@@ -4,7 +4,7 @@
4
4
  // by source URN.
5
5
  //
6
6
  // Thin untyped shell ("typed core, thin shell" —
7
- // docs/plans/minimize-lambda-entrypoint-mjs-shell.md): this file owns ONLY the
7
+ // docs/plans/done/minimize-lambda-entrypoint-mjs-shell.md): this file owns ONLY the
8
8
  // inherently-untyped seam — the dynamic `import()` of the spec + body modules
9
9
  // named in HANDLER_CONFIG and the curried functor applications consuming them
10
10
  // (AutomationSlice_Callback.Make(Spec)(Automation) /
@@ -1,7 +1,7 @@
1
1
  // Typed cold-start core for the AutomationSlice / OutboundTranslationSlice
2
2
  // Lambda entry point.
3
3
  //
4
- // The "typed core, thin shell" split (docs/plans/minimize-lambda-entrypoint-mjs-shell.md):
4
+ // The "typed core, thin shell" split (docs/plans/done/minimize-lambda-entrypoint-mjs-shell.md):
5
5
  // AutomationSliceEntryPoint.mjs keeps only the inherently-untyped seam — the
6
6
  // dynamic `import()` of the spec + body modules named in HANDLER_CONFIG and
7
7
  // the curried callback-functor applications consuming them
@@ -4,7 +4,7 @@
4
4
  // Routes DynamoDB Stream events from references and counts tables.
5
5
  //
6
6
  // Thin untyped shell ("typed core, thin shell" —
7
- // docs/plans/minimize-lambda-entrypoint-mjs-shell.md): this file owns ONLY the
7
+ // docs/plans/done/minimize-lambda-entrypoint-mjs-shell.md): this file owns ONLY the
8
8
  // seams that are inherently untyped — the dynamic `import()` of the
9
9
  // target-spec/mappings modules named in HANDLER_CONFIG, the `patchSpecId`
10
10
  // fix-up, and the MakeCounterHandler functor application consuming them.
@@ -1,6 +1,6 @@
1
1
  // Typed cold-start core for the Counter Lambda entry point.
2
2
  //
3
- // The "typed core, thin shell" split (docs/plans/minimize-lambda-entrypoint-mjs-shell.md):
3
+ // The "typed core, thin shell" split (docs/plans/done/minimize-lambda-entrypoint-mjs-shell.md):
4
4
  // CounterEntryPoint.mjs keeps only the untyped seam — the dynamic `import()`
5
5
  // of the target-spec/mappings modules named in HANDLER_CONFIG, the
6
6
  // `patchSpecId` fix-up, and the EventMapper_Callback.MakeCounterHandler
@@ -27,7 +27,7 @@ import { handleQueueEvent, publishJsons as sqsPublishJsons } from "@reventlessde
27
27
  // Typed cold-start core — scope/partitionTag derivation, storage-ops wiring, and
28
28
  // per-slice handler building (functor + decode + handleCommands), all
29
29
  // compiler-checked against the framework signatures (see the module header and
30
- // docs/plans/minimize-lambda-entrypoint-mjs-shell.md).
30
+ // docs/plans/done/minimize-lambda-entrypoint-mjs-shell.md).
31
31
  import { deriveScope, commandTypeNames, makeStorageOps, buildSliceHandler, buildInboundReceiver } from "./DcbCommandTopicEntryPoint_Ops.res.mjs";
32
32
  import { makeDynamoQueryDbOps } from "./QueryDbEntryPoint_Ops.res.mjs";
33
33
 
@@ -95,6 +95,7 @@ export async function buildHandlersForConfig(config, opts = {}) {
95
95
  );
96
96
 
97
97
  const handlersByType = {};
98
+ const commandSchemasByType = {};
98
99
  const sharedDcbEventLogOps = dcbEventLogOperationsMake({
99
100
  name: config.pluginName,
100
101
  // Mirrors DcbEventLog_Builder.res's `name ++ "DcbEventLog"` — the
@@ -119,6 +120,19 @@ export async function buildHandlersForConfig(config, opts = {}) {
119
120
  );
120
121
  for (const typeName of commandTypeNames(patchedSpec)) {
121
122
  handlersByType[typeName] = jsonHandler;
123
+ // The schema this command's owner fields are read from. Kept beside the
124
+ // handler because the two answer the same question — "which slice is this
125
+ // command?" — and a command routed to a handler but not to a schema is
126
+ // exactly the state that stamps nothing while looking like it worked.
127
+ if (commandSchemasByType[typeName] !== undefined) {
128
+ log.error(
129
+ "two DCB slices declare the command \"" + typeName + "\"; owner stamping " +
130
+ "resolves a command by that name alone and cannot tell them apart",
131
+ { comp: "DcbCommandTopicRuntime" },
132
+ );
133
+ } else {
134
+ commandSchemasByType[typeName] = patchedSpec.commandSchema;
135
+ }
122
136
  }
123
137
  });
124
138
 
@@ -191,9 +205,7 @@ export async function buildHandlersForConfig(config, opts = {}) {
191
205
  // Sync (default): inline-dispatch the command via the same composite handler
192
206
  // that Route 2 uses, so the AppSync resolver gets a typed Accepted/Rejected
193
207
  // outcome. Async: undefined → makeCommandGenerator falls back to publishJsons
194
- // and returns Pending. The schema is permissive (S.json) because AppSync has
195
- // already validated input against the SDL — per-slice schemas reapply inside
196
- // the typed core's buildSliceHandler decode.
208
+ // and returns Pending.
197
209
  const publishJsonsAndWait = DISPATCH_MODE === "async"
198
210
  ? undefined
199
211
  : (jsons) => runInlineAndCollect(jsons, compositeJsonCommandsHandler);
@@ -202,14 +214,31 @@ export async function buildHandlersForConfig(config, opts = {}) {
202
214
  // the framework signature. stripIdFromParams=false: DCB slices may declare a
203
215
  // literal `id` field as part of the command schema (composite partition keys
204
216
  // etc.) — don't strip it.
205
- const generateCommand = makeCommandGenerator(
217
+ //
218
+ // One generator per command, differing only in the schema. This used to be a
219
+ // single generator built on the permissive `jsonSchema`, on the still-true
220
+ // grounds that AppSync has already validated input against the SDL and that
221
+ // per-slice schemas reapply inside `buildSliceHandler`'s decode. What that
222
+ // reasoning did not cover is that the schema is also where `makeGenerateCommand`
223
+ // reads a command's `@owner` fields from: given `S.json` it finds none, for every
224
+ // command, and the write keeps whatever owner the CLIENT sent. Validation was
225
+ // never the only job.
226
+ const makeGeneratorFor = (commandSchema) => makeCommandGenerator(
206
227
  publishJsons,
207
228
  publishJsonsAndWait,
208
229
  config.pluginName,
209
- jsonSchema,
230
+ commandSchema,
210
231
  "StateChangeSlice",
211
232
  false,
212
233
  );
234
+ const generatorsByType = {};
235
+ for (const typeName of Object.keys(commandSchemasByType)) {
236
+ generatorsByType[typeName] = makeGeneratorFor(commandSchemasByType[typeName]);
237
+ }
238
+ // A command no loaded slice claims keeps the permissive generator — what every
239
+ // command got before this change — so anything reaching this handler by another
240
+ // route behaves as it did. It also cannot stamp, hence the warning.
241
+ const generateFallback = makeGeneratorFor(jsonSchema);
213
242
 
214
243
  const cmdGenHandler = (event) => {
215
244
  // CommandGenerator.meta declares ip as array<string> (X-Forwarded-For chain).
@@ -225,6 +254,15 @@ export async function buildHandlersForConfig(config, opts = {}) {
225
254
  groups: [],
226
255
  provider: { TAG: "Custom", _0: "aws" },
227
256
  };
257
+ let generateCommand = generatorsByType[event.command];
258
+ if (generateCommand === undefined) {
259
+ log.warn(
260
+ "no DCB slice declares the command \"" + event.command + "\"; dispatching " +
261
+ "without an owner stamp, so any @owner field keeps the value the caller sent",
262
+ { comp: "DcbCommandTopicRuntime" },
263
+ );
264
+ generateCommand = generateFallback;
265
+ }
228
266
  return generateCommand({ ...event, meta, identity });
229
267
  };
230
268
 
@@ -1,6 +1,6 @@
1
1
  // Typed cold-start core for the DCB CommandTopic Lambda entry point.
2
2
  //
3
- // The "typed core, thin shell" split (docs/plans/minimize-lambda-entrypoint-mjs-shell.md).
3
+ // The "typed core, thin shell" split (docs/plans/done/minimize-lambda-entrypoint-mjs-shell.md).
4
4
  // The `.mjs` shell owns the one boundary that is inherently untyped: reading
5
5
  // `HANDLER_CONFIG` and dynamically `import()`-ing user Spec/Behavior modules at
6
6
  // cold start, whose types are unknowable here. Everything below is compiler-