@reventlessdev/reventless-core 3.0.0-alpha.237 → 3.0.0-alpha.239

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 (49) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +8 -8
  3. package/src/Message.res +1 -1
  4. package/src/adapter/Monitoring/Monitoring.res +1 -1
  5. package/src/admin/Platform_Admin_Structure.res +1 -0
  6. package/src/admin/Platform_Admin_Structure.res.mjs +1 -0
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +2 -1
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  9. package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res +1 -1
  10. package/src/components/Aggregate/Aggregate_Callback.res +2 -2
  11. package/src/components/Api/ApiAllowedStatesHelpers.res +2 -3
  12. package/src/components/Api/ApiTargetStateHelpers.res +4 -3
  13. package/src/components/Api/GraphQL_FragmentGenerator.res +122 -12
  14. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +41 -8
  15. package/src/components/Api/SuryToJsonSchema.res +8 -0
  16. package/src/components/Api/SuryToJsonSchema.res.mjs +8 -4
  17. package/src/components/Dcb/Dcb_Builder.res +79 -16
  18. package/src/components/Dcb/Dcb_Builder.res.mjs +41 -5
  19. package/src/components/EventLog/EventLog.res +1 -1
  20. package/src/plugin/component/Plugin_Builder.res +7 -0
  21. package/src/plugin/component/Plugin_Builder.res.mjs +3 -1
  22. package/src/plugin/component/Plugin_Structure.res +376 -27
  23. package/src/plugin/component/Plugin_Structure.res.mjs +175 -8
  24. package/src/plugin/connect/PluginExtensionPoint_UiFragment.res +1 -1
  25. package/tests/admin/Platform_BakedManifestTest.res +1 -0
  26. package/tests/admin/Platform_BakedManifestTest.res.mjs +1 -0
  27. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +3 -0
  28. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +6 -0
  29. package/tests/admin/Platform_PluginStructuresApiTest.res +1 -0
  30. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +2 -0
  31. package/tests/aggregate/AggregateCacheTest.res +1 -1
  32. package/tests/aggregate/AggregateSnapshotTest.res +1 -1
  33. package/tests/api/GraphQL_FragmentGeneratorTest.res +171 -0
  34. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +108 -0
  35. package/tests/api/SuryToJsonSchemaTest.res +30 -3
  36. package/tests/api/SuryToJsonSchemaTest.res.mjs +49 -4
  37. package/tests/commandgenerator/OwnerStampingTest.res +57 -0
  38. package/tests/commandgenerator/OwnerStampingTest.res.mjs +19 -0
  39. package/tests/message/MessageTest.res +1 -1
  40. package/tests/plugin/HeartbeatDisconnectGraceTest.res +1 -1
  41. package/tests/plugin/PluginStructureTest.res +268 -4
  42. package/tests/plugin/PluginStructureTest.res.mjs +284 -3
  43. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res +31 -0
  44. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +73 -0
  45. package/tests/plugin/StateChangeSlice/PsShipOrder.res +16 -1
  46. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -2
  47. package/tests/plugin/StateViewSlice/PsShipmentsView.res +26 -0
  48. package/tests/plugin/StateViewSlice/PsShipmentsView.res.mjs +101 -0
  49. package/tests/util/CsvStreamTest.res +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,30 @@
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.239 (2026-08-18)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **api:** declare the reference door in the SDL every backend is built from ([5c1857e](https://github.com/ReventlessDev/reventless-core/commit/5c1857ea90ff40305a1c44a9e57043528e6a93aa))
11
+ * **api:** make the by-index door answer, and let an elevated caller widen it ([0fe0c6f](https://github.com/ReventlessDev/reventless-core/commit/0fe0c6f8dec6228ecaba39577e28d780b4f79c83))
12
+ * **core:** fail the build on a retired state the field's enum does not declare ([781935b](https://github.com/ReventlessDev/reventless-core/commit/781935bf8f3e9c418d1e7b25e7235717e4812d7c))
13
+ ### Features
14
+
15
+ * **core:** let a reference name a retired row, and let an elevated caller open one ([9e2623a](https://github.com/ReventlessDev/reventless-core/commit/9e2623a4b22487561607fcc0ca19d51726069ee4))
16
+
17
+
18
+ # 3.0.0-alpha.238 (2026-08-16)
19
+
20
+ ### Bug Fixes
21
+
22
+ * **core:** stamp the owner from each DCB slice's own command schema ([6edbdf4](https://github.com/ReventlessDev/reventless-core/commit/6edbdf4680f2535597c3b560215aa514ceed27eb))
23
+ ### Features
24
+
25
+ * **core:** check a declared lifecycle edge against the states that exist ([d427d92](https://github.com/ReventlessDev/reventless-core/commit/d427d92f8912b4dc9a9407c3c0cec3200d41a5f1))
26
+ * **core:** report a lifecycle state no command can reach ([eb24dbd](https://github.com/ReventlessDev/reventless-core/commit/eb24dbd90df07185f0f4785c4bb726f5b617e7b2))
27
+ * **ppx:** declare a command's lifecycle edge once, as [@transition](https://github.com/transition) ([dd35130](https://github.com/ReventlessDev/reventless-core/commit/dd3513014f14b71879ee21263c6755d3c3d95096))
28
+
29
+
6
30
  # 3.0.0-alpha.237 (2026-08-16)
7
31
 
8
32
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.237",
3
+ "version": "3.0.0-alpha.239",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,21 +29,21 @@
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
+ "@reventlessdev/rescript-node": "2.0.0-alpha.8",
32
33
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
33
- "@reventlessdev/rescript-node": "2.0.0-alpha.7",
34
- "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.7",
35
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
36
- "@reventlessdev/rescript-ssh2": "2.0.0-alpha.7",
35
+ "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
37
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
38
- "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.143",
37
+ "@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
38
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.145",
39
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.117",
40
40
  "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.115"
41
+ "@reventlessdev/rescript-uuid": "2.0.0-alpha.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
45
45
  "sury-ppx": "11.0.0-alpha.2",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.67"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.69"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "12.3.0"
package/src/Message.res CHANGED
@@ -69,7 +69,7 @@ let toCommandSchema' = (idSchema, commandSchema) =>
69
69
  // before a nested `@schema` field existed still decodes instead of bricking the
70
70
  // aggregate's log replay. These two entry points thread the memoized envelope schema;
71
71
  // `Reventless.Message.decode` (the other central decoder) is tolerant at the source.
72
- // See docs/plans/platform-infrastructure-in-plugin-list.md (durable fix option 2).
72
+ // See docs/plans/done/platform-infrastructure-in-plugin-list.md (durable fix option 2).
73
73
  let decodeEvent' = (json, idSchema, eventSchema) =>
74
74
  json->parseJsonTolerant(toEventSchema'(idSchema, eventSchema))
75
75
  let decodeCommand' = (json, idSchema, commandSchema) =>
@@ -10,7 +10,7 @@ concern; this only exposes the choke point through which the framework says
10
10
  "I just provisioned an execution unit" and leaves what to do about it to the
11
11
  listener.
12
12
 
13
- See `docs/plans/monitoring-hook-seam.md`.
13
+ See `docs/plans/done/monitoring-hook-seam.md`.
14
14
  */
15
15
 
16
16
  /**
@@ -141,6 +141,7 @@ let pluginReadModel: queryableDef = {
141
141
  ownerField: None,
142
142
  retiredField: None,
143
143
  retiredValues: None,
144
+ namedWhenRetired: None,
144
145
  }
145
146
 
146
147
  let structure: pluginStructure = {
@@ -154,6 +154,7 @@ let pluginReadModel = {
154
154
  ownerField: undefined,
155
155
  retiredField: undefined,
156
156
  retiredValues: undefined,
157
+ namedWhenRetired: undefined,
157
158
  visibility: undefined,
158
159
  chapter: undefined,
159
160
  singleQueryField: pluginReadModel_singleQueryField,
@@ -29,7 +29,7 @@ let sdlTypes: array<string> = [
29
29
  // structures persisted before the fields existed decode as `None`, a hand-rolled
30
30
  // def may decline to state them, and a state whose key cannot be resolved has no
31
31
  // `idField` to report.
32
- `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n lifecycleField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n retiredField: String\n retiredValues: [String!]\n}`,
32
+ `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n lifecycleField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n retiredField: String\n retiredValues: [String!]\n namedWhenRetired: Boolean!\n}`,
33
33
  `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
34
34
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
35
35
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
@@ -130,6 +130,7 @@ let encodeQueryableDef = (r: queryableDef): JSON.t =>
130
130
  ("ownerField", r.ownerField->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
131
131
  ("retiredField", r.retiredField->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
132
132
  ("retiredValues", r.retiredValues->Option.mapOr(JSON.Encode.null, encodeStrings)),
133
+ ("namedWhenRetired", JSON.Encode.bool(r.namedWhenRetired->Option.getOr(false))),
133
134
  ])->JSON.Encode.object
134
135
 
135
136
  let encodeEventDef = (e: eventDef): JSON.t =>
@@ -10,7 +10,7 @@ let sdlTypes = [
10
10
  `type Platform_EventDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
11
11
  `type Platform_ErrorDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
12
12
  `type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n errors: [Platform_ErrorDef!]!\n chapter: String\n}`,
13
- `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n lifecycleField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n retiredField: String\n retiredValues: [String!]\n}`,
13
+ `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n lifecycleField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n retiredField: String\n retiredValues: [String!]\n namedWhenRetired: Boolean!\n}`,
14
14
  `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
15
15
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
16
16
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
@@ -168,6 +168,10 @@ function encodeQueryableDef(r) {
168
168
  [
169
169
  "retiredValues",
170
170
  Stdlib_Option.mapOr(r.retiredValues, null, encodeStrings)
171
+ ],
172
+ [
173
+ "namedWhenRetired",
174
+ Stdlib_Option.getOr(r.namedWhenRetired, false)
171
175
  ]
172
176
  ]);
173
177
  }
@@ -1,6 +1,6 @@
1
1
  // UiFragmentRegistry StateChangeSlice — the write side of the platform UI-fragment
2
2
  // registry, extracted off the Plugin aggregate (see
3
- // docs/plans/event-sourced-fragment-registries.md).
3
+ // docs/plans/done/event-sourced-fragment-registries.md).
4
4
  //
5
5
  // Commands are driven by the plugin runtime's connect handshake and routed here by the
6
6
  // admin PluginExtensionPoint's UI-fragment mapping (RegisterUiFragment on ConnectPlugin,
@@ -114,7 +114,7 @@ module Make = (
114
114
  // at append time → the conflict branch invalidates the entry and the retry
115
115
  // replays cold. Mirrors the StateChangeSlice decision-model cache, including
116
116
  // the fixed capacity (a per-aggregate knob is a future refinement — see
117
- // docs/plans/aggregate-snapshotting.md).
117
+ // docs/plans/done/aggregate-snapshotting.md).
118
118
  let replayCacheCapacity = 100
119
119
  let replayCache: Lru.t<string, (Behavior.state, int)> = Lru.make(
120
120
  ~capacity=replayCacheCapacity,
@@ -122,7 +122,7 @@ module Make = (
122
122
 
123
123
  let resetCache = () => replayCache->Lru.clear
124
124
 
125
- // Persisted-snapshot configuration (docs/plans/aggregate-snapshotting.md).
125
+ // Persisted-snapshot configuration (docs/plans/done/aggregate-snapshotting.md).
126
126
  // `None` (the default) keeps full replay; `Some({interval, stateSchema})`
127
127
  // seeds cold replays from the latest persisted snapshot and writes a fresh
128
128
  // one every `interval` events. Snapshots are a read optimization only — the
@@ -1,5 +1,5 @@
1
1
  // Read per-variant `allowedStates` metadata that the reventless-ppx
2
- // `@allowedStates([…])` attribute attaches to a command schema. Consumed by
2
+ // an `@transition` from-set attaches to a command schema. Consumed by
3
3
  // `Plugin_Structure.toCommandDef` when building the `commandDef` records that
4
4
  // land in `Platform_ComponentDefinitions` so AutoUI can filter the per-row command
5
5
  // menu by the row's status.
@@ -7,8 +7,7 @@
7
7
  open ReventlessInfra.Api
8
8
 
9
9
  /** Returns the allowed-state list for a given command-variant name, or None
10
- when the variant has no `@allowedStates` annotation (back-compat default
11
- — the command shows on every row). */
10
+ when the variant declares no `@transition` (the command shows on every row). */
12
11
  let getAllowedStates = (
13
12
  commandSchema: S.t<unknown>,
14
13
  ~variantName: string,
@@ -1,5 +1,5 @@
1
1
  // Read per-variant `targetState` metadata that the reventless-ppx
2
- // `@targetState("…")` attribute attaches to a command schema. Consumed by
2
+ // an `@transition` target attaches to a command schema. Consumed by
3
3
  // `Plugin_Structure.toCommandDef` when building the `commandDef` records that
4
4
  // land in `Platform_ComponentDefinitions`, so AutoUI's board drag resolver can
5
5
  // move a row by a *declared* transition instead of a name-stem guess. Mirrors
@@ -8,8 +8,9 @@
8
8
  open ReventlessInfra.Api
9
9
 
10
10
  /** Returns the declared target state for a given command-variant name, or None
11
- when the variant has no `@targetState` annotation (back-compat default —
12
- the resolver falls back to its name-stem heuristic). */
11
+ when the variant declares no target — either because it carries no
12
+ `@transition` at all, or because it declares a from-set only, which is a
13
+ command saying it does not move the row. */
13
14
  let getTargetState = (
14
15
  commandSchema: S.t<unknown>,
15
16
  ~variantName: string,
@@ -411,9 +411,16 @@ let deriveObjectQueryField = (
411
411
  ~subIdField: option<string>=?,
412
412
  ): string =>
413
413
  if includeIdParam {
414
+ // `includeRetired` reaches this door for the reason it reaches the list: a
415
+ // caller the server would serve a retired row to had, until it did, no way
416
+ // to ask for one *singly*. The archive toggle put such rows on screen and
417
+ // clicking one read a door that refuses them to everybody — `decideRetired`
418
+ // withholds from `Elevated` and `System` too until they ask, and here there
419
+ // was nothing to ask with.
414
420
  switch subIdField {
415
- | Some(sortField) => ` ${singleFieldName}(id: ID!, ${sortField}: String!): ${typeName}`
416
- | None => ` ${singleFieldName}(id: ID!): ${typeName}`
421
+ | Some(sortField) =>
422
+ ` ${singleFieldName}(id: ID!, ${sortField}: String!, includeRetired: Boolean): ${typeName}`
423
+ | None => ` ${singleFieldName}(id: ID!, includeRetired: Boolean): ${typeName}`
417
424
  }
418
425
  } else {
419
426
  ` ${singleFieldName}: ${typeName}`
@@ -434,7 +441,38 @@ let deriveByIdsQueryField = (
434
441
  ~listFieldName: string,
435
442
  ~returnTypeName: string,
436
443
  ): string =>
437
- ` ${listFieldName}ByIds(ids: [String!]!): [${returnTypeName}!]!`
444
+ ` ${listFieldName}ByIds(ids: [String!]!, includeRetired: Boolean): [${returnTypeName}!]!`
445
+
446
+ // ── The reference door ─────────────────────────────────────────────────────
447
+ //
448
+ // What a caller holding a pointer to a row may learn about it: its id, the label
449
+ // a reference resolves to, and — where the view declares a retirement — whether
450
+ // this row is in one and which state that is.
451
+ //
452
+ // The narrowness is the *type's*, which is the whole reason this is a separate
453
+ // field rather than an argument on the by-ids door. A rule that answered a
454
+ // retired row on the existing door "as long as only the safe fields were asked
455
+ // for" would have to be re-implemented, identically, in four backends and to
456
+ // survive aliases, fragments and `__typename`. Here a caller cannot ask for a
457
+ // price, because the type has no price.
458
+ //
459
+ // Emitted for every view, not only for the ones that opted in, on exactly the
460
+ // reasoning `includeRetired` states above: a field that appears and disappears
461
+ // with an annotation makes adding or removing that annotation a breaking schema
462
+ // change, and forces every client to feature-detect. What the annotation decides
463
+ // is not whether the door exists but whether a *retired* row comes through it —
464
+ // without it this is a cheap label read that narrows exactly as every other door
465
+ // does.
466
+ let deriveRefTypeSdl = (~returnTypeName: string): string =>
467
+ `type ${returnTypeName}Ref {\n id: ID!\n label: String!\n retired: Boolean!\n retiredState: String\n}`
468
+
469
+ // `retiredState` is null in two cases that do not need telling apart by a
470
+ // consumer: a live row, and a boolean-form retirement, where the field is the
471
+ // state and `retired: true` has already said everything there is to say.
472
+ let deriveRefsQueryField = (
473
+ ~listFieldName: string,
474
+ ~returnTypeName: string,
475
+ ): string => ` ${listFieldName}Refs(ids: [ID!]!): [${returnTypeName}Ref!]!`
438
476
 
439
477
  // `includeRetired` sits beside the paging arguments rather than inside `filter`,
440
478
  // and that placement is the point. `filter` is the caller's description of the
@@ -459,6 +497,63 @@ let deriveConnectionQueryField = (
459
497
  ` ${listFieldName}(filter: ${filterTypeName}${orderByArg}, first: Int, after: String, last: Int, before: String, includeRetired: Boolean): ${singularTypeName}Connection!`
460
498
  }
461
499
 
500
+ // ── The by-index door ──────────────────────────────────────────────────────
501
+ //
502
+ // One derivation, called by every backend that serves this field, because the
503
+ // alternative was tried and did not survive contact. The field used to be
504
+ // emitted here for AppSync and again, independently, in the local adapter, and
505
+ // the two agreed on neither its name, its argument nor its return type. Both
506
+ // were wrong in the same way: each disagreed with the resolver standing behind
507
+ // it. The AppSync shape declared `id: ID!` while its resolver read the index
508
+ // field, so the value could not be passed at all; the local shape promised
509
+ // `[String]` while its resolver returned whole rows, so every call failed to
510
+ // serialise. A door that answers on no backend is a door whose signature can be
511
+ // chosen on the merits, which is what the one below is.
512
+
513
+ /** The row field an index is keyed on.
514
+
515
+ For a named index (`@index("byOwner") ownerId`) the index name and the field it
516
+ indexes differ, and it is the *field* every resolver filters on; for an unnamed
517
+ index the two are the same string. */
518
+ let indexKeyField = (indexConfig: Reventless.ReadModel.indexConfig): string =>
519
+ indexConfig.idField->Option.getOr(indexConfig.index)
520
+
521
+ /** `<single>By<Index>`, dropping a leading `by` from the index name so
522
+ `@index("byOwner")` reads `XByOwner` rather than `XByByOwner`. */
523
+ let indexQueryFieldName = (~singleFieldName: string, ~index: string): string => {
524
+ let stripped = if index->String.startsWith("by") && index->String.length > 2 {
525
+ index->String.slice(~start=2, ~end=index->String.length)
526
+ } else {
527
+ index
528
+ }
529
+ singleFieldName ++ "By" ++ stripped->String.capitalize
530
+ }
531
+
532
+ /** The by-index door's signature.
533
+
534
+ Paging and `includeRetired` are here for the reasons `deriveConnectionQueryField`
535
+ gives above — this door reads a secondary index that can carry as many rows as
536
+ the list, and an elevated caller that can widen every other door but this one
537
+ would find the archive reachable by id and by list and not by the index that
538
+ exists to look rows up. */
539
+ let deriveIndexQueryField = (
540
+ ~singleFieldName: string,
541
+ ~indexConfig: Reventless.ReadModel.indexConfig,
542
+ ~connectionTypeName: string,
543
+ ): string => {
544
+ let fieldName = indexQueryFieldName(~singleFieldName, ~index=indexConfig.index)
545
+ let keyField = indexKeyField(indexConfig)
546
+ // An index with a sort key can be narrowed to one exact value on it. Optional,
547
+ // unlike the partition argument: naming the index value is what the door is
548
+ // for, narrowing further is a refinement. The AppSync sort template has read
549
+ // this argument all along, against an SDL that never offered it.
550
+ let sortArg = switch indexConfig.subIdField {
551
+ | Some(sortField) => `${sortField}: String, `
552
+ | None => ""
553
+ }
554
+ ` ${fieldName}(${keyField}: String!, ${sortArg}first: Int, after: String, last: Int, before: String, includeRetired: Boolean): ${connectionTypeName}!`
555
+ }
556
+
462
557
  // ── Mutation field derivation ──────────────────────────────────────────────
463
558
 
464
559
  let deriveMutationFieldFromObject = (
@@ -652,6 +747,23 @@ let generate = (
652
747
  ~returnTypeName=entry.returnTypeName,
653
748
  ),
654
749
  )
750
+
751
+ // The reference door, under the by-ids condition because it is the same
752
+ // read. Emitted here rather than per-backend so the field a backend
753
+ // provisions a resolver for is one this SDL declares: the AppSync adapter
754
+ // registers `<list>Refs` for every queryable on exactly that premise, and
755
+ // a door named in one half and not the other fails the deploy outright.
756
+ let refTypeName = entry.returnTypeName ++ "Ref"
757
+ if !(seenTypes->Set.has(refTypeName)) {
758
+ seenTypes->Set.add(refTypeName)
759
+ types->Array.push(deriveRefTypeSdl(~returnTypeName=entry.returnTypeName))
760
+ }
761
+ queries->Array.push(
762
+ deriveRefsQueryField(
763
+ ~listFieldName=entry.listFieldName,
764
+ ~returnTypeName=entry.returnTypeName,
765
+ ),
766
+ )
655
767
  }
656
768
 
657
769
  let listFieldName = entry.listFieldName
@@ -677,17 +789,15 @@ let generate = (
677
789
  switch entry.indexQueries {
678
790
  | Some(indexes) =>
679
791
  let connectionTypeName = entry.returnTypeName ++ "Connection"
680
- indexes->Array.forEach(({index}) => {
681
- let stripped = if index->String.startsWith("by") && index->String.length > 2 {
682
- index->String.slice(~start=2, ~end=index->String.length)
683
- } else {
684
- index
685
- }
686
- let fieldName = entry.singleFieldName ++ "By" ++ stripped->String.capitalize
792
+ indexes->Array.forEach(indexConfig =>
687
793
  queries->Array.push(
688
- ` ${fieldName}(id: ID!, first: Int, after: String, last: Int, before: String): ${connectionTypeName}!`,
794
+ deriveIndexQueryField(
795
+ ~singleFieldName=entry.singleFieldName,
796
+ ~indexConfig,
797
+ ~connectionTypeName,
798
+ ),
689
799
  )
690
- })
800
+ )
691
801
  | None => ()
692
802
  }
693
803
 
@@ -354,9 +354,9 @@ function deriveObjectQueryField(singleFieldName, typeName, includeIdParamOpt, su
354
354
  let includeIdParam = includeIdParamOpt !== undefined ? includeIdParamOpt : true;
355
355
  if (includeIdParam) {
356
356
  if (subIdField !== undefined) {
357
- return ` ` + singleFieldName + `(id: ID!, ` + subIdField + `: String!): ` + typeName;
357
+ return ` ` + singleFieldName + `(id: ID!, ` + subIdField + `: String!, includeRetired: Boolean): ` + typeName;
358
358
  } else {
359
- return ` ` + singleFieldName + `(id: ID!): ` + typeName;
359
+ return ` ` + singleFieldName + `(id: ID!, includeRetired: Boolean): ` + typeName;
360
360
  }
361
361
  } else {
362
362
  return ` ` + singleFieldName + `: ` + typeName;
@@ -368,7 +368,15 @@ function deriveListQueryField(listFieldName, pluralTypeName) {
368
368
  }
369
369
 
370
370
  function deriveByIdsQueryField(listFieldName, returnTypeName) {
371
- return ` ` + listFieldName + `ByIds(ids: [String!]!): [` + returnTypeName + `!]!`;
371
+ return ` ` + listFieldName + `ByIds(ids: [String!]!, includeRetired: Boolean): [` + returnTypeName + `!]!`;
372
+ }
373
+
374
+ function deriveRefTypeSdl(returnTypeName) {
375
+ return `type ` + returnTypeName + `Ref {\n id: ID!\n label: String!\n retired: Boolean!\n retiredState: String\n}`;
376
+ }
377
+
378
+ function deriveRefsQueryField(listFieldName, returnTypeName) {
379
+ return ` ` + listFieldName + `Refs(ids: [ID!]!): [` + returnTypeName + `Ref!]!`;
372
380
  }
373
381
 
374
382
  function deriveConnectionQueryField(listFieldName, singularTypeName, filterTypeName, hasOrderByOpt) {
@@ -377,6 +385,23 @@ function deriveConnectionQueryField(listFieldName, singularTypeName, filterTypeN
377
385
  return ` ` + listFieldName + `(filter: ` + filterTypeName + orderByArg + `, first: Int, after: String, last: Int, before: String, includeRetired: Boolean): ` + singularTypeName + `Connection!`;
378
386
  }
379
387
 
388
+ function indexKeyField(indexConfig) {
389
+ return Stdlib_Option.getOr(indexConfig.idField, indexConfig.index);
390
+ }
391
+
392
+ function indexQueryFieldName(singleFieldName, index) {
393
+ let stripped = index.startsWith("by") && index.length > 2 ? index.slice(2, index.length) : index;
394
+ return singleFieldName + "By" + Stdlib_String.capitalize(stripped);
395
+ }
396
+
397
+ function deriveIndexQueryField(singleFieldName, indexConfig, connectionTypeName) {
398
+ let fieldName = indexQueryFieldName(singleFieldName, indexConfig.index);
399
+ let keyField = indexKeyField(indexConfig);
400
+ let sortField = indexConfig.subIdField;
401
+ let sortArg = sortField !== undefined ? sortField + `: String, ` : "";
402
+ return ` ` + fieldName + `(` + keyField + `: String!, ` + sortArg + `first: Int, after: String, last: Int, before: String, includeRetired: Boolean): ` + connectionTypeName + `!`;
403
+ }
404
+
380
405
  function deriveMutationFieldFromObject(fieldName, collectedTypes, seenTypes, variantSchema) {
381
406
  let fields = SchemaType$ReventlessCore.fromSuryObject(fieldName, variantSchema);
382
407
  if (fields === undefined) {
@@ -481,6 +506,12 @@ function generate(mutationEntries, queryEntries) {
481
506
  queries.push(singleField);
482
507
  if (includeIdParam && entry.subIdField === undefined) {
483
508
  queries.push(deriveByIdsQueryField(entry.listFieldName, entry.returnTypeName));
509
+ let refTypeName = entry.returnTypeName + "Ref";
510
+ if (!seenTypes.has(refTypeName)) {
511
+ seenTypes.add(refTypeName);
512
+ types.push(deriveRefTypeSdl(entry.returnTypeName));
513
+ }
514
+ queries.push(deriveRefsQueryField(entry.listFieldName, entry.returnTypeName));
484
515
  }
485
516
  let listFieldName = entry.listFieldName;
486
517
  let _sf = entry.subIdField;
@@ -495,11 +526,8 @@ function generate(mutationEntries, queryEntries) {
495
526
  let indexes = entry.indexQueries;
496
527
  if (indexes !== undefined) {
497
528
  let connectionTypeName = entry.returnTypeName + "Connection";
498
- indexes.forEach(param => {
499
- let index = param.index;
500
- let stripped = index.startsWith("by") && index.length > 2 ? index.slice(2, index.length) : index;
501
- let fieldName = entry.singleFieldName + "By" + Stdlib_String.capitalize(stripped);
502
- queries.push(` ` + fieldName + `(id: ID!, first: Int, after: String, last: Int, before: String): ` + connectionTypeName + `!`);
529
+ indexes.forEach(indexConfig => {
530
+ queries.push(deriveIndexQueryField(entry.singleFieldName, indexConfig, connectionTypeName));
503
531
  });
504
532
  }
505
533
  if (connectionSpec) {
@@ -579,7 +607,12 @@ export {
579
607
  deriveObjectQueryField,
580
608
  deriveListQueryField,
581
609
  deriveByIdsQueryField,
610
+ deriveRefTypeSdl,
611
+ deriveRefsQueryField,
582
612
  deriveConnectionQueryField,
613
+ indexKeyField,
614
+ indexQueryFieldName,
615
+ deriveIndexQueryField,
583
616
  deriveMutationFieldFromObject,
584
617
  mutationArgTypes,
585
618
  generate,
@@ -137,6 +137,14 @@ let mergeAnnotations = (
137
137
  }
138
138
  let entries =
139
139
  r.label === "" ? entries : Array.concat([("label", JSON.Encode.string(r.label))], entries)
140
+ // `namedWhenRetired` travels only when true, on the omit-the-default rule
141
+ // the keys above follow: false is what every record said before the opt-in
142
+ // existed, and writing it would put a key on every retirement to say
143
+ // nothing changed.
144
+ let entries =
145
+ r.namedWhenRetired
146
+ ? Array.concat(entries, [("namedWhenRetired", JSON.Encode.bool(true))])
147
+ : entries
140
148
  obj->Dict.set("x-reventless-retired", JSON.Encode.object(Dict.fromArray(entries)))
141
149
  | _ => ()
142
150
  }
@@ -119,20 +119,24 @@ function mergeAnnotations(fieldSchema, fieldName, spec) {
119
119
  "label",
120
120
  r.label
121
121
  ]].concat(entries$1);
122
- obj["x-reventless-retired"] = Object.fromEntries(entries$3);
122
+ let entries$4 = r.namedWhenRetired ? entries$3.concat([[
123
+ "namedWhenRetired",
124
+ true
125
+ ]]) : entries$3;
126
+ obj["x-reventless-retired"] = Object.fromEntries(entries$4);
123
127
  }
124
128
  let match$4 = spec.metric.find(param => param[0] === fieldName);
125
129
  if (match$4 !== undefined) {
126
130
  let m = match$4[1];
127
- let entries$4 = [[
131
+ let entries$5 = [[
128
132
  "aggregate",
129
133
  m.aggregate
130
134
  ]];
131
- let entries$5 = m.label === "" ? entries$4 : entries$4.concat([[
135
+ let entries$6 = m.label === "" ? entries$5 : entries$5.concat([[
132
136
  "label",
133
137
  m.label
134
138
  ]]);
135
- obj["x-reventless-metric"] = Object.fromEntries(entries$5);
139
+ obj["x-reventless-metric"] = Object.fromEntries(entries$6);
136
140
  }
137
141
  return obj;
138
142
  }