@reventlessdev/reventless-local 3.0.0-alpha.225 → 3.0.0-alpha.227

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.
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
4
+ import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
4
5
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
6
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
@@ -16,6 +17,7 @@ import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/s
16
17
  import * as SortKey_Filter$ReventlessLocal from "./SortKey_Filter.res.mjs";
17
18
  import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
18
19
  import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Callback.res.mjs";
20
+ import * as GraphQL_CallerError$ReventlessLocal from "../GraphQL_CallerError.res.mjs";
19
21
  import * as DomainGraphQL_Server$ReventlessLocal from "../DomainGraphQL_Server.res.mjs";
20
22
  import * as OwnerScopeDiagnostics$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/OwnerScopeDiagnostics.res.mjs";
21
23
  import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
@@ -112,7 +114,6 @@ function Make(Bus) {
112
114
  return true;
113
115
  }
114
116
  };
115
- let cap = s => s.charAt(0).toUpperCase() + s.slice(1);
116
117
  let registryEntry = Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry[name];
117
118
  let singleQueryName = registryEntry !== undefined ? registryEntry.singleFieldName : name.charAt(0).toLowerCase() + name.slice(1);
118
119
  let listQueryName = registryEntry !== undefined ? registryEntry.listFieldName : name + "s";
@@ -124,7 +125,7 @@ function Make(Bus) {
124
125
  relay.registerNodeType(returnTypeName, name);
125
126
  }
126
127
  let byIdSdl = includeIdParam ? (
127
- subIdField !== undefined ? ` ` + singleQueryName + `(id: ID!, ` + subIdField + `: String): ` + returnTypeName : ` ` + singleQueryName + `(id: ID!): ` + returnTypeName
128
+ subIdField !== undefined ? ` ` + singleQueryName + `(id: ID!, ` + subIdField + `: String, includeRetired: Boolean): ` + returnTypeName : ` ` + singleQueryName + `(id: ID!, includeRetired: Boolean): ` + returnTypeName
128
129
  ) : ` ` + singleQueryName + `: ` + returnTypeName;
129
130
  let byIdResolver = async (_root, args, ctx) => {
130
131
  let match = await runInterceptor(ctx, args);
@@ -219,6 +220,101 @@ function Make(Bus) {
219
220
  byIdsResolverEntry = undefined;
220
221
  }
221
222
  let labelField = registryEntry !== undefined ? Stdlib_Option.getOr(registryEntry.labelField, "id") : "id";
223
+ let refsSdl = includeIdParam && subIdField === undefined ? [GraphQL_FragmentGenerator$ReventlessCore.deriveRefsQueryField(listQueryName, returnTypeName)] : [];
224
+ let refsResolverEntry;
225
+ if (includeIdParam && subIdField === undefined) {
226
+ server.registerTypes([GraphQL_FragmentGenerator$ReventlessCore.deriveRefTypeSdl(returnTypeName)]);
227
+ let resolver$1 = async (_root, args, ctx) => {
228
+ let match = await runInterceptor(ctx, args);
229
+ if (typeof match === "object") {
230
+ return [];
231
+ }
232
+ let ids = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d["ids"]), Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.string);
233
+ let spec = retiredSpecOf();
234
+ let namesRetired = Stdlib_Option.mapOr(spec, false, r => r.namedWhenRetired);
235
+ let retirementOf = item => {
236
+ if (spec === undefined) {
237
+ return [
238
+ false,
239
+ undefined
240
+ ];
241
+ }
242
+ let cell = Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[spec.field]);
243
+ let scope_field = spec.field;
244
+ let scope_values = spec.values;
245
+ let scope = {
246
+ field: scope_field,
247
+ values: scope_values
248
+ };
249
+ let retired = OwnerScope$Reventless.isRetiredValue(scope, cell);
250
+ return [
251
+ retired,
252
+ retired && Stdlib_Option.isSome(spec.values) ? Stdlib_Option.flatMap(cell, Stdlib_JSON.Decode.string) : undefined
253
+ ];
254
+ };
255
+ let ops = Bus.getQueryDb(name);
256
+ if (ops === undefined) {
257
+ return [];
258
+ }
259
+ let load = key => Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(key)), param => Effect.succeed([])));
260
+ let loaded = await Promise.all(ids.map(async id => {
261
+ let item = (await load(id))[0];
262
+ if (item !== undefined) {
263
+ return [
264
+ id,
265
+ item
266
+ ];
267
+ }
268
+ let localId = Api_Ids$ReventlessCore.alternateKey(id);
269
+ if (localId !== undefined) {
270
+ return [
271
+ localId,
272
+ (await load(localId))[0]
273
+ ];
274
+ } else {
275
+ return [
276
+ id,
277
+ undefined
278
+ ];
279
+ }
280
+ }));
281
+ return Stdlib_Array.filterMap(loaded, param => {
282
+ let id = param[0];
283
+ return Stdlib_Option.flatMap(Stdlib_Option.filter(param[1], item => ownerAllows(ctx, item)), item => {
284
+ let match = retirementOf(item);
285
+ let retired = match[0];
286
+ if (retired && !namesRetired) {
287
+ return;
288
+ }
289
+ let label = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[labelField]), Stdlib_JSON.Decode.string), id);
290
+ return Object.fromEntries([
291
+ [
292
+ "id",
293
+ id
294
+ ],
295
+ [
296
+ "label",
297
+ label
298
+ ],
299
+ [
300
+ "retired",
301
+ retired
302
+ ],
303
+ [
304
+ "retiredState",
305
+ Stdlib_Option.mapOr(match[1], null, prim => prim)
306
+ ]
307
+ ]);
308
+ });
309
+ });
310
+ };
311
+ refsResolverEntry = [
312
+ listQueryName + "Refs",
313
+ resolver$1
314
+ ];
315
+ } else {
316
+ refsResolverEntry = undefined;
317
+ }
222
318
  let stateSchemaOpt = Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name];
223
319
  let capability = stateSchemaOpt !== undefined ? GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(name, stateSchemaOpt) : GraphQL_FragmentGenerator$ReventlessCore.emptyCapability;
224
320
  let fetchAllItems = async () => {
@@ -250,7 +346,7 @@ function Make(Bus) {
250
346
  endCursor: null
251
347
  }
252
348
  };
253
- let resolver$1 = async (_root, args, ctx) => {
349
+ let resolver$2 = async (_root, args, ctx) => {
254
350
  let match = await runInterceptor(ctx, args);
255
351
  if (typeof match === "object") {
256
352
  return emptyConnection;
@@ -277,11 +373,11 @@ function Make(Bus) {
277
373
  };
278
374
  match = [
279
375
  sdl,
280
- resolver$1
376
+ resolver$2
281
377
  ];
282
378
  } else {
283
379
  let sdl$1 = [` ` + listQueryName + `(nextToken: String, limit: Int): ` + pluralTypeName + `!`];
284
- let resolver$2 = async (_root, args, ctx) => {
380
+ let resolver$3 = async (_root, args, ctx) => {
285
381
  let match = await runInterceptor(ctx, args);
286
382
  if (typeof match === "object") {
287
383
  return {
@@ -306,7 +402,7 @@ function Make(Bus) {
306
402
  };
307
403
  match = [
308
404
  sdl$1,
309
- resolver$2
405
+ resolver$3
310
406
  ];
311
407
  }
312
408
  let listResolvers = [[
@@ -324,7 +420,7 @@ function Make(Bus) {
324
420
  }
325
421
  let itemsResolvers;
326
422
  if (subIdField !== undefined) {
327
- let resolver$3 = async (_root, args, ctx) => {
423
+ let resolver$4 = async (_root, args, ctx) => {
328
424
  let emptyConn = {
329
425
  edges: [],
330
426
  pageInfo: {
@@ -423,22 +519,81 @@ function Make(Bus) {
423
519
  };
424
520
  itemsResolvers = [[
425
521
  singleQueryName + "Items",
426
- resolver$3
522
+ resolver$4
427
523
  ]];
428
524
  } else {
429
525
  itemsResolvers = [];
430
526
  }
431
- let indexSdlFields = indexes.map(ic => ` ` + singleQueryName + `By` + cap(ic.index) + `(` + ic.index + `: String!): [String]`);
527
+ let indexSdlFields = indexes.map(ic => GraphQL_FragmentGenerator$ReventlessCore.deriveIndexQueryField(singleQueryName, ic, returnTypeName + "Connection"));
528
+ let rejectBackwardPaging = args => {
529
+ let given = key => {
530
+ let match = Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d[key]);
531
+ if (match !== undefined) {
532
+ return match !== null;
533
+ } else {
534
+ return false;
535
+ }
536
+ };
537
+ if (!(given("last") || given("before"))) {
538
+ return;
539
+ }
540
+ throw GraphQL_CallerError$ReventlessLocal.badUserInput("Backward pagination (last/before) is not supported on by-index connections; use first/after.");
541
+ };
542
+ let indexConnection = (args, items) => {
543
+ let arg = key => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d[key]);
544
+ let after = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(arg("after"), Stdlib_JSON.Decode.string), s => Stdlib_Int.fromString(s, undefined)), -1);
545
+ let first = Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(arg("first"), Stdlib_JSON.Decode.float), prim => prim | 0), 50);
546
+ let start = after + 1 | 0;
547
+ let page = items.slice(start, start + first | 0);
548
+ let taken = page.length;
549
+ let edges = page.map((item, i) => Object.fromEntries([
550
+ [
551
+ "node",
552
+ item
553
+ ],
554
+ [
555
+ "cursor",
556
+ (start + i | 0).toString()
557
+ ]
558
+ ]));
559
+ return Object.fromEntries([
560
+ [
561
+ "edges",
562
+ edges
563
+ ],
564
+ [
565
+ "pageInfo",
566
+ Object.fromEntries([
567
+ [
568
+ "hasNextPage",
569
+ (start + taken | 0) < items.length
570
+ ],
571
+ [
572
+ "hasPreviousPage",
573
+ start > 0
574
+ ],
575
+ [
576
+ "startCursor",
577
+ taken > 0 ? start.toString() : null
578
+ ],
579
+ [
580
+ "endCursor",
581
+ taken > 0 ? ((start + taken | 0) - 1 | 0).toString() : null
582
+ ]
583
+ ])
584
+ ]
585
+ ]);
586
+ };
432
587
  let indexResolvers = indexes.map(ic => {
433
- let index = ic.index;
434
- let resolverName = singleQueryName + "By" + cap(index);
435
- let filterField = Stdlib_Option.getOr(ic.idField, index);
588
+ let resolverName = GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName(singleQueryName, ic.index);
589
+ let filterField = GraphQL_FragmentGenerator$ReventlessCore.indexKeyField(ic);
436
590
  let resolver = async (_root, args, ctx) => {
437
591
  let match = await runInterceptor(ctx, args);
438
592
  if (typeof match === "object") {
439
- return [];
593
+ return indexConnection(args, []);
440
594
  }
441
- let value = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d[index]), Stdlib_JSON.Decode.string), "");
595
+ rejectBackwardPaging(args);
596
+ let value = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d[filterField]), Stdlib_JSON.Decode.string), "");
442
597
  let scoped = rows => rows.filter(item => {
443
598
  if (ownerAllows(ctx, item)) {
444
599
  return retiredAllows(ctx, args, item);
@@ -448,13 +603,13 @@ function Make(Bus) {
448
603
  });
449
604
  let lookup = Bus.getQueryDbIndexLookup(name);
450
605
  if (lookup !== undefined) {
451
- return scoped(lookup(filterField, value));
606
+ return indexConnection(args, scoped(lookup(filterField, value)));
452
607
  }
453
608
  let scanAll = Bus.getQueryDbScan(name);
454
609
  if (scanAll !== undefined) {
455
- return scoped(scanAll().filter(item => Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[filterField]), Stdlib_JSON.Decode.string), v => v === value), false)));
610
+ return indexConnection(args, scoped(scanAll().filter(item => Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[filterField]), Stdlib_JSON.Decode.string), v => v === value), false))));
456
611
  } else {
457
- return [];
612
+ return indexConnection(args, []);
458
613
  }
459
614
  };
460
615
  return [
@@ -462,12 +617,15 @@ function Make(Bus) {
462
617
  resolver
463
618
  ];
464
619
  });
465
- let allSdl = [byIdSdl].concat(byIdsSdl).concat(match[0]).concat(itemsSdl).concat(indexSdlFields);
620
+ let allSdl = [byIdSdl].concat(byIdsSdl).concat(refsSdl).concat(match[0]).concat(itemsSdl).concat(indexSdlFields);
466
621
  let resolvers = {};
467
622
  resolvers[singleQueryName] = byIdResolver;
468
623
  Stdlib_Option.forEach(byIdsResolverEntry, param => {
469
624
  resolvers[param[0]] = param[1];
470
625
  });
626
+ Stdlib_Option.forEach(refsResolverEntry, param => {
627
+ resolvers[param[0]] = param[1];
628
+ });
471
629
  listResolvers.forEach(param => {
472
630
  resolvers[param[0]] = param[1];
473
631
  });
@@ -103,30 +103,54 @@ describe("UiHints.emit", () => {
103
103
  // point, while mid-session it is almost always an editor mid-save, and killing a
104
104
  // running dev server over a keystroke is worse than the restart this replaces.
105
105
 
106
- // Both exits close the watcher AND clear the deadline. A watcher is `unref`ed
106
+ // The edit is repeated, and that is not belt-and-braces.
107
+ //
108
+ // `fs.watch` does not promise that a write landing near the watcher's creation
109
+ // is reported. The FSEvents stream behind it on macOS is armed asynchronously,
110
+ // and a write that gets there first is not reported late — it is not reported at
111
+ // all. Measured against this very module: once the stream is up the edit is seen
112
+ // in about 10ms, and when it is not, the same edit produces no event in four
113
+ // seconds, with which of the two happens varying by how many watchers the
114
+ // process has already opened rather than by anything the test does.
115
+ //
116
+ // So a single edit tests the arming, not the reload. Repeating it on an interval
117
+ // far longer than a reload takes — 50ms of debounce plus a file copy — leaves the
118
+ // case where the first write lands identical to what it always was, and lets the
119
+ // case where it is dropped recover on the next one instead of reporting that hot
120
+ // reload is broken. Re-writing the same bytes is what a developer saving twice
121
+ // does, so nothing here asserts less than it did.
122
+ let retryEvery = 1000
123
+
124
+ // All three exits close the watcher AND clear both timers. A watcher is `unref`ed
107
125
  // and would not hold the run open on its own, but a live 4-second timer would —
108
126
  // and "Jest did not exit" on a suite that passed is exactly the noise that
109
127
  // teaches a reader to ignore it.
110
- let waitForReload = (~timeoutMs: int=4000, ~afterWatching: unit => unit, ~uiHintsFile, ~dir) =>
128
+ let waitForReload = (~timeoutMs: int=4000, ~edit: unit => unit, ~uiHintsFile, ~dir) =>
111
129
  Promise.make((resolve, _) => {
112
130
  let fired = ref(0)
113
131
  let watcher = ref(None)
114
132
  let deadline = ref(None)
133
+ let retry = ref(None)
115
134
  // Resolves with a count rather than rejecting on the deadline: "nothing was
116
135
  // re-served" is the expected answer in half these cases, and a rejection
117
136
  // would make the assertion read as an infrastructure failure.
118
137
  let finish = () => {
119
138
  watcher.contents->Option.forEach(NodeFs.watcherClose)
120
139
  deadline.contents->Option.forEach(clearTimeout)
140
+ retry.contents->Option.forEach(clearTimeout)
121
141
  resolve(fired.contents)
122
142
  }
143
+ let rec editUntilSeen = () => {
144
+ edit()
145
+ retry := Some(setTimeout(editUntilSeen, retryEvery))
146
+ }
123
147
  watcher :=
124
148
  UiHints.watch(~uiHintsFile, ~dir, ~onReload=() => {
125
149
  fired := fired.contents + 1
126
150
  finish()
127
151
  })
128
152
  deadline := Some(setTimeout(finish, timeoutMs))
129
- afterWatching()
153
+ editUntilSeen()
130
154
  })
131
155
 
132
156
  describe("UiHints.watch", () => {
@@ -147,7 +171,7 @@ describe("UiHints.watch", () => {
147
171
  let fired = await waitForReload(
148
172
  ~uiHintsFile=Some(path),
149
173
  ~dir,
150
- ~afterWatching=() => NodeFs.writeFileSync(path, edited),
174
+ ~edit=() => NodeFs.writeFileSync(path, edited),
151
175
  )
152
176
  expect((fired > 0, served(dir)))->toEqual((true, edited))
153
177
  })
@@ -163,7 +187,7 @@ describe("UiHints.watch", () => {
163
187
  ~timeoutMs=1500,
164
188
  ~uiHintsFile=Some(path),
165
189
  ~dir,
166
- ~afterWatching=() => NodeFs.writeFileSync(path, `{"Catalog": {"views":`),
190
+ ~edit=() => NodeFs.writeFileSync(path, `{"Catalog": {"views":`),
167
191
  )
168
192
  expect((fired, served(dir)))->toEqual((0, declared))
169
193
  })
@@ -178,7 +202,10 @@ describe("UiHints.watch", () => {
178
202
  let fired = await waitForReload(
179
203
  ~uiHintsFile=Some(path),
180
204
  ~dir,
181
- ~afterWatching=() => {
205
+ // Both halves of the save, so a repeat of this edit is the same story told
206
+ // again and still ends on the whole file — never a truncated write left
207
+ // standing after a complete one.
208
+ ~edit=() => {
182
209
  NodeFs.writeFileSync(path, `{"Catalog": {"views":`)
183
210
  let _ = setTimeout(() => NodeFs.writeFileSync(path, whole), 200)
184
211
  },
@@ -88,7 +88,7 @@ globalThis.describe("UiHints.emit", () => {
88
88
  });
89
89
  });
90
90
 
91
- function waitForReload(timeoutMsOpt, afterWatching, uiHintsFile, dir) {
91
+ function waitForReload(timeoutMsOpt, edit, uiHintsFile, dir) {
92
92
  let timeoutMs = timeoutMsOpt !== undefined ? timeoutMsOpt : 4000;
93
93
  return new Promise((resolve, param) => {
94
94
  let fired = {
@@ -100,6 +100,9 @@ function waitForReload(timeoutMsOpt, afterWatching, uiHintsFile, dir) {
100
100
  let deadline = {
101
101
  contents: undefined
102
102
  };
103
+ let retry = {
104
+ contents: undefined
105
+ };
103
106
  let finish = () => {
104
107
  Stdlib_Option.forEach(watcher.contents, prim => {
105
108
  prim.close();
@@ -107,14 +110,21 @@ function waitForReload(timeoutMsOpt, afterWatching, uiHintsFile, dir) {
107
110
  Stdlib_Option.forEach(deadline.contents, prim => {
108
111
  clearTimeout(prim);
109
112
  });
113
+ Stdlib_Option.forEach(retry.contents, prim => {
114
+ clearTimeout(prim);
115
+ });
110
116
  resolve(fired.contents);
111
117
  };
118
+ let editUntilSeen = () => {
119
+ edit();
120
+ retry.contents = Primitive_option.some(setTimeout(editUntilSeen, 1000));
121
+ };
112
122
  watcher.contents = UiHints$ReventlessLocal.watch(uiHintsFile, dir, () => {
113
123
  fired.contents = fired.contents + 1 | 0;
114
124
  finish();
115
125
  });
116
126
  deadline.contents = Primitive_option.some(setTimeout(finish, timeoutMs));
117
- afterWatching();
127
+ editUntilSeen();
118
128
  });
119
129
  }
120
130
 
@@ -178,6 +188,8 @@ globalThis.describe("UiHints.watch", () => {
178
188
  });
179
189
  });
180
190
 
191
+ let retryEvery = 1000;
192
+
181
193
  export {
182
194
  shipped,
183
195
  declared,
@@ -187,6 +199,7 @@ export {
187
199
  served,
188
200
  baselinePath,
189
201
  threw,
202
+ retryEvery,
190
203
  waitForReload,
191
204
  }
192
205
  /* Not a pure module */
@@ -94,7 +94,7 @@ describe("DcbEventLogStorage_Sqlite", () => {
94
94
  // Backend contract: an empty tag value (an absent composite-partition member) is
95
95
  // legitimate — it appends, is recorded verbatim, and keeps matching through both
96
96
  // the partition read and a composite read. Held identically by the in-memory and
97
- // DynamoDB backends. See docs/plans/dcb-empty-tag-values-break-append.md.
97
+ // DynamoDB backends. See docs/plans/done/dcb-empty-tag-values-break-append.md.
98
98
  testPromise("an empty tag value appends and stays readable", async () => {
99
99
  await runUnderSqlite(async () => {
100
100
  module TestBus = LocalBus.Make()
@@ -145,7 +145,7 @@ describe("DcbEventLogStorage_InMemory", () => {
145
145
  // An empty tag value is a legitimate model state — an absent member of a
146
146
  // composite partition key. Every backend must accept it, record it verbatim,
147
147
  // and keep matching the event through its partition and composite reads. See
148
- // docs/plans/dcb-empty-tag-values-break-append.md.
148
+ // docs/plans/done/dcb-empty-tag-values-break-append.md.
149
149
  testPromise("an empty tag value appends and stays readable", async () => {
150
150
  let storage = makeStorage()
151
151
  let ops = await storage.operations->TestRunner.resolve
@@ -1,5 +1,5 @@
1
1
  // Backend-parity tests for the EventLog snapshot ops
2
- // (docs/plans/aggregate-snapshotting.md): the in-memory and SQLite backends
2
+ // (docs/plans/done/aggregate-snapshotting.md): the in-memory and SQLite backends
3
3
  // must behave identically for latestSnapshot / writeSnapshot (keep-one) and
4
4
  // the replayStream ~fromSeq delta read, so GWT/local tests of
5
5
  // snapshot-enabled aggregates give the same results under either backend.
@@ -271,7 +271,7 @@ describe("GraphQL_SchemaInspector", () => {
271
271
  expect(result.typeDef->Option.isSome)->toBe(true)
272
272
  let typeDef = result.typeDef->Option.getOrThrow
273
273
  expect(typeDef->String.includes("type CatalogProduct"))->toBe(true)
274
- expect(result.singleQuery->String.includes("Catalog_Product(id: ID!)"))->toBe(true)
274
+ expect(result.singleQuery->String.includes("Catalog_Product(id: ID!, includeRetired: Boolean)"))->toBe(true)
275
275
  expect(result.singleQuery->String.includes("CatalogProduct"))->toBe(true)
276
276
  expect(result.listQuery->Option.isSome)->toBe(true)
277
277
  let listQ = result.listQuery->Option.getOrThrow
@@ -298,7 +298,7 @@ describe("GraphQL_SchemaInspector", () => {
298
298
  )
299
299
  let inspection = ReventlessCore.GraphQL_SchemaInspector.inspectFragment(fragment)
300
300
  let sdl = inspection.sdlPreview
301
- expect(sdl->String.includes("RM_Product(id: ID!): RMProduct"))->toBe(true)
301
+ expect(sdl->String.includes("RM_Product(id: ID!, includeRetired: Boolean): RMProduct"))->toBe(true)
302
302
  expect(sdl->String.includes("type RMProduct"))->toBe(true)
303
303
  // The type should have an injected id: ID! field (first field in the type)
304
304
  let typeLines = sdl->String.split("\n")
@@ -357,7 +357,7 @@ describe("GraphQL_SchemaInspector", () => {
357
357
  )
358
358
  let inspection = ReventlessCore.GraphQL_SchemaInspector.inspectFragment(fragment)
359
359
  let sdl = inspection.sdlPreview
360
- expect(sdl->String.includes("Default_Thing(id: ID!): DefaultThing"))->toBe(true)
360
+ expect(sdl->String.includes("Default_Thing(id: ID!, includeRetired: Boolean): DefaultThing"))->toBe(true)
361
361
  })
362
362
  })
363
363
 
@@ -542,13 +542,15 @@ describe("GraphQL_SchemaInspector", () => {
542
542
  ],
543
543
  )
544
544
  let inspection = ReventlessCore.GraphQL_SchemaInspector.inspectFragment(fragment)
545
- // TestState, TestStateEdge, TestStateConnection, TestStateFilter (Phase 4 connection filter)
545
+ // TestState, TestStateEdge, TestStateConnection, TestStateFilter (connection filter)
546
+ // + TestStateRef (the reference door's projection)
546
547
  // + CommandResult union + CommandAccepted + CommandRejected + CommandPending
547
548
  // (auto-injected whenever the fragment emits any mutation field).
548
- expect(inspection.types->Array.length)->toBe(8)
549
+ expect(inspection.types->Array.length)->toBe(9)
549
550
  expect(inspection.mutations->Array.length)->toBe(1)
550
- // single Test_State(id), list Test_States(...), and Test_StatesByIds(ids: [String!]!)
551
- expect(inspection.queries->Array.length)->toBe(3)
551
+ // single Test_State(id), list Test_States(...), Test_StatesByIds(ids: [String!]!)
552
+ // and Test_StatesRefs(ids: [ID!]!) — the by-ids read projected to a reference.
553
+ expect(inspection.queries->Array.length)->toBe(4)
552
554
  expect(inspection.sdlPreview->String.includes("type TestState"))->toBe(true)
553
555
  expect(inspection.sdlPreview->String.includes("type TestStateEdge"))->toBe(true)
554
556
  expect(inspection.sdlPreview->String.includes("type TestStateConnection"))->toBe(true)
@@ -239,7 +239,7 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
239
239
  globalThis.expect(Stdlib_Option.isSome(result.typeDef)).toBe(true);
240
240
  let typeDef = Stdlib_Option.getOrThrow(result.typeDef, undefined);
241
241
  globalThis.expect(typeDef.includes("type CatalogProduct")).toBe(true);
242
- globalThis.expect(result.singleQuery.includes("Catalog_Product(id: ID!)")).toBe(true);
242
+ globalThis.expect(result.singleQuery.includes("Catalog_Product(id: ID!, includeRetired: Boolean)")).toBe(true);
243
243
  globalThis.expect(result.singleQuery.includes("CatalogProduct")).toBe(true);
244
244
  globalThis.expect(Stdlib_Option.isSome(result.listQuery)).toBe(true);
245
245
  let listQ = Stdlib_Option.getOrThrow(result.listQuery, undefined);
@@ -258,7 +258,7 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
258
258
  }]);
259
259
  let inspection = GraphQL_SchemaInspector$ReventlessCore.inspectFragment(fragment);
260
260
  let sdl = inspection.sdlPreview;
261
- globalThis.expect(sdl.includes("RM_Product(id: ID!): RMProduct")).toBe(true);
261
+ globalThis.expect(sdl.includes("RM_Product(id: ID!, includeRetired: Boolean): RMProduct")).toBe(true);
262
262
  globalThis.expect(sdl.includes("type RMProduct")).toBe(true);
263
263
  let typeLines = sdl.split("\n");
264
264
  let idFieldInType = typeLines.some(line => {
@@ -302,7 +302,7 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
302
302
  }]);
303
303
  let inspection = GraphQL_SchemaInspector$ReventlessCore.inspectFragment(fragment);
304
304
  let sdl = inspection.sdlPreview;
305
- globalThis.expect(sdl.includes("Default_Thing(id: ID!): DefaultThing")).toBe(true);
305
+ globalThis.expect(sdl.includes("Default_Thing(id: ID!, includeRetired: Boolean): DefaultThing")).toBe(true);
306
306
  });
307
307
  });
308
308
  globalThis.describe("Relay compliance", () => {
@@ -424,9 +424,9 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
424
424
  authorization: undefined
425
425
  }]);
426
426
  let inspection = GraphQL_SchemaInspector$ReventlessCore.inspectFragment(fragment);
427
- globalThis.expect(inspection.types.length).toBe(8);
427
+ globalThis.expect(inspection.types.length).toBe(9);
428
428
  globalThis.expect(inspection.mutations.length).toBe(1);
429
- globalThis.expect(inspection.queries.length).toBe(3);
429
+ globalThis.expect(inspection.queries.length).toBe(4);
430
430
  globalThis.expect(inspection.sdlPreview.includes("type TestState")).toBe(true);
431
431
  globalThis.expect(inspection.sdlPreview.includes("type TestStateEdge")).toBe(true);
432
432
  globalThis.expect(inspection.sdlPreview.includes("type TestStateConnection")).toBe(true);
@@ -102,7 +102,7 @@ let pageInfoString = (response: JSON.t, key: string): option<string> =>
102
102
  // Per-test fixture — fresh Bus + storage + registry + resolver
103
103
  // ─────────────────────────────────────────────────────────────
104
104
 
105
- let buildFixture = async (~name: string) => {
105
+ let buildFixture = async (~name: string, ~indexes: array<Reventless.ReadModel.indexConfig>=[]) => {
106
106
  module Bus = LocalBus.Make()
107
107
  module Storage = LocalQueryDbStorage.Make(Bus)
108
108
  module Resolvers = QueryDbResolvers_GraphQL.Make(Bus)
@@ -151,7 +151,7 @@ let buildFixture = async (~name: string) => {
151
151
  ~api=(),
152
152
  ~apiRole=(),
153
153
  ~dataSourceName=""->Pulumi.Output.make,
154
- ~indexes=[],
154
+ ~indexes,
155
155
  ~subIdField=None,
156
156
  ~idResolverConfigs=[],
157
157
  ~idsResolverConfigs=[],
@@ -440,3 +440,83 @@ describe("QueryDb list resolver — SQLite push-down path", () => {
440
440
  })
441
441
  )
442
442
  })
443
+
444
+ // The by-index door answers a Relay connection, keyed on the index's column.
445
+ // Both halves used to be wrong here and in the opposite direction on AppSync:
446
+ // this backend promised `[String]` and returned whole rows, so every call failed
447
+ // to serialise before a caller could reach the rows at all.
448
+ describe("QueryDb by-index resolver", () => {
449
+ let statusIndex: array<Reventless.ReadModel.indexConfig> = [
450
+ {index: "status", type_: "S", projectionType: ALL},
451
+ ]
452
+
453
+ let indexResolver = async (~name) => {
454
+ let _ = await buildFixture(~name, ~indexes=statusIndex)
455
+ switch DomainGraphQL_Server.getQueryResolver(name ++ "ByStatus") {
456
+ | Some(r) => r
457
+ | None => JsError.throwWithMessage("index resolver not registered: " ++ name ++ "ByStatus")
458
+ }
459
+ }
460
+
461
+ beforeEach(() => {
462
+ DomainGraphQL_Server.reset()
463
+ })
464
+
465
+ testPromise("answers the rows carrying the index value, as edges", async () => {
466
+ let resolver = await indexResolver(~name="IdxA")
467
+ let response = await resolver(JSON.Encode.null, argsOf([("status", strJson("active"))]), emptyCtx)
468
+ let edges = getEdges(response)
469
+ expect(edges->Array.length)->toBe(3)
470
+ expect(edgeNodeField(edges->Array.getUnsafe(0), "productId"))->toBe("p-1")
471
+ })
472
+
473
+ testPromise("pages forward on first/after like every other connection", async () => {
474
+ let resolver = await indexResolver(~name="IdxB")
475
+ let page1 =
476
+ await resolver(
477
+ JSON.Encode.null,
478
+ argsOf([("status", strJson("active")), ("first", numJson(2))]),
479
+ emptyCtx,
480
+ )
481
+ expect(getEdges(page1)->Array.length)->toBe(2)
482
+ expect(pageInfoBool(page1, "hasNextPage"))->toBe(true)
483
+
484
+ let page2 =
485
+ await resolver(
486
+ JSON.Encode.null,
487
+ argsOf([
488
+ ("status", strJson("active")),
489
+ ("first", numJson(2)),
490
+ ("after", strJson(pageInfoString(page1, "endCursor")->Option.getOr(""))),
491
+ ]),
492
+ emptyCtx,
493
+ )
494
+ let edges2 = getEdges(page2)
495
+ expect(edges2->Array.length)->toBe(1)
496
+ expect(edgeNodeField(edges2->Array.getUnsafe(0), "productId"))->toBe("p-4")
497
+ expect(pageInfoBool(page2, "hasNextPage"))->toBe(false)
498
+ })
499
+
500
+ // Refused rather than ignored, and refused here because AppSync cannot do it:
501
+ // a door that paged backward on one backend and quietly returned the forward
502
+ // page on the other is the divergence this whole field was rebuilt to remove.
503
+ testPromise("refuses backward paging instead of answering forward", async () => {
504
+ let resolver = await indexResolver(~name="IdxC")
505
+ let outcome = try {
506
+ let _ =
507
+ await resolver(
508
+ JSON.Encode.null,
509
+ argsOf([("status", strJson("active")), ("last", numJson(2))]),
510
+ emptyCtx,
511
+ )
512
+ Ok()
513
+ } catch {
514
+ | e => Error(e->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr(""))
515
+ }
516
+ switch outcome {
517
+ | Ok() => expect("no error")->toBe("a BAD_USER_INPUT refusal")
518
+ | Error(message) =>
519
+ expect(message->String.includes("Backward pagination (last/before) is not supported"))->toBe(true)
520
+ }
521
+ })
522
+ })