@reventlessdev/reventless-local 3.0.0-alpha.211 → 3.0.0-alpha.212

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
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.212 (2026-08-11)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **api:** one id contract across the read-side query doors ([c52fac2](https://github.com/ReventlessDev/reventless-core/commit/c52fac2bee87b3dd1fa33b4970a74f3cd3866280))
11
+ ### Features
12
+
13
+ * **api:** infer a queryable's key field and publish its provenance ([c835a42](https://github.com/ReventlessDev/reventless-core/commit/c835a42a0da07cdc4a3f010212e1f340a4a0ca27))
14
+
15
+
6
16
  # 3.0.0-alpha.211 (2026-08-10)
7
17
 
8
18
  **Note:** Version bump only for package @reventlessdev/reventless-local
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.211",
3
+ "version": "3.0.0-alpha.212",
4
4
  "description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -35,18 +35,18 @@
35
35
  "sury": "11.0.0-alpha.4",
36
36
  "ws": "^8.18.0",
37
37
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
38
- "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.26",
39
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
38
+ "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
40
39
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
41
- "@reventlessdev/rescript-node": "2.0.0-alpha.3",
40
+ "@reventlessdev/rescript-node": "2.0.0-alpha.4",
41
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
42
42
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
43
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.71",
44
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.170",
45
- "@reventlessdev/reventless-core": "3.0.0-alpha.223",
46
- "@reventlessdev/reventless-infra": "3.0.0-alpha.133",
47
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.87",
48
- "@reventlessdev/reventless-spec": "3.0.0-alpha.107",
49
- "@reventlessdev/reventless-seed": "1.0.0-alpha.10"
43
+ "@reventlessdev/reventless-core": "3.0.0-alpha.224",
44
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.72",
45
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.171",
46
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.88",
47
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.134",
48
+ "@reventlessdev/reventless-seed": "1.0.0-alpha.11",
49
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.108"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.0",
@@ -257,26 +257,14 @@ type resolverFn = YG.resolverFn
257
257
 
258
258
  // -- Relay Global ID encoding/decoding -------------------------------------
259
259
 
260
- @val external btoa: string => string = "btoa"
261
- @val external atob: string => string = "atob"
262
-
260
+ // The encoding itself lives in `ReventlessCore.Api_Ids` — one definition shared
261
+ // with the AWS adapter and with the doors that accept either id form. Kept as
262
+ // named bindings here because the resolver wiring below and the `relay` record
263
+ // passed to QueryDbResolvers_GraphQL both reference them.
263
264
  let encodeGlobalId = (~typeName: string, ~localId: string): string =>
264
- btoa(`${typeName}:${localId}`)
265
+ ReventlessCore.Api_Ids.encode(~typeName, ~localId)
265
266
 
266
- let decodeGlobalId = (globalId: string): option<(string, string)> =>
267
- try {
268
- let decoded = atob(globalId)
269
- let idx = decoded->String.indexOf(":")
270
- if idx > 0 {
271
- let typeName = decoded->String.slice(~start=0, ~end=idx)
272
- let localId = decoded->String.slice(~start=idx + 1, ~end=decoded->String.length)
273
- Some((typeName, localId))
274
- } else {
275
- None
276
- }
277
- } catch {
278
- | _ => None
279
- }
267
+ let decodeGlobalId = ReventlessCore.Api_Ids.decode
280
268
 
281
269
  // -- Scoped registry ---------------------------------------------------------
282
270
  // Mirrors the AWS plugin=source-API, platform=merged-API model: every
@@ -15,6 +15,7 @@ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js
15
15
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
16
16
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
17
17
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
18
+ import * as Api_Ids$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Ids.res.mjs";
18
19
  import * as LocalAuth$ReventlessLocal from "./Auth/LocalAuth.res.mjs";
19
20
  import * as GraphQL_Stitcher$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_Stitcher.res.mjs";
20
21
  import * as LocalObjectStore$ReventlessLocal from "./ObjectStore/LocalObjectStore.res.mjs";
@@ -193,27 +194,7 @@ function _dispatch(req, res, yoga, getSdl) {
193
194
  });
194
195
  }
195
196
 
196
- function encodeGlobalId(typeName, localId) {
197
- return btoa(typeName + `:` + localId);
198
- }
199
-
200
- function decodeGlobalId(globalId) {
201
- try {
202
- let decoded = atob(globalId);
203
- let idx = decoded.indexOf(":");
204
- if (idx <= 0) {
205
- return;
206
- }
207
- let typeName = decoded.slice(0, idx);
208
- let localId = decoded.slice(idx + 1 | 0, decoded.length);
209
- return [
210
- typeName,
211
- localId
212
- ];
213
- } catch (exn) {
214
- return;
215
- }
216
- }
197
+ let encodeGlobalId = Api_Ids$ReventlessCore.encode;
217
198
 
218
199
  let platformScope = "platform";
219
200
 
@@ -368,7 +349,7 @@ function buildNodeResolver() {
368
349
  }
369
350
  let resolver = async (_root, args, _ctx) => {
370
351
  let id = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d["id"]), Stdlib_JSON.Decode.string), "");
371
- let match = decodeGlobalId(id);
352
+ let match = Api_Ids$ReventlessCore.decode(id);
372
353
  if (match === undefined) {
373
354
  return null;
374
355
  }
@@ -738,6 +719,8 @@ let YG;
738
719
 
739
720
  let buildAuthContext = Auth_GraphqlContext$ReventlessLocal.buildAuthContext;
740
721
 
722
+ let decodeGlobalId = Api_Ids$ReventlessCore.decode;
723
+
741
724
  export {
742
725
  YG,
743
726
  log,
@@ -85,6 +85,13 @@ module Make = (Bus: LocalBus.T) => {
85
85
 
86
86
  // Register the Relay node resolver callback once per QueryDb (domain only).
87
87
  // Scans all QueryDb instances to resolve node(id: ID!) queries.
88
+ // `node` is the one door that speaks Relay global ids, on both sides: it needs
89
+ // the `<Type>:` prefix to know which read model to load, so a storage key alone
90
+ // could not be resolved here. Every other door — the list, `X(id:)`,
91
+ // `XsByIds`, `filter.ids` — reports the storage key, which is what a client
92
+ // reads off a row and passes back. Callers that hold a global id are still
93
+ // served by the typed doors (see `Api_Ids.alternateKey`); the reverse is not
94
+ // possible, which is why this door is opt-in rather than the default form.
88
95
  switch relay {
89
96
  | Some(r) =>
90
97
  r.registerNodeResolverCallback(async (~typeName, ~localId) => {
@@ -101,7 +108,10 @@ module Make = (Bus: LocalBus.T) => {
101
108
  ->Effect.runPromise
102
109
  switch items->Array.get(0) {
103
110
  | Some(item) =>
104
- let obj = item->JSON.Decode.object->Option.getOr(Dict.make())
111
+ // Copied: `JSON.Decode.object` hands back the stored object itself, so
112
+ // setting a field here would rewrite the row inside the QueryDb — the
113
+ // in-memory backend keeps the very object it returns.
114
+ let obj = item->JSON.Decode.object->Option.mapOr(Dict.make(), Dict.copy)
105
115
  obj->Dict.set("__typename", JSON.Encode.string(typeName))
106
116
  obj->Dict.set("id", r.encodeGlobalId(~typeName, ~localId)->JSON.Encode.string)
107
117
  Some(JSON.Encode.object(obj))
@@ -169,11 +179,6 @@ module Make = (Bus: LocalBus.T) => {
169
179
  }
170
180
  }
171
181
 
172
- let encodeId = switch relay {
173
- | Some(r) => (~typeName, ~localId) => r.encodeGlobalId(~typeName, ~localId)
174
- | None => (~typeName as _, ~localId) => localId
175
- }
176
-
177
182
  // -- Main query: getById ---------------------------------------------------
178
183
  let byIdSdl = if includeIdParam {
179
184
  switch subIdField {
@@ -191,16 +196,34 @@ module Make = (Bus: LocalBus.T) => {
191
196
  args->JSON.Decode.object->Option.flatMap(d => d->Dict.get("id"))->Option.flatMap(JSON.Decode.string)->Option.getOr("")
192
197
  switch Bus.getQueryDb(name) {
193
198
  | Some(ops) =>
194
- let items =
195
- await ops.loadStream(id)
199
+ let load = key =>
200
+ ops.loadStream(key)
196
201
  ->Stream.runCollect
197
202
  ->Effect.catchAll(_ => Effect.succeed([]))
198
203
  ->Effect.runPromise
204
+ let firstAttempt = await load(id)
205
+ // The row advertises a Relay global id, so `X(id: row.id)` arrives here
206
+ // as one. Retried rather than decoded up front: the raw key is what this
207
+ // door has always taken, and a key that merely looks like base64 must
208
+ // keep resolving to its own row.
209
+ let (resolvedKey, items) = switch (
210
+ firstAttempt->Array.get(0),
211
+ Api_Ids.alternateKey(id),
212
+ ) {
213
+ | (None, Some(localId)) => (localId, await load(localId))
214
+ | _ => (id, firstAttempt)
215
+ }
199
216
  switch items->Array.get(0) {
200
217
  | Some(item) =>
201
218
  if includeIdParam {
202
- let obj = item->JSON.Decode.object->Option.getOr(Dict.make())
203
- obj->Dict.set("id", encodeId(~typeName=returnTypeName, ~localId=id)->JSON.Encode.string)
219
+ // Copied `JSON.Decode.object` hands back the stored object itself,
220
+ // so setting a field here would rewrite the row inside the QueryDb.
221
+ let obj = item->JSON.Decode.object->Option.mapOr(Dict.make(), Dict.copy)
222
+ // The storage key, which is what the list answers and what this door
223
+ // takes back. `resolvedKey`, not the argument: a caller who passed a
224
+ // Relay global id gets the raw key returned, so a round trip through
225
+ // this door converges on the one form instead of alternating.
226
+ obj->Dict.set("id", JSON.Encode.string(resolvedKey))
204
227
  JSON.Encode.object(obj)
205
228
  } else {
206
229
  item
@@ -237,21 +260,28 @@ module Make = (Bus: LocalBus.T) => {
237
260
  ->Array.filterMap(JSON.Decode.string)
238
261
  switch Bus.getQueryDb(name) {
239
262
  | Some(ops) =>
240
- let loaded = await ids->Array.map(id =>
241
- ops.loadStream(id)
263
+ let load = key =>
264
+ ops.loadStream(key)
242
265
  ->Stream.runCollect
243
266
  ->Effect.catchAll(_ => Effect.succeed([]))
244
267
  ->Effect.runPromise
245
- ->Promise.thenResolve(items => (id, items->Array.get(0)))
268
+ // Same either-form rule as the single-id door: raw key first, the
269
+ // key inside a Relay global id only on a miss.
270
+ let loaded = await ids->Array.map(async id =>
271
+ switch (await load(id))->Array.get(0) {
272
+ | Some(item) => (id, Some(item))
273
+ | None =>
274
+ switch Api_Ids.alternateKey(id) {
275
+ | Some(localId) => (localId, (await load(localId))->Array.get(0))
276
+ | None => (id, None)
277
+ }
278
+ }
246
279
  )->Promise.all
247
280
  loaded
248
281
  ->Array.filterMap(((id, opt)) =>
249
282
  opt->Option.map(item => {
250
- let obj = item->JSON.Decode.object->Option.getOr(Dict.make())
251
- obj->Dict.set(
252
- "id",
253
- encodeId(~typeName=returnTypeName, ~localId=id)->JSON.Encode.string,
254
- )
283
+ let obj = item->JSON.Decode.object->Option.mapOr(Dict.make(), Dict.copy)
284
+ obj->Dict.set("id", JSON.Encode.string(id))
255
285
  JSON.Encode.object(obj)
256
286
  })
257
287
  )
@@ -279,7 +309,7 @@ module Make = (Bus: LocalBus.T) => {
279
309
  // so the resolver derives the same serverCapability the FragmentGenerator emitted.
280
310
  let stateSchemaOpt = Plugin_Helpers.stateSchemaRegistry->Dict.get(name)
281
311
  let capability = switch stateSchemaOpt {
282
- | Some(s) => GraphQL_FragmentGenerator.deriveServerCapability(s)
312
+ | Some(s) => GraphQL_FragmentGenerator.deriveServerCapability(~entityName=name, s)
283
313
  | None => GraphQL_FragmentGenerator.emptyCapability
284
314
  }
285
315
 
@@ -7,6 +7,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
7
  import * as Effect from "effect/Effect";
8
8
  import * as Stdlib_Nullable from "@rescript/runtime/lib/es6/Stdlib_Nullable.js";
9
9
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
10
+ import * as Api_Ids$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Ids.res.mjs";
10
11
  import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
11
12
  import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
12
13
  import * as SortKey_Filter$ReventlessLocal from "./SortKey_Filter.res.mjs";
@@ -63,7 +64,7 @@ function Make(Bus) {
63
64
  if (item === undefined) {
64
65
  return;
65
66
  }
66
- let obj = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(item), {});
67
+ let obj = Stdlib_Option.mapOr(Stdlib_JSON.Decode.object(item), {}, prim => Object.assign({}, prim));
67
68
  obj["__typename"] = typeName;
68
69
  obj["id"] = relay.encodeGlobalId(typeName, localId);
69
70
  return obj;
@@ -95,7 +96,6 @@ function Make(Bus) {
95
96
  if (includeIdParam && relay !== undefined) {
96
97
  relay.registerNodeType(returnTypeName, name);
97
98
  }
98
- let encodeId = relay !== undefined ? (typeName, localId) => relay.encodeGlobalId(typeName, localId) : (param, localId) => localId;
99
99
  let byIdSdl = includeIdParam ? (
100
100
  subIdField !== undefined ? ` ` + singleQueryName + `(id: ID!, ` + subIdField + `: String): ` + returnTypeName : ` ` + singleQueryName + `(id: ID!): ` + returnTypeName
101
101
  ) : ` ` + singleQueryName + `: ` + returnTypeName;
@@ -109,16 +109,26 @@ function Make(Bus) {
109
109
  if (ops === undefined) {
110
110
  return null;
111
111
  }
112
- let items = await Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(id)), param => Effect.succeed([])));
113
- let item = items[0];
112
+ let load = key => Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(key)), param => Effect.succeed([])));
113
+ let firstAttempt = await load(id);
114
+ let match$1 = firstAttempt[0];
115
+ let match$2 = Api_Ids$ReventlessCore.alternateKey(id);
116
+ let match$3 = match$1 !== undefined || match$2 === undefined ? [
117
+ id,
118
+ firstAttempt
119
+ ] : [
120
+ match$2,
121
+ await load(match$2)
122
+ ];
123
+ let item = match$3[1][0];
114
124
  if (item === undefined) {
115
125
  return null;
116
126
  }
117
127
  if (!includeIdParam) {
118
128
  return item;
119
129
  }
120
- let obj = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(item), {});
121
- obj["id"] = encodeId(returnTypeName, id);
130
+ let obj = Stdlib_Option.mapOr(Stdlib_JSON.Decode.object(item), {}, prim => Object.assign({}, prim));
131
+ obj["id"] = match$3[0];
122
132
  return obj;
123
133
  };
124
134
  let byIdsSdl = includeIdParam && subIdField === undefined ? [GraphQL_FragmentGenerator$ReventlessCore.deriveByIdsQueryField(listQueryName, returnTypeName)] : [];
@@ -134,15 +144,33 @@ function Make(Bus) {
134
144
  if (ops === undefined) {
135
145
  return [];
136
146
  }
137
- let loaded = await Promise.all(ids.map(id => Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(id)), param => Effect.succeed([]))).then(items => [
138
- id,
139
- items[0]
140
- ])));
147
+ let load = key => Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(key)), param => Effect.succeed([])));
148
+ let loaded = await Promise.all(ids.map(async id => {
149
+ let item = (await load(id))[0];
150
+ if (item !== undefined) {
151
+ return [
152
+ id,
153
+ item
154
+ ];
155
+ }
156
+ let localId = Api_Ids$ReventlessCore.alternateKey(id);
157
+ if (localId !== undefined) {
158
+ return [
159
+ localId,
160
+ (await load(localId))[0]
161
+ ];
162
+ } else {
163
+ return [
164
+ id,
165
+ undefined
166
+ ];
167
+ }
168
+ }));
141
169
  return Stdlib_Array.filterMap(loaded, param => {
142
170
  let id = param[0];
143
171
  return Stdlib_Option.map(param[1], item => {
144
- let obj = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(item), {});
145
- obj["id"] = encodeId(returnTypeName, id);
172
+ let obj = Stdlib_Option.mapOr(Stdlib_JSON.Decode.object(item), {}, prim => Object.assign({}, prim));
173
+ obj["id"] = id;
146
174
  return obj;
147
175
  });
148
176
  });
@@ -156,7 +184,7 @@ function Make(Bus) {
156
184
  }
157
185
  let labelField = registryEntry !== undefined ? Stdlib_Option.getOr(registryEntry.labelField, "id") : "id";
158
186
  let stateSchemaOpt = Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name];
159
- let capability = stateSchemaOpt !== undefined ? GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(stateSchemaOpt) : GraphQL_FragmentGenerator$ReventlessCore.emptyCapability;
187
+ let capability = stateSchemaOpt !== undefined ? GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(name, stateSchemaOpt) : GraphQL_FragmentGenerator$ReventlessCore.emptyCapability;
160
188
  let fetchAllItems = async () => {
161
189
  let makeStream = Bus.getQueryDbStream(name);
162
190
  if (makeStream !== undefined) {
@@ -77,6 +77,14 @@ type plainState = {
77
77
  name: string,
78
78
  }
79
79
 
80
+ // No `*Id` field at all, so the key ladder has nothing to nominate — the state
81
+ // that still produces an empty capability.
82
+ @schema
83
+ type keylessState = {
84
+ name: string,
85
+ note: string,
86
+ }
87
+
80
88
  @schema
81
89
  type indexedState = {
82
90
  productId: @s.matches(Reventless.DcbTag.string) string,
@@ -582,7 +590,12 @@ describe("GraphQL_SchemaInspector", () => {
582
590
  // ── Phase 1: server capability — auto-derived Filter / OrderBy ─────────
583
591
 
584
592
  describe("server capability — auto-derived Filter / OrderBy", () => {
585
- testPromise("read model with no annotations emits the unchanged search-only Filter", async () => {
593
+ // This used to assert that a state with no annotations got a search-only
594
+ // Filter and no OrderBy at all. It no longer does: `plainState` has exactly
595
+ // one `*Id` field, so the key is knowable without `@id` and the queryable now
596
+ // gets that key's eq filter and sort. The search-only case is the one below,
597
+ // where nothing identifies the row.
598
+ testPromise("read model with no annotations gets its inferred key's Eq + OrderBy", async () => {
586
599
  let fragment = ReventlessCore.GraphQL_FragmentGenerator.generate(
587
600
  ~mutationEntries=[],
588
601
  ~queryEntries=[
@@ -599,16 +612,44 @@ describe("GraphQL_SchemaInspector", () => {
599
612
  )
600
613
  let inspection = ReventlessCore.GraphQL_SchemaInspector.inspectFragment(fragment)
601
614
  let sdl = inspection.sdlPreview
602
- // Filter has only the legacy block
615
+ // The legacy block is untouched
603
616
  expect(sdl->String.includes("input PlainViewFilter"))->toBe(true)
604
617
  expect(sdl->String.includes("search: String"))->toBe(true)
605
618
  expect(sdl->String.includes("searchPrefix: String"))->toBe(true)
606
619
  expect(sdl->String.includes("ids: [ID!]"))->toBe(true)
607
- // No per-field eq inputs
620
+ // and `productId`, the sole candidate, now narrows and sorts.
621
+ expect(sdl->String.includes("productIdEq: ID"))->toBe(true)
622
+ expect(sdl->String.includes("PlainViewOrderBy"))->toBe(true)
623
+ expect(sdl->String.includes("PlainViewOrderField"))->toBe(true)
624
+ expect(sdl->String.includes("orderBy:"))->toBe(true)
625
+ // Only the key — an inferred key never drags the rest of the record in.
626
+ expect(sdl->String.includes("nameEq:"))->toBe(false)
627
+ })
628
+
629
+ // The remaining empty-capability case: no `*Id` field, so nothing says which
630
+ // field identifies a row and the ladder declines rather than guessing.
631
+ testPromise("read model whose key cannot be resolved stays search-only", async () => {
632
+ let fragment = ReventlessCore.GraphQL_FragmentGenerator.generate(
633
+ ~mutationEntries=[],
634
+ ~queryEntries=[
635
+ {
636
+ singleFieldName: "Keyless_View",
637
+ listFieldName: "Keyless_Views",
638
+ returnTypeName: "KeylessView",
639
+ stateSchema: keylessStateSchema->S.castToUnknown,
640
+ authorization: None,
641
+ includeIdParam: false,
642
+ connectionSpec: true,
643
+ },
644
+ ],
645
+ )
646
+ let inspection = ReventlessCore.GraphQL_SchemaInspector.inspectFragment(fragment)
647
+ let sdl = inspection.sdlPreview
648
+ expect(sdl->String.includes("input KeylessViewFilter"))->toBe(true)
649
+ expect(sdl->String.includes("search: String"))->toBe(true)
608
650
  expect(sdl->String.includes("Eq:"))->toBe(false)
609
- // No OrderBy / OrderField types or arg
610
- expect(sdl->String.includes("PlainViewOrderBy"))->toBe(false)
611
- expect(sdl->String.includes("PlainViewOrderField"))->toBe(false)
651
+ expect(sdl->String.includes("KeylessViewOrderBy"))->toBe(false)
652
+ expect(sdl->String.includes("KeylessViewOrderField"))->toBe(false)
612
653
  expect(sdl->String.includes("orderBy:"))->toBe(false)
613
654
  })
614
655
 
@@ -84,6 +84,11 @@ let plainStateSchema = S.schema(s => ({
84
84
  name: s.m(S.string)
85
85
  }));
86
86
 
87
+ let keylessStateSchema = S.schema(s => ({
88
+ name: s.m(S.string),
89
+ note: s.m(S.string)
90
+ }));
91
+
87
92
  let indexedStateSchema = S.schema(s => ({
88
93
  productId: s.m(DcbTag$Reventless.string),
89
94
  ownerId: s.m(S.string),
@@ -453,7 +458,7 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
453
458
  });
454
459
  });
455
460
  globalThis.describe("server capability — auto-derived Filter / OrderBy", () => {
456
- globalThis.test("read model with no annotations emits the unchanged search-only Filter", async () => {
461
+ globalThis.test("read model with no annotations gets its inferred key's Eq + OrderBy", async () => {
457
462
  let fragment = GraphQL_FragmentGenerator$ReventlessCore.generate([], [{
458
463
  singleFieldName: "Plain_View",
459
464
  listFieldName: "Plain_Views",
@@ -469,9 +474,29 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
469
474
  globalThis.expect(sdl.includes("search: String")).toBe(true);
470
475
  globalThis.expect(sdl.includes("searchPrefix: String")).toBe(true);
471
476
  globalThis.expect(sdl.includes("ids: [ID!]")).toBe(true);
477
+ globalThis.expect(sdl.includes("productIdEq: ID")).toBe(true);
478
+ globalThis.expect(sdl.includes("PlainViewOrderBy")).toBe(true);
479
+ globalThis.expect(sdl.includes("PlainViewOrderField")).toBe(true);
480
+ globalThis.expect(sdl.includes("orderBy:")).toBe(true);
481
+ globalThis.expect(sdl.includes("nameEq:")).toBe(false);
482
+ });
483
+ globalThis.test("read model whose key cannot be resolved stays search-only", async () => {
484
+ let fragment = GraphQL_FragmentGenerator$ReventlessCore.generate([], [{
485
+ singleFieldName: "Keyless_View",
486
+ listFieldName: "Keyless_Views",
487
+ returnTypeName: "KeylessView",
488
+ stateSchema: keylessStateSchema,
489
+ authorization: undefined,
490
+ includeIdParam: false,
491
+ connectionSpec: true
492
+ }]);
493
+ let inspection = GraphQL_SchemaInspector$ReventlessCore.inspectFragment(fragment);
494
+ let sdl = inspection.sdlPreview;
495
+ globalThis.expect(sdl.includes("input KeylessViewFilter")).toBe(true);
496
+ globalThis.expect(sdl.includes("search: String")).toBe(true);
472
497
  globalThis.expect(sdl.includes("Eq:")).toBe(false);
473
- globalThis.expect(sdl.includes("PlainViewOrderBy")).toBe(false);
474
- globalThis.expect(sdl.includes("PlainViewOrderField")).toBe(false);
498
+ globalThis.expect(sdl.includes("KeylessViewOrderBy")).toBe(false);
499
+ globalThis.expect(sdl.includes("KeylessViewOrderField")).toBe(false);
475
500
  globalThis.expect(sdl.includes("orderBy:")).toBe(false);
476
501
  });
477
502
  globalThis.test("read model with @id + @index emits Eq filters + OrderBy + orderBy arg", async () => {
@@ -701,6 +726,7 @@ export {
701
726
  dcbSingleCommandSchema,
702
727
  dcbMultiCommandSchema,
703
728
  plainStateSchema,
729
+ keylessStateSchema,
704
730
  indexedStateSchema,
705
731
  indexedStateSchemaWithAnnotations,
706
732
  orderedStateSchema,