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

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 (67) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +8 -8
  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/PgQueryResolver_Lambda.res +64 -0
  14. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +54 -2
  15. package/src/adapter/QueryDb/QueryDbBackend.res +1 -1
  16. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +138 -46
  17. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +25 -21
  18. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +19 -11
  19. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +11 -10
  20. package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res +1 -1
  21. package/src/adapter/Runtime/AggregateEntryPoint.mjs +1 -1
  22. package/src/adapter/Runtime/AggregateEntryPoint_Ops.res +1 -1
  23. package/src/adapter/Runtime/AutomationSliceEntryPoint.mjs +1 -1
  24. package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +1 -1
  25. package/src/adapter/Runtime/CounterEntryPoint.mjs +1 -1
  26. package/src/adapter/Runtime/CounterEntryPoint_Ops.res +1 -1
  27. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +44 -6
  28. package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res +1 -1
  29. package/src/adapter/Runtime/EventCollectorEntryPoint.mjs +1 -1
  30. package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res +1 -1
  31. package/src/adapter/Runtime/EventMapperEntryPoint.mjs +1 -1
  32. package/src/adapter/Runtime/EventMapperEntryPoint_Ops.res +1 -1
  33. package/src/adapter/Runtime/ExtensionPointEntryPoint.mjs +1 -1
  34. package/src/adapter/Runtime/ExtensionPointEntryPoint_Ops.res +1 -1
  35. package/src/adapter/Runtime/PgQueryResolverEntryPoint.mjs +1 -1
  36. package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res +7 -1
  37. package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res.mjs +2 -1
  38. package/src/adapter/Runtime/PluginExtensionPointEntryPoint.res +1 -1
  39. package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res +1 -1
  40. package/src/adapter/Runtime/QueryDbEntryPoint_Ops.res +1 -1
  41. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +1 -1
  42. package/src/adapter/Runtime/ReadModelEntryPoint_Ops.res +1 -1
  43. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +1 -1
  44. package/src/adapter/Runtime/SideEffectEntryPoint.mjs +1 -1
  45. package/src/adapter/Runtime/SideEffectEntryPoint_Ops.res +1 -1
  46. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +1 -1
  47. package/src/adapter/Runtime/StateViewSliceEntryPoint_Ops.res +1 -1
  48. package/src/adapter/Runtime/StreamRoutedEntryPoint_Ops.res +1 -1
  49. package/src/adapter/Runtime/TaskBucketEntryPoint.mjs +1 -1
  50. package/src/adapter/Runtime/TaskBucketEntryPoint_Ops.res +1 -1
  51. package/src/util/Util_Bundle.res +1 -1
  52. package/src/util/Util_DynamoDbStream_Runtime.res +1 -1
  53. package/tests/AWS_TagsTest.res +2 -2
  54. package/tests/AppSync_RetirementNarrowingTest.res +272 -0
  55. package/tests/AppSync_RetirementNarrowingTest.res.mjs +193 -0
  56. package/tests/Auth_ActiveRolePoolAttachmentTest.res +140 -1
  57. package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +117 -2
  58. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +2 -2
  59. package/tests/EventLogStorage_DynamoDb_RuntimeTest.res +1 -1
  60. package/tests/PgQueryResolver_LambdaTest.res +2 -0
  61. package/tests/PgQueryResolver_LambdaTest.res.mjs +34 -30
  62. package/tests/QueryDbResolvers_AppSyncTest.res +2 -2
  63. package/tests/Util_BundleEsmLoaderTest.res +1 -1
  64. package/tests/Util_BundleRuntimeExtensionTest.res +1 -1
  65. package/tests/integration/AggregateEntryPoint_IntegrationTest.res +1 -1
  66. package/tests/integration/DcbCommandTopicEntryPoint_IntegrationTest.res +1 -1
  67. package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res +2 -2
@@ -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,55 @@ 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
+
187
+ // Read here for exactly the reason `ownerField` above is, and after making
188
+ // the same mistake: the by-id, by-ids and by-index resolvers are built below
189
+ // and this lookup used to sit past all of them, so they were generated
190
+ // without a retirement predicate — a list that withheld archived rows beside
191
+ // by-key doors that handed any one of them over on request.
192
+ let retiredSpec =
193
+ stateSchemaOpt
194
+ ->Option.flatMap(Reventless.StateAnnotations.getSpec)
195
+ ->Option.flatMap(spec => spec.retired)
196
+ let retiredField = retiredSpec->Option.map(r => r.field)
197
+ let retiredValues = retiredSpec->Option.flatMap(r => r.values)
198
+
170
199
  let resolverByIdSingle = if includeIdParam {
171
200
  makeQueryResolver(
172
201
  ~resolverName=fieldNameForSingle->String.capitalize,
173
202
  ~field=fieldNameForSingle->Pulumi.Input.make,
174
203
  ~code=switch subIdField {
175
- | Some(sortField) => Resolver.Functions.queryByIdSort(sortField)
176
- | None => Resolver.Functions.getItemById
204
+ | Some(sortField) =>
205
+ Resolver.Functions.queryByIdSort(
206
+ sortField,
207
+ ~ownerField?,
208
+ ~elevatedGroups,
209
+ ~retiredField?,
210
+ ~retiredValues?,
211
+ )
212
+ | None =>
213
+ Resolver.Functions.getItemById(
214
+ ~ownerField?,
215
+ ~elevatedGroups,
216
+ ~retiredField?,
217
+ ~retiredValues?,
218
+ )
177
219
  },
178
220
  )
179
221
  } else {
@@ -188,7 +230,11 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
188
230
  makeQueryResolver(
189
231
  ~resolverName=fieldNameForSingle->String.capitalize ++ "Items",
190
232
  ~field=(fieldNameForSingle ++ "Items")->Pulumi.Input.make,
191
- ~code=Resolver.Functions.queryItemsWithSortConditions(sortField),
233
+ ~code=Resolver.Functions.queryItemsWithSortConditions(
234
+ sortField,
235
+ ~ownerField?,
236
+ ~elevatedGroups,
237
+ ),
192
238
  )
193
239
  )
194
240
  } else {
@@ -203,7 +249,6 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
203
249
  // registered or has no indexable fields — the resolver template emits the same
204
250
  // SDL the in-memory adapter does, so the AWS Filter / OrderBy stays in lockstep
205
251
  // with the SDL emitted by GraphQL_FragmentGenerator at runtime.
206
- let stateSchemaOpt = ReventlessCore.Plugin_Helpers.stateSchemaRegistry->Dict.get(name)
207
252
  let capability = switch stateSchemaOpt {
208
253
  | Some(s) => ReventlessCore.GraphQL_FragmentGenerator.deriveServerCapability(~entityName=name, s)
209
254
  | None => ReventlessCore.GraphQL_FragmentGenerator.emptyCapability
@@ -236,14 +281,8 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
236
281
  // resolves `name: String!` to null → non-null violation that nulls the entire
237
282
  // Platform_PluginConnection. Exclude them with an `attribute_exists(#name)` filter
238
283
  // (prefix-agnostic: real plugin rows always carry `name`, internal rows never do).
239
- // See docs/plans/platform-plugins-admin-connection-null-rows.md.
284
+ // See docs/plans/done/platform-plugins-admin-connection-null-rows.md.
240
285
  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
286
  // A DynamoDB FilterExpression is applied AFTER the page is read, so a scoped
248
287
  // list over a table with no index on the owner field returns short pages —
249
288
  // correct, but pathological once a caller owns a small fraction of the rows.
@@ -271,12 +310,6 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
271
310
  }
272
311
  | None => ()
273
312
  }
274
- let retiredSpec =
275
- stateSchemaOpt
276
- ->Option.flatMap(Reventless.StateAnnotations.getSpec)
277
- ->Option.flatMap(spec => spec.retired)
278
- let retiredField = retiredSpec->Option.map(r => r.field)
279
- let retiredValues = retiredSpec->Option.flatMap(r => r.values)
280
313
  // The same class of "works, but scans" mistake as the owner warning above,
281
314
  // and the retirement case degrades the same way: the FilterExpression is
282
315
  // applied after the page is read, so pages shrink as the archive's share of
@@ -310,7 +343,7 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
310
343
  ~sortFields=sortFieldNames,
311
344
  ~requireAttribute?,
312
345
  ~ownerField?,
313
- ~elevatedGroups=Reventless.OwnerScope.elevatedGroups(),
346
+ ~elevatedGroups,
314
347
  ~retiredField?,
315
348
  ~retiredValues?,
316
349
  )
@@ -319,26 +352,16 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
319
352
  },
320
353
  )
321
354
  let resolversByIndex = indexes->Array.map(({index} as indexConfig) => {
322
- // Strip a leading "by" from the index name before capitalizing to avoid
323
- // double "By" in the generated field name (e.g. index "byPlugin" "ByPlugin",
324
- // not "ByByPlugin"). Uses the plugin-prefixed fieldNameForSingle so the
325
- // resolver name is properly namespaced (e.g. "schemaHistoryByPlugin", not
326
- // "SchemaHistoryByByPlugin" from the raw entity name).
327
- let stripLeadingBy = s =>
328
- if s->String.startsWith("by") && s->String.length > 2 {
329
- s->String.slice(~start=2, ~end=s->String.length)
330
- } else {
331
- s
332
- }
333
- let resolverName =
334
- fieldNameForSingle->String.capitalize ++
335
- "By" ++
336
- (index->stripLeadingBy->String.capitalize)
337
- let fieldName =
338
- fieldNameForSingle ++
339
- "By" ++
340
- (index->stripLeadingBy->String.capitalize)
341
- let idField = indexConfig.idField->Option.getOr(index)
355
+ // Name and key field both come from `GraphQL_FragmentGenerator`, which is
356
+ // where the SDL field this attaches to is derived. Deriving them here as
357
+ // well is how the two came to disagree: the emitted field declared `id`
358
+ // while this resolver read the index key, so the door could not be called.
359
+ let fieldName = ReventlessCore.GraphQL_FragmentGenerator.indexQueryFieldName(
360
+ ~singleFieldName=fieldNameForSingle,
361
+ ~index,
362
+ )
363
+ let resolverName = fieldName->String.capitalize
364
+ let idField = ReventlessCore.GraphQL_FragmentGenerator.indexKeyField(indexConfig)
342
365
  switch indexConfig.authorization {
343
366
  | None =>
344
367
  makeQueryResolver(
@@ -346,8 +369,24 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
346
369
  ~field=fieldName->Pulumi.Input.make,
347
370
  ~code=switch indexConfig.subIdField {
348
371
  | Some(sortField) =>
349
- Resolver.Functions.queryByIndexSortFiltered(~index, ~idField, ~sortField)
350
- | None => Resolver.Functions.queryByIndexFiltered(~index, ~idField)
372
+ Resolver.Functions.queryByIndexSortFiltered(
373
+ ~index,
374
+ ~idField,
375
+ ~sortField,
376
+ ~ownerField?,
377
+ ~retiredField?,
378
+ ~retiredValues?,
379
+ ~elevatedGroups,
380
+ )
381
+ | None =>
382
+ Resolver.Functions.queryByIndexFiltered(
383
+ ~index,
384
+ ~idField,
385
+ ~ownerField?,
386
+ ~retiredField?,
387
+ ~retiredValues?,
388
+ ~elevatedGroups,
389
+ )
351
390
  },
352
391
  )
353
392
  | Some({tableName, group}) =>
@@ -373,7 +412,20 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
373
412
  ~name=resolverName,
374
413
  ~api,
375
414
  ~dataSource=dataSourceName,
376
- ~code=Resolver.Functions.queryByIndexFiltered(~index, ~idField),
415
+ // No `~ownerField`, deliberately: this index declares its own
416
+ // authorization, and the rows it grants a group member are by
417
+ // construction owned by somebody else — an order assigned to a
418
+ // fulfilment operator belongs to the customer who placed it. Adding the
419
+ // owner predicate here would return nothing and revoke the access the
420
+ // auth table exists to grant. Retirement still applies: an archived row
421
+ // is withdrawn from everyone who has not asked, whoever owns it.
422
+ ~code=Resolver.Functions.queryByIndexFiltered(
423
+ ~index,
424
+ ~idField,
425
+ ~retiredField?,
426
+ ~retiredValues?,
427
+ ~elevatedGroups,
428
+ ),
377
429
  ~opts,
378
430
  )
379
431
  // The interceptor leads the chain, as it does in every other Query
@@ -426,7 +478,43 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
426
478
  ~field=byIdsField->Pulumi.Input.make,
427
479
  ~code=generateCode(
428
480
  ~storageResource=storage,
429
- ~template=Resolver.Functions.batchGetItemsByIds,
481
+ ~template=Resolver.Functions.batchGetItemsByIds(
482
+ ~ownerField?,
483
+ ~retiredField?,
484
+ ~retiredValues?,
485
+ ~elevatedGroups,
486
+ ),
487
+ ),
488
+ ),
489
+ )
490
+ } else {
491
+ None
492
+ }
493
+
494
+ // The reference door. Registered under the same conditions as by-ids because
495
+ // it is the same read; what differs is the projection on the way out, and
496
+ // that a view which named its retired rows lets them through it.
497
+ //
498
+ // Not conditional on the annotation: `GraphQL_FragmentGenerator` emits the
499
+ // field for every view — one SDL serves every backend — so a view without a
500
+ // resolver here would be a field that errors rather than one that is absent.
501
+ let resolverRefs = if includeIdParam && subIdField === None {
502
+ let storage = storageResource(~pluginName=None, ~tableName=name)
503
+ let refsField = fieldNameForAll ++ "Refs"
504
+ Some(
505
+ makeQueryResolver(
506
+ ~resolverName=refsField->String.capitalize,
507
+ ~field=refsField->Pulumi.Input.make,
508
+ ~code=generateCode(
509
+ ~storageResource=storage,
510
+ ~template=Resolver.Functions.refsByIds(
511
+ ~labelField,
512
+ ~retiredField,
513
+ ~retiredValues,
514
+ ~namedWhenRetired=retiredSpec->Option.mapOr(false, r => r.namedWhenRetired),
515
+ ~ownerField?,
516
+ ~elevatedGroups,
517
+ ),
430
518
  ),
431
519
  ),
432
520
  )
@@ -521,7 +609,11 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
521
609
  | (None, Some(byIds)) => [resolverByIdSingle, resolverAll, byIds]
522
610
  | (None, None) => [resolverByIdSingle, resolverAll]
523
611
  }
524
- Array.flat([mainResolvers, resolversByIndex, idResolvers, idsResolvers])
612
+ // Appended rather than folded into `mainResolvers`' four-way switch: the door
613
+ // is independent of whether this view has a sub-id door, and adding a third
614
+ // dimension to that match would spell eight cases to say one thing.
615
+ let refsResolvers = resolverRefs->Option.mapOr([], r => [r])
616
+ Array.flat([mainResolvers, refsResolvers, resolversByIndex, idResolvers, idsResolvers])
525
617
  ->Array.map(Util.AppSync.toResourceNative)
526
618
  }
527
619
 
@@ -91,10 +91,15 @@ 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 retiredSpec = Stdlib_Option.flatMap(Stdlib_Option.flatMap(stateSchemaOpt, StateAnnotations$Reventless.getSpec), spec => spec.retired);
98
+ let retiredField = Stdlib_Option.map(retiredSpec, r => r.field);
99
+ let retiredValues = Stdlib_Option.flatMap(retiredSpec, r => r.values);
100
+ let resolverByIdSingle = includeIdParam ? makeQueryResolver(Stdlib_String.capitalize(fieldNameForSingle), fieldNameForSingle, subIdField !== undefined ? AppSync_Resolver_Functions$PulumiAws.queryByIdSort(subIdField, ownerField, elevatedGroups, retiredField, retiredValues) : AppSync_Resolver_Functions$PulumiAws.getItemById(ownerField, elevatedGroups, retiredField, retiredValues)) : makeQueryResolver(Stdlib_String.capitalize(fieldNameForSingle), fieldNameForSingle, AppSync_Resolver_Functions$PulumiAws.listAllItems);
101
+ 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;
102
+ let labelField = registryEntry !== undefined ? Stdlib_Option.getOr(registryEntry.labelField, "id") : "id";
98
103
  let capability = stateSchemaOpt !== undefined ? GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(name$1, stateSchemaOpt) : GraphQL_FragmentGenerator$ReventlessCore.emptyCapability;
99
104
  let filterFieldNames = capability.filterFields.map(f => f.name);
100
105
  let rangeFieldNames = Stdlib_Array.filterMap(capability.filterFields, f => {
@@ -110,7 +115,6 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
110
115
  GraphQL_FragmentGenerator$ReventlessCore.validateScanSortAlignment(stateSchemaOpt, name$1, knownSortFields).forEach(msg => log.warn("QueryDbResolvers_AppSync", undefined, msg));
111
116
  }
112
117
  let requireAttribute = internalRowRequiredAttr(name$1);
113
- let ownerField = stateSchemaOpt !== undefined ? Owner$Reventless.fieldNames(stateSchemaOpt)[0] : undefined;
114
118
  OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("QueryDbResolvers_AppSync", name$1, ownerField);
115
119
  let isIndexed = f => {
116
120
  if (indexes.some(ic => Stdlib_Option.getOr(ic.idField, ic.index) === f)) {
@@ -122,30 +126,20 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
122
126
  if (ownerField !== undefined && !isIndexed(ownerField)) {
123
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.");
124
128
  }
125
- let retiredSpec = Stdlib_Option.flatMap(Stdlib_Option.flatMap(stateSchemaOpt, StateAnnotations$Reventless.getSpec), spec => spec.retired);
126
- let retiredField = Stdlib_Option.map(retiredSpec, r => r.field);
127
- let retiredValues = Stdlib_Option.flatMap(retiredSpec, r => r.values);
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
- let stripLeadingBy = s => {
135
- if (s.startsWith("by") && s.length > 2) {
136
- return s.slice(2, s.length);
137
- } else {
138
- return s;
139
- }
140
- };
141
- let resolverName = Stdlib_String.capitalize(fieldNameForSingle) + "By" + Stdlib_String.capitalize(stripLeadingBy(index));
142
- let fieldName = fieldNameForSingle + "By" + Stdlib_String.capitalize(stripLeadingBy(index));
143
- let idField = Stdlib_Option.getOr(indexConfig.idField, index);
135
+ let fieldName = GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName(fieldNameForSingle, index);
136
+ let resolverName = Stdlib_String.capitalize(fieldName);
137
+ let idField = GraphQL_FragmentGenerator$ReventlessCore.indexKeyField(indexConfig);
144
138
  let match = indexConfig.authorization;
145
139
  if (match !== undefined) {
146
140
  let authDataSource = AppSync_DataSource$PulumiAws.makeDynamoDBDataSourceWithTableName(resolverName + "Auth", api, Util_DynamoDb$ReventlessAws.findResource(Util_QueryDb$ReventlessCore.getLocalStorageResources(allQueryDbs, match.tableName)).name, apiRole, opts);
147
141
  let authFunction = AppSync_Function$PulumiAws.makeJs(resolverName + "Auth", api, authDataSource.name, AppSync_Resolver_Functions$PulumiAws.authorizeIndexedAccess(index, match.group), opts);
148
- let queryFunction = AppSync_Function$PulumiAws.makeJs(resolverName, api, dataSourceName, AppSync_Resolver_Functions$PulumiAws.queryByIndexFiltered(index, idField), opts);
142
+ let queryFunction = AppSync_Function$PulumiAws.makeJs(resolverName, api, dataSourceName, AppSync_Resolver_Functions$PulumiAws.queryByIndexFiltered(index, idField, undefined, retiredField, retiredValues, elevatedGroups), opts);
149
143
  let interceptorFn = interceptorFunction(resolverName);
150
144
  return AppSync_Resolver_Retrying$ReventlessAws.makePipelineJsResolver(resolverName, api, "Query", fieldName, AppSync_Resolver_Functions$PulumiAws.pipelinePassThrough, interceptorFn !== undefined ? [
151
145
  interceptorFn,
@@ -157,7 +151,7 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
157
151
  ], opts);
158
152
  }
159
153
  let sortField = indexConfig.subIdField;
160
- return makeQueryResolver(resolverName, fieldName, sortField !== undefined ? AppSync_Resolver_Functions$PulumiAws.queryByIndexSortFiltered(index, idField, sortField) : AppSync_Resolver_Functions$PulumiAws.queryByIndexFiltered(index, idField));
154
+ return makeQueryResolver(resolverName, fieldName, sortField !== undefined ? AppSync_Resolver_Functions$PulumiAws.queryByIndexSortFiltered(index, idField, ownerField, sortField, retiredField, retiredValues, elevatedGroups) : AppSync_Resolver_Functions$PulumiAws.queryByIndexFiltered(index, idField, ownerField, retiredField, retiredValues, elevatedGroups));
161
155
  });
162
156
  let storageResource = (pluginName, tableName) => Adapter$ReventlessCore.outputToResource(Util_DynamoDb$ReventlessAws.findResourceInOutput(Plugin_Helpers$ReventlessCore.getStorageResources(allQueryDbs, pluginName, tableName)));
163
157
  let generateCode = (storageResource, template) => storageResource.name.apply(realTableName => template(realTableName));
@@ -165,10 +159,18 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
165
159
  if (includeIdParam && subIdField === undefined) {
166
160
  let storage = storageResource(undefined, name$1);
167
161
  let byIdsField = fieldNameForAll + "ByIds";
168
- resolverByIds = makeQueryResolver(Stdlib_String.capitalize(byIdsField), byIdsField, generateCode(storage, AppSync_Resolver_Functions$PulumiAws.batchGetItemsByIds));
162
+ resolverByIds = makeQueryResolver(Stdlib_String.capitalize(byIdsField), byIdsField, generateCode(storage, AppSync_Resolver_Functions$PulumiAws.batchGetItemsByIds(ownerField, retiredField, retiredValues, elevatedGroups)));
169
163
  } else {
170
164
  resolverByIds = undefined;
171
165
  }
166
+ let resolverRefs;
167
+ if (includeIdParam && subIdField === undefined) {
168
+ let storage$1 = storageResource(undefined, name$1);
169
+ let refsField = fieldNameForAll + "Refs";
170
+ resolverRefs = makeQueryResolver(Stdlib_String.capitalize(refsField), refsField, generateCode(storage$1, AppSync_Resolver_Functions$PulumiAws.refsByIds(labelField, retiredField, retiredValues, Stdlib_Option.mapOr(retiredSpec, false, r => r.namedWhenRetired), ownerField, elevatedGroups)));
171
+ } else {
172
+ resolverRefs = undefined;
173
+ }
172
174
  let idResolvers = idResolverConfigs.map(config => {
173
175
  let target = config.target;
174
176
  let targetId = target.idField;
@@ -251,8 +253,10 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
251
253
  resolverAll
252
254
  ]
253
255
  );
256
+ let refsResolvers = Stdlib_Option.mapOr(resolverRefs, [], r => [r]);
254
257
  return [
255
258
  mainResolvers,
259
+ refsResolvers,
256
260
  resolversByIndex,
257
261
  idResolvers,
258
262
  idsResolvers
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // Field names / includeIdParam / connectionSpec come from the same registry the
11
11
  // AppSync path reads, so the SDL emitted by GraphQL_FragmentGenerator stays in
12
- // lockstep. Kinds covered: getById, list (connection), index, byIds. Deferred to
12
+ // lockstep. Kinds covered: getById, list (connection), index, byIds, refs. Deferred to
13
13
  // B3.2c: items (sub-id connection), @resolves/@resolvesMany, node, auth-table.
14
14
 
15
15
  module Resolver = AppSync_Resolver_Retrying
@@ -150,13 +150,6 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
150
150
  ~opts,
151
151
  )
152
152
 
153
- let stripLeadingBy = s =>
154
- if s->String.startsWith("by") && s->String.length > 2 {
155
- s->String.slice(~start=2, ~end=s->String.length)
156
- } else {
157
- s
158
- }
159
-
160
153
  // Resolvers are deferred into resourcesMaker (created inside the schema-pushed
161
154
  // builderOutputs.apply, like the AppSync path) — so they only exist after the
162
155
  // fields are ACTIVE and after PgQueryResolver_Builder.provision has resolved
@@ -202,14 +195,28 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
202
195
  []
203
196
  }
204
197
 
198
+ // The reference door — {list}Refs(ids). Same shape as by-ids and registered
199
+ // under the same conditions, because it is the same read: a set of keys in,
200
+ // rows out. What differs is on the way out, where the row is projected to
201
+ // what a reference needs and a retirement is lifted for a view that named its
202
+ // rows.
203
+ let refs = if includeIdParam && subIdField === None {
204
+ let refsField = fieldNameForAll ++ "Refs"
205
+ [mkResolver(~resolverName=refsField->String.capitalize, ~field=refsField, ~kind="refs")]
206
+ } else {
207
+ []
208
+ }
209
+
205
210
  // Per-index equality queries: {single}By{Index}. A group-restricted index
206
211
  // (indexConfig.authorization) bakes the auth-table pipeline params — the
207
212
  // Lambda verifies group membership + ownership against the auth read model.
208
213
  let byIndex = indexes->Array.map((ic: indexConfig) => {
209
214
  let index = ic.index
210
- let resolverName =
211
- fieldNameForSingle->String.capitalize ++ "By" ++ index->stripLeadingBy->String.capitalize
212
- let field = fieldNameForSingle ++ "By" ++ index->stripLeadingBy->String.capitalize
215
+ let field = ReventlessCore.GraphQL_FragmentGenerator.indexQueryFieldName(
216
+ ~singleFieldName=fieldNameForSingle,
217
+ ~index,
218
+ )
219
+ let resolverName = field->String.capitalize
213
220
  switch ic.authorization {
214
221
  | Some({tableName, group}) =>
215
222
  mkResolver(
@@ -278,6 +285,7 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
278
285
  [byId, all]
279
286
  ->Array.concat(items)
280
287
  ->Array.concat(byIds)
288
+ ->Array.concat(refs)
281
289
  ->Array.concat(byIndex)
282
290
  ->Array.concat(idResolvers)
283
291
  ->Array.concat(idsResolvers)
@@ -7,6 +7,7 @@ import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/s
7
7
  import * as AppSync_Resolver_Functions$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_Resolver_Functions.res.mjs";
8
8
  import * as PgQueryResolver_Builder$ReventlessAws from "./PgQueryResolver_Builder.res.mjs";
9
9
  import * as AppSync_Resolver_Retrying$ReventlessAws from "../Api/AppSync_Resolver_Retrying.res.mjs";
10
+ import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
10
11
 
11
12
  let identityBlock = `id != null && id.sub != null
12
13
  ? { userId: id.sub, username: id.username, groups: id.claims?.['cognito:groups'] ?? [], claims: id.claims, provider: 'Cognito' }
@@ -77,13 +78,6 @@ function make(name, api, param, dataSourceName, indexes, subIdField, idResolverC
77
78
  PgQueryResolver_Builder$ReventlessAws.registerNodeType(returnTypeName, name$1);
78
79
  }
79
80
  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);
80
- let stripLeadingBy = s => {
81
- if (s.startsWith("by") && s.length > 2) {
82
- return s.slice(2, s.length);
83
- } else {
84
- return s;
85
- }
86
- };
87
81
  let resourcesMaker = _allQueryDbs => {
88
82
  let byId = mkResolver(Stdlib_String.capitalize(fieldNameForSingle), fieldNameForSingle, includeIdParam ? "getById" : "list", undefined, undefined, undefined);
89
83
  let all = mkResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, "list", undefined, undefined, undefined);
@@ -95,10 +89,17 @@ function make(name, api, param, dataSourceName, indexes, subIdField, idResolverC
95
89
  } else {
96
90
  byIds = [];
97
91
  }
92
+ let refs;
93
+ if (includeIdParam && subIdField === undefined) {
94
+ let refsField = fieldNameForAll + "Refs";
95
+ refs = [mkResolver(Stdlib_String.capitalize(refsField), refsField, "refs", undefined, undefined, undefined)];
96
+ } else {
97
+ refs = [];
98
+ }
98
99
  let byIndex = indexes.map(ic => {
99
100
  let index = ic.index;
100
- let resolverName = Stdlib_String.capitalize(fieldNameForSingle) + "By" + Stdlib_String.capitalize(stripLeadingBy(index));
101
- let field = fieldNameForSingle + "By" + Stdlib_String.capitalize(stripLeadingBy(index));
101
+ let field = GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName(fieldNameForSingle, index);
102
+ let resolverName = Stdlib_String.capitalize(field);
102
103
  let match = ic.authorization;
103
104
  if (match !== undefined) {
104
105
  return mkResolver(resolverName, field, "index", index, Stdlib_String.capitalize(match.tableName), match.group);
@@ -150,7 +151,7 @@ function make(name, api, param, dataSourceName, indexes, subIdField, idResolverC
150
151
  return [
151
152
  byId,
152
153
  all
153
- ].concat(items).concat(byIds).concat(byIndex).concat(idResolvers).concat(idsResolvers).map(Util_AppSync$ReventlessAws.toResourceNative);
154
+ ].concat(items).concat(byIds).concat(refs).concat(byIndex).concat(idResolvers).concat(idsResolvers).map(Util_AppSync$ReventlessAws.toResourceNative);
154
155
  };
155
156
  return {
156
157
  resources: [],
@@ -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