@sylphx/lens-server 2.0.1 → 2.2.0

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/dist/index.d.ts CHANGED
@@ -41,7 +41,7 @@ declare function extendContext<
41
41
  E extends ContextValue
42
42
  >(current: T, extension: E): T & E;
43
43
  import { ContextValue as ContextValue3, InferRouterContext as InferRouterContext2, RouterDef as RouterDef2 } from "@sylphx/lens-core";
44
- import { ContextValue as ContextValue2, EntityDef, InferRouterContext, MutationDef, OptimisticDSL, QueryDef, Resolvers, RouterDef } from "@sylphx/lens-core";
44
+ import { ContextValue as ContextValue2, EntityDef, InferRouterContext, MutationDef, Observable, OptimisticDSL, QueryDef, Resolvers, RouterDef } from "@sylphx/lens-core";
45
45
  /**
46
46
  * @sylphx/lens-server - Plugin System Types
47
47
  *
@@ -436,11 +436,27 @@ declare class PluginManager {
436
436
  */
437
437
  declare function createPluginManager(): PluginManager;
438
438
  import { FieldType } from "@sylphx/lens-core";
439
- /** Selection object type for nested field selection */
439
+ /**
440
+ * Nested selection object with optional input.
441
+ * Used for relations that need their own params.
442
+ */
443
+ interface NestedSelection {
444
+ /** Input/params for this nested query */
445
+ input?: Record<string, unknown>;
446
+ /** Field selection for this nested query */
447
+ select?: SelectionObject;
448
+ }
449
+ /**
450
+ * Selection object for field selection.
451
+ * Supports:
452
+ * - `true` - Select this field
453
+ * - `{ select: {...} }` - Nested selection only
454
+ * - `{ input: {...}, select?: {...} }` - Nested with input params
455
+ */
440
456
  interface SelectionObject {
441
457
  [key: string]: boolean | SelectionObject | {
442
458
  select: SelectionObject;
443
- };
459
+ } | NestedSelection;
444
460
  }
445
461
  /** Entity map type */
446
462
  type EntitiesMap = Record<string, EntityDef<string, any>>;
@@ -539,8 +555,19 @@ interface WebSocketLike {
539
555
  interface LensServer {
540
556
  /** Get server metadata for transport handshake */
541
557
  getMetadata(): ServerMetadata;
542
- /** Execute operation - auto-detects query vs mutation */
543
- execute(op: LensOperation): Promise<LensResult>;
558
+ /**
559
+ * Execute operation - auto-detects query vs mutation.
560
+ *
561
+ * Always returns Observable<LensResult>:
562
+ * - One-shot: emits once, then completes
563
+ * - Streaming: emits multiple times (AsyncIterable or emit-based)
564
+ *
565
+ * Usage:
566
+ * - HTTP: `await firstValueFrom(server.execute(op))`
567
+ * - WS/SSE: `server.execute(op).subscribe(...)`
568
+ * - direct: pass through Observable directly
569
+ */
570
+ execute(op: LensOperation): Observable<LensResult>;
544
571
  /**
545
572
  * Register a client connection.
546
573
  * Call when a client connects via WebSocket/SSE.
package/dist/index.js CHANGED
@@ -215,6 +215,20 @@ class DataLoader {
215
215
  }
216
216
 
217
217
  // src/server/selection.ts
218
+ function extractSelect(value) {
219
+ if (value === true)
220
+ return null;
221
+ if (typeof value !== "object" || value === null)
222
+ return null;
223
+ const obj = value;
224
+ if ("input" in obj || "select" in obj && typeof obj.select === "object") {
225
+ return obj.select ?? null;
226
+ }
227
+ if ("select" in obj && typeof obj.select === "object") {
228
+ return obj.select;
229
+ }
230
+ return value;
231
+ }
218
232
  function applySelection(data, select) {
219
233
  if (!data)
220
234
  return data;
@@ -233,8 +247,12 @@ function applySelection(data, select) {
233
247
  if (value === true) {
234
248
  result[key] = obj[key];
235
249
  } else if (typeof value === "object" && value !== null) {
236
- const nestedSelect = "select" in value ? value.select : value;
237
- result[key] = applySelection(obj[key], nestedSelect);
250
+ const nestedSelect = extractSelect(value);
251
+ if (nestedSelect) {
252
+ result[key] = applySelection(obj[key], nestedSelect);
253
+ } else {
254
+ result[key] = obj[key];
255
+ }
238
256
  }
239
257
  }
240
258
  return result;
@@ -319,90 +337,167 @@ class LensServerImpl {
319
337
  operations: this.buildOperationsMap()
320
338
  };
321
339
  }
322
- async execute(op) {
340
+ execute(op) {
323
341
  const { path, input } = op;
324
- try {
325
- if (this.queries[path]) {
326
- const data = await this.executeQuery(path, input);
327
- return { data };
328
- }
329
- if (this.mutations[path]) {
330
- const data = await this.executeMutation(path, input);
331
- return { data };
332
- }
333
- return { error: new Error(`Operation not found: ${path}`) };
334
- } catch (error) {
335
- return { error: error instanceof Error ? error : new Error(String(error)) };
342
+ const isQuery = !!this.queries[path];
343
+ const isMutation = !!this.mutations[path];
344
+ if (!isQuery && !isMutation) {
345
+ return {
346
+ subscribe: (observer) => {
347
+ observer.next?.({ error: new Error(`Operation not found: ${path}`) });
348
+ observer.complete?.();
349
+ return { unsubscribe: () => {} };
350
+ }
351
+ };
336
352
  }
353
+ return this.executeAsObservable(path, input, isQuery);
337
354
  }
338
- async executeQuery(name, input) {
339
- const queryDef = this.queries[name];
340
- if (!queryDef)
341
- throw new Error(`Query not found: ${name}`);
342
- let select;
343
- let cleanInput = input;
344
- if (input && typeof input === "object" && "$select" in input) {
345
- const { $select, ...rest } = input;
346
- select = $select;
347
- cleanInput = Object.keys(rest).length > 0 ? rest : undefined;
348
- }
349
- if (queryDef._input && cleanInput !== undefined) {
350
- const result = queryDef._input.safeParse(cleanInput);
351
- if (!result.success) {
352
- throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
353
- }
354
- }
355
- const context = await this.contextFactory();
356
- try {
357
- return await runWithContext(this.ctx, context, async () => {
358
- const resolver = queryDef._resolve;
359
- if (!resolver)
360
- throw new Error(`Query ${name} has no resolver`);
361
- const emit = createEmit(() => {});
362
- const onCleanup = () => () => {};
363
- const lensContext = { ...context, emit, onCleanup };
364
- const result = resolver({ input: cleanInput, ctx: lensContext });
365
- let data;
366
- if (isAsyncIterable(result)) {
367
- for await (const value of result) {
368
- data = value;
369
- break;
355
+ executeAsObservable(path, input, isQuery) {
356
+ return {
357
+ subscribe: (observer) => {
358
+ let cancelled = false;
359
+ let currentState;
360
+ const cleanups = [];
361
+ (async () => {
362
+ try {
363
+ const def = isQuery ? this.queries[path] : this.mutations[path];
364
+ if (!def) {
365
+ observer.next?.({ error: new Error(`Operation not found: ${path}`) });
366
+ observer.complete?.();
367
+ return;
368
+ }
369
+ let select;
370
+ let cleanInput = input;
371
+ if (isQuery && input && typeof input === "object" && "$select" in input) {
372
+ const { $select, ...rest } = input;
373
+ select = $select;
374
+ cleanInput = Object.keys(rest).length > 0 ? rest : undefined;
375
+ }
376
+ if (def._input && cleanInput !== undefined) {
377
+ const result = def._input.safeParse(cleanInput);
378
+ if (!result.success) {
379
+ observer.next?.({
380
+ error: new Error(`Invalid input: ${JSON.stringify(result.error)}`)
381
+ });
382
+ observer.complete?.();
383
+ return;
384
+ }
385
+ }
386
+ const context = await this.contextFactory();
387
+ await runWithContext(this.ctx, context, async () => {
388
+ const resolver = def._resolve;
389
+ if (!resolver) {
390
+ observer.next?.({ error: new Error(`Operation ${path} has no resolver`) });
391
+ observer.complete?.();
392
+ return;
393
+ }
394
+ const emitHandler = (command) => {
395
+ if (cancelled)
396
+ return;
397
+ currentState = this.applyEmitCommand(command, currentState);
398
+ observer.next?.({ data: currentState });
399
+ };
400
+ const emit = createEmit(emitHandler);
401
+ const onCleanup = (fn) => {
402
+ cleanups.push(fn);
403
+ return () => {
404
+ const idx = cleanups.indexOf(fn);
405
+ if (idx >= 0)
406
+ cleanups.splice(idx, 1);
407
+ };
408
+ };
409
+ const lensContext = { ...context, emit, onCleanup };
410
+ const result = resolver({ input: cleanInput, ctx: lensContext });
411
+ if (isAsyncIterable(result)) {
412
+ for await (const value of result) {
413
+ if (cancelled)
414
+ break;
415
+ currentState = value;
416
+ const processed = await this.processQueryResult(path, value, select);
417
+ observer.next?.({ data: processed });
418
+ }
419
+ if (!cancelled) {
420
+ observer.complete?.();
421
+ }
422
+ } else {
423
+ const value = await result;
424
+ currentState = value;
425
+ const processed = isQuery ? await this.processQueryResult(path, value, select) : value;
426
+ if (!cancelled) {
427
+ observer.next?.({ data: processed });
428
+ }
429
+ }
430
+ });
431
+ } catch (error) {
432
+ if (!cancelled) {
433
+ observer.next?.({ error: error instanceof Error ? error : new Error(String(error)) });
434
+ observer.complete?.();
435
+ }
436
+ } finally {
437
+ this.clearLoaders();
370
438
  }
371
- if (data === undefined) {
372
- throw new Error(`Query ${name} returned empty stream`);
439
+ })();
440
+ return {
441
+ unsubscribe: () => {
442
+ cancelled = true;
443
+ for (const fn of cleanups) {
444
+ fn();
445
+ }
373
446
  }
374
- } else {
375
- data = await result;
376
- }
377
- return this.processQueryResult(name, data, select);
378
- });
379
- } finally {
380
- this.clearLoaders();
381
- }
447
+ };
448
+ }
449
+ };
382
450
  }
383
- async executeMutation(name, input) {
384
- const mutationDef = this.mutations[name];
385
- if (!mutationDef)
386
- throw new Error(`Mutation not found: ${name}`);
387
- if (mutationDef._input) {
388
- const result = mutationDef._input.safeParse(input);
389
- if (!result.success) {
390
- throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
451
+ applyEmitCommand(command, state) {
452
+ switch (command.type) {
453
+ case "full":
454
+ if (command.replace) {
455
+ return command.data;
456
+ }
457
+ if (state && typeof state === "object" && typeof command.data === "object") {
458
+ return { ...state, ...command.data };
459
+ }
460
+ return command.data;
461
+ case "field":
462
+ if (state && typeof state === "object") {
463
+ return {
464
+ ...state,
465
+ [command.field]: command.update.data
466
+ };
467
+ }
468
+ return { [command.field]: command.update.data };
469
+ case "batch":
470
+ if (state && typeof state === "object") {
471
+ const result = { ...state };
472
+ for (const update of command.updates) {
473
+ result[update.field] = update.update.data;
474
+ }
475
+ return result;
476
+ }
477
+ return state;
478
+ case "array": {
479
+ const arr = Array.isArray(state) ? [...state] : [];
480
+ const op = command.operation;
481
+ switch (op.op) {
482
+ case "push":
483
+ return [...arr, op.item];
484
+ case "unshift":
485
+ return [op.item, ...arr];
486
+ case "insert":
487
+ arr.splice(op.index, 0, op.item);
488
+ return arr;
489
+ case "remove":
490
+ arr.splice(op.index, 1);
491
+ return arr;
492
+ case "update":
493
+ arr[op.index] = op.item;
494
+ return arr;
495
+ default:
496
+ return arr;
497
+ }
391
498
  }
392
- }
393
- const context = await this.contextFactory();
394
- try {
395
- return await runWithContext(this.ctx, context, async () => {
396
- const resolver = mutationDef._resolve;
397
- if (!resolver)
398
- throw new Error(`Mutation ${name} has no resolver`);
399
- const emit = createEmit(() => {});
400
- const onCleanup = () => () => {};
401
- const lensContext = { ...context, emit, onCleanup };
402
- return await resolver({ input, ctx: lensContext });
403
- });
404
- } finally {
405
- this.clearLoaders();
499
+ default:
500
+ return state;
406
501
  }
407
502
  }
408
503
  async processQueryResult(_operationName, data, select) {
@@ -698,6 +793,7 @@ function createSSEHandler(config = {}) {
698
793
  }
699
794
 
700
795
  // src/handlers/http.ts
796
+ import { firstValueFrom } from "@sylphx/lens-core";
701
797
  function createHTTPHandler(server, options = {}) {
702
798
  const { pathPrefix = "", cors } = options;
703
799
  const corsHeaders = {
@@ -737,10 +833,10 @@ function createHTTPHandler(server, options = {}) {
737
833
  }
738
834
  });
739
835
  }
740
- const result2 = await server.execute({
836
+ const result2 = await firstValueFrom(server.execute({
741
837
  path: operationPath2,
742
838
  input: body.input
743
- });
839
+ }));
744
840
  if (result2.error) {
745
841
  return new Response(JSON.stringify({ error: result2.error.message }), {
746
842
  status: 500,
@@ -808,6 +904,7 @@ function createHandler(server, options = {}) {
808
904
  return result;
809
905
  }
810
906
  // src/handlers/framework.ts
907
+ import { firstValueFrom as firstValueFrom2 } from "@sylphx/lens-core";
811
908
  function createServerClientProxy(server) {
812
909
  function createProxy(path) {
813
910
  return new Proxy(() => {}, {
@@ -821,7 +918,7 @@ function createServerClientProxy(server) {
821
918
  },
822
919
  async apply(_, __, args) {
823
920
  const input = args[0];
824
- const result = await server.execute({ path, input });
921
+ const result = await firstValueFrom2(server.execute({ path, input }));
825
922
  if (result.error) {
826
923
  throw result.error;
827
924
  }
@@ -835,7 +932,7 @@ async function handleWebQuery(server, path, url) {
835
932
  try {
836
933
  const inputParam = url.searchParams.get("input");
837
934
  const input = inputParam ? JSON.parse(inputParam) : undefined;
838
- const result = await server.execute({ path, input });
935
+ const result = await firstValueFrom2(server.execute({ path, input }));
839
936
  if (result.error) {
840
937
  return Response.json({ error: result.error.message }, { status: 400 });
841
938
  }
@@ -848,7 +945,7 @@ async function handleWebMutation(server, path, request) {
848
945
  try {
849
946
  const body = await request.json();
850
947
  const input = body.input;
851
- const result = await server.execute({ path, input });
948
+ const result = await firstValueFrom2(server.execute({ path, input }));
852
949
  if (result.error) {
853
950
  return Response.json({ error: result.error.message }, { status: 400 });
854
951
  }
@@ -920,6 +1017,9 @@ function createFrameworkHandler(server, options = {}) {
920
1017
  };
921
1018
  }
922
1019
  // src/handlers/ws.ts
1020
+ import {
1021
+ firstValueFrom as firstValueFrom3
1022
+ } from "@sylphx/lens-core";
923
1023
  function createWSHandler(server, options = {}) {
924
1024
  const { logger = {} } = options;
925
1025
  const connections = new Map;
@@ -996,7 +1096,7 @@ function createWSHandler(server, options = {}) {
996
1096
  const { id, operation, input, fields } = message;
997
1097
  let result;
998
1098
  try {
999
- result = await server.execute({ path: operation, input });
1099
+ result = await firstValueFrom3(server.execute({ path: operation, input }));
1000
1100
  if (result.error) {
1001
1101
  conn.ws.send(JSON.stringify({
1002
1102
  type: "error",
@@ -1122,10 +1222,10 @@ function createWSHandler(server, options = {}) {
1122
1222
  }
1123
1223
  async function handleQuery(conn, message) {
1124
1224
  try {
1125
- const result = await server.execute({
1225
+ const result = await firstValueFrom3(server.execute({
1126
1226
  path: message.operation,
1127
1227
  input: message.input
1128
- });
1228
+ }));
1129
1229
  if (result.error) {
1130
1230
  conn.ws.send(JSON.stringify({
1131
1231
  type: "error",
@@ -1150,10 +1250,10 @@ function createWSHandler(server, options = {}) {
1150
1250
  }
1151
1251
  async function handleMutation(conn, message) {
1152
1252
  try {
1153
- const result = await server.execute({
1253
+ const result = await firstValueFrom3(server.execute({
1154
1254
  path: message.operation,
1155
1255
  input: message.input
1156
- });
1256
+ }));
1157
1257
  if (result.error) {
1158
1258
  conn.ws.send(JSON.stringify({
1159
1259
  type: "error",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/lens-server",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "Server runtime for Lens API framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -30,7 +30,7 @@
30
30
  "author": "SylphxAI",
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
- "@sylphx/lens-core": "^2.0.1"
33
+ "@sylphx/lens-core": "^2.1.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "^5.9.3",
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, it } from "bun:test";
11
- import { entity, lens, mutation, query, t } from "@sylphx/lens-core";
11
+ import { entity, firstValueFrom, lens, mutation, query, t } from "@sylphx/lens-core";
12
12
  import { z } from "zod";
13
13
  import { optimisticPlugin } from "../plugin/optimistic.js";
14
14
  import { createApp } from "../server/create.js";
@@ -58,7 +58,7 @@ describe("E2E - Basic Operations", () => {
58
58
  queries: { getUsers },
59
59
  });
60
60
 
61
- const result = await server.execute({ path: "getUsers" });
61
+ const result = await firstValueFrom(server.execute({ path: "getUsers" }));
62
62
 
63
63
  expect(result.error).toBeUndefined();
64
64
  expect(result.data).toEqual(mockUsers);
@@ -79,10 +79,12 @@ describe("E2E - Basic Operations", () => {
79
79
  queries: { getUser },
80
80
  });
81
81
 
82
- const result = await server.execute({
83
- path: "getUser",
84
- input: { id: "user-1" },
85
- });
82
+ const result = await firstValueFrom(
83
+ server.execute({
84
+ path: "getUser",
85
+ input: { id: "user-1" },
86
+ }),
87
+ );
86
88
 
87
89
  expect(result.error).toBeUndefined();
88
90
  expect(result.data).toEqual(mockUsers[0]);
@@ -104,10 +106,12 @@ describe("E2E - Basic Operations", () => {
104
106
  mutations: { createUser },
105
107
  });
106
108
 
107
- const result = await server.execute({
108
- path: "createUser",
109
- input: { name: "Charlie", email: "charlie@example.com" },
110
- });
109
+ const result = await firstValueFrom(
110
+ server.execute({
111
+ path: "createUser",
112
+ input: { name: "Charlie", email: "charlie@example.com" },
113
+ }),
114
+ );
111
115
 
112
116
  expect(result.error).toBeUndefined();
113
117
  expect(result.data).toEqual({
@@ -129,10 +133,12 @@ describe("E2E - Basic Operations", () => {
129
133
  queries: { failingQuery },
130
134
  });
131
135
 
132
- const result = await server.execute({
133
- path: "failingQuery",
134
- input: { id: "123" },
135
- });
136
+ const result = await firstValueFrom(
137
+ server.execute({
138
+ path: "failingQuery",
139
+ input: { id: "123" },
140
+ }),
141
+ );
136
142
 
137
143
  expect(result.data).toBeUndefined();
138
144
  expect(result.error).toBeInstanceOf(Error);
@@ -142,10 +148,12 @@ describe("E2E - Basic Operations", () => {
142
148
  it("handles unknown operation", async () => {
143
149
  const server = createApp({});
144
150
 
145
- const result = await server.execute({
146
- path: "unknownOperation",
147
- input: {},
148
- });
151
+ const result = await firstValueFrom(
152
+ server.execute({
153
+ path: "unknownOperation",
154
+ input: {},
155
+ }),
156
+ );
149
157
 
150
158
  expect(result.data).toBeUndefined();
151
159
  expect(result.error?.message).toContain("not found");
@@ -172,10 +180,12 @@ describe("E2E - Context", () => {
172
180
  context: () => ({ userId: "ctx-user-1", role: "admin" }),
173
181
  });
174
182
 
175
- await server.execute({
176
- path: "getUser",
177
- input: { id: "user-1" },
178
- });
183
+ await firstValueFrom(
184
+ server.execute({
185
+ path: "getUser",
186
+ input: { id: "user-1" },
187
+ }),
188
+ );
179
189
 
180
190
  expect(capturedContext).toMatchObject({
181
191
  userId: "ctx-user-1",
@@ -201,10 +211,12 @@ describe("E2E - Context", () => {
201
211
  },
202
212
  });
203
213
 
204
- await server.execute({
205
- path: "getUser",
206
- input: { id: "user-1" },
207
- });
214
+ await firstValueFrom(
215
+ server.execute({
216
+ path: "getUser",
217
+ input: { id: "user-1" },
218
+ }),
219
+ );
208
220
 
209
221
  expect(capturedContext).toMatchObject({
210
222
  userId: "async-user",
@@ -232,13 +244,15 @@ describe("E2E - Selection", () => {
232
244
  queries: { getUser },
233
245
  });
234
246
 
235
- const result = await server.execute({
236
- path: "getUser",
237
- input: {
238
- id: "user-1",
239
- $select: { name: true },
240
- },
241
- });
247
+ const result = await firstValueFrom(
248
+ server.execute({
249
+ path: "getUser",
250
+ input: {
251
+ id: "user-1",
252
+ $select: { name: true },
253
+ },
254
+ }),
255
+ );
242
256
 
243
257
  expect(result.error).toBeUndefined();
244
258
  // Should include id (always) and selected fields
@@ -262,13 +276,15 @@ describe("E2E - Selection", () => {
262
276
  queries: { getUser },
263
277
  });
264
278
 
265
- const result = await server.execute({
266
- path: "getUser",
267
- input: {
268
- id: "user-1",
269
- $select: { email: true },
270
- },
271
- });
279
+ const result = await firstValueFrom(
280
+ server.execute({
281
+ path: "getUser",
282
+ input: {
283
+ id: "user-1",
284
+ $select: { email: true },
285
+ },
286
+ }),
287
+ );
272
288
 
273
289
  expect(result.data).toEqual({
274
290
  id: "user-1",
@@ -318,20 +334,22 @@ describe("E2E - Entity Resolvers", () => {
318
334
  });
319
335
 
320
336
  // Test with $select for nested posts
321
- const result = await server.execute({
322
- path: "getUser",
323
- input: {
324
- id: "user-1",
325
- $select: {
326
- name: true,
327
- posts: {
328
- select: {
329
- title: true,
337
+ const result = await firstValueFrom(
338
+ server.execute({
339
+ path: "getUser",
340
+ input: {
341
+ id: "user-1",
342
+ $select: {
343
+ name: true,
344
+ posts: {
345
+ select: {
346
+ title: true,
347
+ },
330
348
  },
331
349
  },
332
350
  },
333
- },
334
- });
351
+ }),
352
+ );
335
353
 
336
354
  expect(result.error).toBeUndefined();
337
355
  expect(result.data).toMatchObject({
@@ -380,19 +398,21 @@ describe("E2E - Entity Resolvers", () => {
380
398
  });
381
399
 
382
400
  // Execute query with nested selection for all users
383
- const result = await server.execute({
384
- path: "getUsers",
385
- input: {
386
- $select: {
387
- name: true,
388
- posts: {
389
- select: {
390
- title: true,
401
+ const result = await firstValueFrom(
402
+ server.execute({
403
+ path: "getUsers",
404
+ input: {
405
+ $select: {
406
+ name: true,
407
+ posts: {
408
+ select: {
409
+ title: true,
410
+ },
391
411
  },
392
412
  },
393
413
  },
394
- },
395
- });
414
+ }),
415
+ );
396
416
 
397
417
  expect(result.error).toBeUndefined();
398
418
  // Resolvers are called - exact count depends on DataLoader batching behavior