@sylphx/lens-server 2.4.0 → 2.5.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 +18 -1
- package/dist/index.js +56 -12
- package/package.json +2 -2
- package/src/handlers/framework.ts +4 -17
- package/src/plugin/types.ts +1 -1
- package/src/server/create.test.ts +201 -0
- package/src/server/create.ts +107 -2
- package/src/server/types.ts +23 -0
package/dist/index.d.ts
CHANGED
|
@@ -249,7 +249,7 @@ interface EnhanceOperationMetaContext {
|
|
|
249
249
|
/** Operation path (e.g., 'user.create') */
|
|
250
250
|
path: string;
|
|
251
251
|
/** Operation type */
|
|
252
|
-
type: "query" | "mutation";
|
|
252
|
+
type: "query" | "mutation" | "subscription";
|
|
253
253
|
/** Current metadata (can be modified) */
|
|
254
254
|
meta: Record<string, unknown>;
|
|
255
255
|
/** Operation definition (MutationDef or QueryDef) */
|
|
@@ -612,6 +612,23 @@ interface LensServer {
|
|
|
612
612
|
* Get the plugin manager for direct hook access.
|
|
613
613
|
*/
|
|
614
614
|
getPluginManager(): PluginManager;
|
|
615
|
+
/**
|
|
616
|
+
* Check if any selected field (recursively) is a subscription.
|
|
617
|
+
* Used by handlers to determine if SSE/WS transport is needed.
|
|
618
|
+
*
|
|
619
|
+
* @param entityName - The entity type name
|
|
620
|
+
* @param select - Selection object (if undefined, checks ALL fields)
|
|
621
|
+
* @returns true if any selected field is a subscription
|
|
622
|
+
*/
|
|
623
|
+
hasAnySubscription(entityName: string, select?: SelectionObject): boolean;
|
|
624
|
+
/**
|
|
625
|
+
* Check if an operation requires streaming transport.
|
|
626
|
+
* Returns true if operation uses async generator OR any selected field is subscription.
|
|
627
|
+
*
|
|
628
|
+
* @param path - Operation path
|
|
629
|
+
* @param select - Selection object for return type fields
|
|
630
|
+
*/
|
|
631
|
+
requiresStreamingTransport(path: string, select?: SelectionObject): boolean;
|
|
615
632
|
}
|
|
616
633
|
type InferInput<T> = T extends QueryDef<infer I, any> ? I : T extends MutationDef<infer I, any> ? I : never;
|
|
617
634
|
type InferOutput<T> = T extends QueryDef<any, infer O> ? O : T extends MutationDef<any, infer O> ? O : T extends FieldType<infer F> ? F : never;
|
package/dist/index.js
CHANGED
|
@@ -403,6 +403,54 @@ class LensServerImpl {
|
|
|
403
403
|
}
|
|
404
404
|
}
|
|
405
405
|
}
|
|
406
|
+
hasAnySubscription(entityName, select, visited = new Set) {
|
|
407
|
+
if (visited.has(entityName))
|
|
408
|
+
return false;
|
|
409
|
+
visited.add(entityName);
|
|
410
|
+
const resolver = this.resolverMap?.get(entityName);
|
|
411
|
+
if (!resolver)
|
|
412
|
+
return false;
|
|
413
|
+
const fieldsToCheck = select ? Object.keys(select) : resolver.getFieldNames();
|
|
414
|
+
for (const fieldName of fieldsToCheck) {
|
|
415
|
+
if (!resolver.hasField(fieldName))
|
|
416
|
+
continue;
|
|
417
|
+
if (resolver.isSubscription(fieldName)) {
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
const fieldSelect = select?.[fieldName];
|
|
421
|
+
const nestedSelect = typeof fieldSelect === "object" && fieldSelect !== null && "select" in fieldSelect ? fieldSelect.select : undefined;
|
|
422
|
+
if (nestedSelect || typeof fieldSelect === "object" && fieldSelect !== null) {
|
|
423
|
+
for (const [targetEntityName] of this.resolverMap ?? []) {
|
|
424
|
+
if (targetEntityName === entityName)
|
|
425
|
+
continue;
|
|
426
|
+
if (this.hasAnySubscription(targetEntityName, nestedSelect, visited)) {
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
requiresStreamingTransport(path, select) {
|
|
435
|
+
const def = this.queries[path] ?? this.mutations[path];
|
|
436
|
+
if (!def)
|
|
437
|
+
return false;
|
|
438
|
+
const resolverFn = def._resolve;
|
|
439
|
+
if (resolverFn) {
|
|
440
|
+
const fnName = resolverFn.constructor?.name;
|
|
441
|
+
if (fnName === "AsyncGeneratorFunction" || fnName === "GeneratorFunction") {
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const returnType = def._output;
|
|
446
|
+
if (returnType && typeof returnType === "object" && "_name" in returnType) {
|
|
447
|
+
const entityName = returnType._name;
|
|
448
|
+
if (this.hasAnySubscription(entityName, select)) {
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
406
454
|
getMetadata() {
|
|
407
455
|
return {
|
|
408
456
|
version: this.version,
|
|
@@ -770,10 +818,12 @@ class LensServerImpl {
|
|
|
770
818
|
current[parts[parts.length - 1]] = meta;
|
|
771
819
|
};
|
|
772
820
|
for (const [name, def] of Object.entries(this.queries)) {
|
|
773
|
-
const
|
|
821
|
+
const isSubscription = def._resolve?.constructor?.name === "AsyncGeneratorFunction" || def._resolve?.constructor?.name === "GeneratorFunction";
|
|
822
|
+
const opType = isSubscription ? "subscription" : "query";
|
|
823
|
+
const meta = { type: opType };
|
|
774
824
|
this.pluginManager.runEnhanceOperationMeta({
|
|
775
825
|
path: name,
|
|
776
|
-
type:
|
|
826
|
+
type: opType,
|
|
777
827
|
meta,
|
|
778
828
|
definition: def
|
|
779
829
|
});
|
|
@@ -1179,13 +1229,7 @@ function createHandler(server, options = {}) {
|
|
|
1179
1229
|
return result;
|
|
1180
1230
|
}
|
|
1181
1231
|
// src/handlers/framework.ts
|
|
1182
|
-
import { firstValueFrom as firstValueFrom2
|
|
1183
|
-
async function resolveExecuteResult(result) {
|
|
1184
|
-
if (isObservable(result)) {
|
|
1185
|
-
return firstValueFrom2(result);
|
|
1186
|
-
}
|
|
1187
|
-
return result;
|
|
1188
|
-
}
|
|
1232
|
+
import { firstValueFrom as firstValueFrom2 } from "@sylphx/lens-core";
|
|
1189
1233
|
function createServerClientProxy(server) {
|
|
1190
1234
|
function createProxy(path) {
|
|
1191
1235
|
return new Proxy(() => {}, {
|
|
@@ -1199,7 +1243,7 @@ function createServerClientProxy(server) {
|
|
|
1199
1243
|
},
|
|
1200
1244
|
async apply(_, __, args) {
|
|
1201
1245
|
const input = args[0];
|
|
1202
|
-
const result = await
|
|
1246
|
+
const result = await firstValueFrom2(server.execute({ path, input }));
|
|
1203
1247
|
if (result.error) {
|
|
1204
1248
|
throw result.error;
|
|
1205
1249
|
}
|
|
@@ -1213,7 +1257,7 @@ async function handleWebQuery(server, path, url) {
|
|
|
1213
1257
|
try {
|
|
1214
1258
|
const inputParam = url.searchParams.get("input");
|
|
1215
1259
|
const input = inputParam ? JSON.parse(inputParam) : undefined;
|
|
1216
|
-
const result = await
|
|
1260
|
+
const result = await firstValueFrom2(server.execute({ path, input }));
|
|
1217
1261
|
if (result.error) {
|
|
1218
1262
|
return Response.json({ error: result.error.message }, { status: 400 });
|
|
1219
1263
|
}
|
|
@@ -1226,7 +1270,7 @@ async function handleWebMutation(server, path, request) {
|
|
|
1226
1270
|
try {
|
|
1227
1271
|
const body = await request.json();
|
|
1228
1272
|
const input = body.input;
|
|
1229
|
-
const result = await
|
|
1273
|
+
const result = await firstValueFrom2(server.execute({ path, input }));
|
|
1230
1274
|
if (result.error) {
|
|
1231
1275
|
return Response.json({ error: result.error.message }, { status: 400 });
|
|
1232
1276
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/lens-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.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.
|
|
33
|
+
"@sylphx/lens-core": "^2.3.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "^5.9.3",
|
|
@@ -37,21 +37,8 @@
|
|
|
37
37
|
* ```
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
-
import { firstValueFrom
|
|
40
|
+
import { firstValueFrom } from "@sylphx/lens-core";
|
|
41
41
|
import type { LensServer } from "../server/create.js";
|
|
42
|
-
import type { LensResult } from "../server/types.js";
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Helper to resolve server.execute() result which may be Observable or Promise.
|
|
46
|
-
* This provides backwards compatibility for test mocks that return Promises.
|
|
47
|
-
*/
|
|
48
|
-
async function resolveExecuteResult<T>(result: unknown): Promise<LensResult<T>> {
|
|
49
|
-
if (isObservable<LensResult<T>>(result)) {
|
|
50
|
-
return firstValueFrom(result);
|
|
51
|
-
}
|
|
52
|
-
// Handle Promise or direct value (for backwards compatibility with test mocks)
|
|
53
|
-
return result as Promise<LensResult<T>>;
|
|
54
|
-
}
|
|
55
42
|
|
|
56
43
|
// =============================================================================
|
|
57
44
|
// Server Client Proxy
|
|
@@ -88,7 +75,7 @@ export function createServerClientProxy(server: LensServer): unknown {
|
|
|
88
75
|
},
|
|
89
76
|
async apply(_, __, args) {
|
|
90
77
|
const input = args[0];
|
|
91
|
-
const result = await
|
|
78
|
+
const result = await firstValueFrom(server.execute({ path, input }));
|
|
92
79
|
|
|
93
80
|
if (result.error) {
|
|
94
81
|
throw result.error;
|
|
@@ -126,7 +113,7 @@ export async function handleWebQuery(
|
|
|
126
113
|
const inputParam = url.searchParams.get("input");
|
|
127
114
|
const input = inputParam ? JSON.parse(inputParam) : undefined;
|
|
128
115
|
|
|
129
|
-
const result = await
|
|
116
|
+
const result = await firstValueFrom(server.execute({ path, input }));
|
|
130
117
|
|
|
131
118
|
if (result.error) {
|
|
132
119
|
return Response.json({ error: result.error.message }, { status: 400 });
|
|
@@ -161,7 +148,7 @@ export async function handleWebMutation(
|
|
|
161
148
|
const body = (await request.json()) as { input?: unknown };
|
|
162
149
|
const input = body.input;
|
|
163
150
|
|
|
164
|
-
const result = await
|
|
151
|
+
const result = await firstValueFrom(server.execute({ path, input }));
|
|
165
152
|
|
|
166
153
|
if (result.error) {
|
|
167
154
|
return Response.json({ error: result.error.message }, { status: 400 });
|
package/src/plugin/types.ts
CHANGED
|
@@ -218,7 +218,7 @@ export interface EnhanceOperationMetaContext {
|
|
|
218
218
|
/** Operation path (e.g., 'user.create') */
|
|
219
219
|
path: string;
|
|
220
220
|
/** Operation type */
|
|
221
|
-
type: "query" | "mutation";
|
|
221
|
+
type: "query" | "mutation" | "subscription";
|
|
222
222
|
/** Current metadata (can be modified) */
|
|
223
223
|
meta: Record<string, unknown>;
|
|
224
224
|
/** Operation definition (MutationDef or QueryDef) */
|
|
@@ -1274,3 +1274,204 @@ describe("observable error handling", () => {
|
|
|
1274
1274
|
expect(result.error?.message).toBe("Async error");
|
|
1275
1275
|
});
|
|
1276
1276
|
});
|
|
1277
|
+
|
|
1278
|
+
// =============================================================================
|
|
1279
|
+
// Test: Subscription Detection
|
|
1280
|
+
// =============================================================================
|
|
1281
|
+
|
|
1282
|
+
describe("Subscription detection", () => {
|
|
1283
|
+
// Test entity for subscription detection
|
|
1284
|
+
const Profile = entity("Profile", {
|
|
1285
|
+
id: t.id(),
|
|
1286
|
+
name: t.string(),
|
|
1287
|
+
status: t.string(),
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
describe("hasAnySubscription", () => {
|
|
1291
|
+
it("returns false when no resolvers are configured", () => {
|
|
1292
|
+
const server = createApp({
|
|
1293
|
+
entities: { User },
|
|
1294
|
+
queries: { getUser },
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
expect(server.hasAnySubscription("User")).toBe(false);
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
it("returns false when entity has only .resolve() fields", () => {
|
|
1301
|
+
const userResolver = resolver(User, (f) => ({
|
|
1302
|
+
id: f.expose("id"),
|
|
1303
|
+
name: f.expose("name"),
|
|
1304
|
+
displayName: f.string().resolve(({ parent }) => `User: ${parent.name}`),
|
|
1305
|
+
}));
|
|
1306
|
+
|
|
1307
|
+
const server = createApp({
|
|
1308
|
+
entities: { User },
|
|
1309
|
+
queries: { getUser },
|
|
1310
|
+
resolvers: [userResolver],
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
expect(server.hasAnySubscription("User")).toBe(false);
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
it("returns true when entity has a .subscribe() field", () => {
|
|
1317
|
+
const profileResolver = resolver(Profile, (f) => ({
|
|
1318
|
+
id: f.expose("id"),
|
|
1319
|
+
name: f.expose("name"),
|
|
1320
|
+
// Subscription field
|
|
1321
|
+
status: f.string().subscribe(({ ctx }) => {
|
|
1322
|
+
ctx.emit("online");
|
|
1323
|
+
}),
|
|
1324
|
+
}));
|
|
1325
|
+
|
|
1326
|
+
const server = createApp({
|
|
1327
|
+
entities: { Profile },
|
|
1328
|
+
queries: {
|
|
1329
|
+
getProfile: query()
|
|
1330
|
+
.input(z.object({ id: z.string() }))
|
|
1331
|
+
.returns(Profile)
|
|
1332
|
+
.resolve(({ input }) => ({ id: input.id, name: "Test", status: "online" })),
|
|
1333
|
+
},
|
|
1334
|
+
resolvers: [profileResolver],
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
expect(server.hasAnySubscription("Profile")).toBe(true);
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
it("returns false with select that excludes subscription field", () => {
|
|
1341
|
+
const profileResolver = resolver(Profile, (f) => ({
|
|
1342
|
+
id: f.expose("id"),
|
|
1343
|
+
name: f.expose("name"),
|
|
1344
|
+
status: f.string().subscribe(({ ctx }) => {
|
|
1345
|
+
ctx.emit("online");
|
|
1346
|
+
}),
|
|
1347
|
+
}));
|
|
1348
|
+
|
|
1349
|
+
const server = createApp({
|
|
1350
|
+
entities: { Profile },
|
|
1351
|
+
queries: {
|
|
1352
|
+
getProfile: query()
|
|
1353
|
+
.input(z.object({ id: z.string() }))
|
|
1354
|
+
.returns(Profile)
|
|
1355
|
+
.resolve(({ input }) => ({ id: input.id, name: "Test", status: "online" })),
|
|
1356
|
+
},
|
|
1357
|
+
resolvers: [profileResolver],
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
// Only selecting non-subscription fields
|
|
1361
|
+
expect(server.hasAnySubscription("Profile", { id: true, name: true })).toBe(false);
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it("returns true with select that includes subscription field", () => {
|
|
1365
|
+
const profileResolver = resolver(Profile, (f) => ({
|
|
1366
|
+
id: f.expose("id"),
|
|
1367
|
+
name: f.expose("name"),
|
|
1368
|
+
status: f.string().subscribe(({ ctx }) => {
|
|
1369
|
+
ctx.emit("online");
|
|
1370
|
+
}),
|
|
1371
|
+
}));
|
|
1372
|
+
|
|
1373
|
+
const server = createApp({
|
|
1374
|
+
entities: { Profile },
|
|
1375
|
+
queries: {
|
|
1376
|
+
getProfile: query()
|
|
1377
|
+
.input(z.object({ id: z.string() }))
|
|
1378
|
+
.returns(Profile)
|
|
1379
|
+
.resolve(({ input }) => ({ id: input.id, name: "Test", status: "online" })),
|
|
1380
|
+
},
|
|
1381
|
+
resolvers: [profileResolver],
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
// Selecting the subscription field
|
|
1385
|
+
expect(server.hasAnySubscription("Profile", { id: true, status: true })).toBe(true);
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
it("returns false for unknown entity", () => {
|
|
1389
|
+
const server = createApp({
|
|
1390
|
+
entities: { User },
|
|
1391
|
+
queries: { getUser },
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
expect(server.hasAnySubscription("UnknownEntity")).toBe(false);
|
|
1395
|
+
});
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
describe("requiresStreamingTransport", () => {
|
|
1399
|
+
it("returns false for regular query", () => {
|
|
1400
|
+
const server = createApp({
|
|
1401
|
+
entities: { User },
|
|
1402
|
+
queries: { getUser },
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
expect(server.requiresStreamingTransport("getUser")).toBe(false);
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
it("returns true for async generator query", () => {
|
|
1409
|
+
const streamQuery = query().subscribe(async function* () {
|
|
1410
|
+
yield { count: 1 };
|
|
1411
|
+
yield { count: 2 };
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
const server = createApp({
|
|
1415
|
+
queries: { streamQuery },
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
expect(server.requiresStreamingTransport("streamQuery")).toBe(true);
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it("returns false for unknown operation", () => {
|
|
1422
|
+
const server = createApp({
|
|
1423
|
+
queries: { getUser },
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
expect(server.requiresStreamingTransport("unknownOperation")).toBe(false);
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
it("returns true when return type has subscription fields", () => {
|
|
1430
|
+
const profileResolver = resolver(Profile, (f) => ({
|
|
1431
|
+
id: f.expose("id"),
|
|
1432
|
+
name: f.expose("name"),
|
|
1433
|
+
status: f.string().subscribe(({ ctx }) => {
|
|
1434
|
+
ctx.emit("online");
|
|
1435
|
+
}),
|
|
1436
|
+
}));
|
|
1437
|
+
|
|
1438
|
+
const getProfile = query()
|
|
1439
|
+
.input(z.object({ id: z.string() }))
|
|
1440
|
+
.returns(Profile)
|
|
1441
|
+
.resolve(({ input }) => ({ id: input.id, name: "Test", status: "online" }));
|
|
1442
|
+
|
|
1443
|
+
const server = createApp({
|
|
1444
|
+
entities: { Profile },
|
|
1445
|
+
queries: { getProfile },
|
|
1446
|
+
resolvers: [profileResolver],
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
// No select - checks all fields
|
|
1450
|
+
expect(server.requiresStreamingTransport("getProfile")).toBe(true);
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
it("returns false when select excludes subscription fields", () => {
|
|
1454
|
+
const profileResolver = resolver(Profile, (f) => ({
|
|
1455
|
+
id: f.expose("id"),
|
|
1456
|
+
name: f.expose("name"),
|
|
1457
|
+
status: f.string().subscribe(({ ctx }) => {
|
|
1458
|
+
ctx.emit("online");
|
|
1459
|
+
}),
|
|
1460
|
+
}));
|
|
1461
|
+
|
|
1462
|
+
const getProfile = query()
|
|
1463
|
+
.input(z.object({ id: z.string() }))
|
|
1464
|
+
.returns(Profile)
|
|
1465
|
+
.resolve(({ input }) => ({ id: input.id, name: "Test", status: "online" }));
|
|
1466
|
+
|
|
1467
|
+
const server = createApp({
|
|
1468
|
+
entities: { Profile },
|
|
1469
|
+
queries: { getProfile },
|
|
1470
|
+
resolvers: [profileResolver],
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
// Only selecting non-subscription fields
|
|
1474
|
+
expect(server.requiresStreamingTransport("getProfile", { id: true, name: true })).toBe(false);
|
|
1475
|
+
});
|
|
1476
|
+
});
|
|
1477
|
+
});
|
package/src/server/create.ts
CHANGED
|
@@ -244,6 +244,106 @@ class LensServerImpl<
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
// =========================================================================
|
|
248
|
+
// Subscription Detection
|
|
249
|
+
// =========================================================================
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Check if any selected field (recursively) is a subscription.
|
|
253
|
+
* Used to determine if SSE/WS transport is needed.
|
|
254
|
+
*
|
|
255
|
+
* @param entityName - The entity type name
|
|
256
|
+
* @param select - Selection object (if undefined, checks ALL fields)
|
|
257
|
+
* @param visited - Set of visited entity names (prevents infinite recursion)
|
|
258
|
+
* @returns true if any selected field is a subscription
|
|
259
|
+
*/
|
|
260
|
+
hasAnySubscription(
|
|
261
|
+
entityName: string,
|
|
262
|
+
select?: SelectionObject,
|
|
263
|
+
visited: Set<string> = new Set(),
|
|
264
|
+
): boolean {
|
|
265
|
+
// Prevent infinite recursion on circular references
|
|
266
|
+
if (visited.has(entityName)) return false;
|
|
267
|
+
visited.add(entityName);
|
|
268
|
+
|
|
269
|
+
const resolver = this.resolverMap?.get(entityName);
|
|
270
|
+
if (!resolver) return false;
|
|
271
|
+
|
|
272
|
+
// Determine which fields to check
|
|
273
|
+
const fieldsToCheck = select ? Object.keys(select) : (resolver.getFieldNames() as string[]);
|
|
274
|
+
|
|
275
|
+
for (const fieldName of fieldsToCheck) {
|
|
276
|
+
// Skip if field doesn't exist in resolver
|
|
277
|
+
if (!resolver.hasField(fieldName)) continue;
|
|
278
|
+
|
|
279
|
+
// Check if this field is a subscription
|
|
280
|
+
if (resolver.isSubscription(fieldName)) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Get nested selection for this field
|
|
285
|
+
const fieldSelect = select?.[fieldName];
|
|
286
|
+
const nestedSelect =
|
|
287
|
+
typeof fieldSelect === "object" && fieldSelect !== null && "select" in fieldSelect
|
|
288
|
+
? (fieldSelect as { select?: SelectionObject }).select
|
|
289
|
+
: undefined;
|
|
290
|
+
|
|
291
|
+
// For relation fields, recursively check the target entity
|
|
292
|
+
// We need to determine the target entity from the resolver's field definition
|
|
293
|
+
// For now, we use the selection to guide us - if there's nested selection,
|
|
294
|
+
// we try to find a matching entity resolver
|
|
295
|
+
if (nestedSelect || (typeof fieldSelect === "object" && fieldSelect !== null)) {
|
|
296
|
+
// Try to find target entity by checking all resolvers
|
|
297
|
+
// In a real scenario, we'd have field metadata linking to target entity
|
|
298
|
+
for (const [targetEntityName] of this.resolverMap ?? []) {
|
|
299
|
+
if (targetEntityName === entityName) continue; // Skip self
|
|
300
|
+
if (this.hasAnySubscription(targetEntityName, nestedSelect, visited)) {
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Check if an operation (and its return type's fields) requires streaming transport.
|
|
312
|
+
*
|
|
313
|
+
* Returns true if:
|
|
314
|
+
* 1. Operation resolver is async generator (yields values)
|
|
315
|
+
* 2. Operation resolver uses emit pattern
|
|
316
|
+
* 3. Any selected field in the return type is a subscription
|
|
317
|
+
*
|
|
318
|
+
* @param path - Operation path
|
|
319
|
+
* @param select - Selection object for return type fields
|
|
320
|
+
*/
|
|
321
|
+
requiresStreamingTransport(path: string, select?: SelectionObject): boolean {
|
|
322
|
+
const def = this.queries[path] ?? this.mutations[path];
|
|
323
|
+
if (!def) return false;
|
|
324
|
+
|
|
325
|
+
// Check 1: Operation-level subscription (async generator)
|
|
326
|
+
const resolverFn = def._resolve;
|
|
327
|
+
if (resolverFn) {
|
|
328
|
+
const fnName = resolverFn.constructor?.name;
|
|
329
|
+
if (fnName === "AsyncGeneratorFunction" || fnName === "GeneratorFunction") {
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Check 2 & 3: Field-level subscriptions
|
|
335
|
+
// Get the return entity type from operation metadata
|
|
336
|
+
const returnType = def._output;
|
|
337
|
+
if (returnType && typeof returnType === "object" && "_name" in returnType) {
|
|
338
|
+
const entityName = (returnType as { _name: string })._name;
|
|
339
|
+
if (this.hasAnySubscription(entityName, select)) {
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
|
|
247
347
|
// =========================================================================
|
|
248
348
|
// Core Methods
|
|
249
349
|
// =========================================================================
|
|
@@ -897,10 +997,15 @@ class LensServerImpl<
|
|
|
897
997
|
};
|
|
898
998
|
|
|
899
999
|
for (const [name, def] of Object.entries(this.queries)) {
|
|
900
|
-
|
|
1000
|
+
// Auto-detect subscription: if resolver is AsyncGeneratorFunction → subscription
|
|
1001
|
+
const isSubscription =
|
|
1002
|
+
def._resolve?.constructor?.name === "AsyncGeneratorFunction" ||
|
|
1003
|
+
def._resolve?.constructor?.name === "GeneratorFunction";
|
|
1004
|
+
const opType = isSubscription ? "subscription" : "query";
|
|
1005
|
+
const meta: OperationMeta = { type: opType };
|
|
901
1006
|
this.pluginManager.runEnhanceOperationMeta({
|
|
902
1007
|
path: name,
|
|
903
|
-
type:
|
|
1008
|
+
type: opType,
|
|
904
1009
|
meta: meta as unknown as Record<string, unknown>,
|
|
905
1010
|
definition: def,
|
|
906
1011
|
});
|
package/src/server/types.ts
CHANGED
|
@@ -262,6 +262,29 @@ export interface LensServer {
|
|
|
262
262
|
* Get the plugin manager for direct hook access.
|
|
263
263
|
*/
|
|
264
264
|
getPluginManager(): PluginManager;
|
|
265
|
+
|
|
266
|
+
// =========================================================================
|
|
267
|
+
// Subscription Detection (Transport Selection)
|
|
268
|
+
// =========================================================================
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Check if any selected field (recursively) is a subscription.
|
|
272
|
+
* Used by handlers to determine if SSE/WS transport is needed.
|
|
273
|
+
*
|
|
274
|
+
* @param entityName - The entity type name
|
|
275
|
+
* @param select - Selection object (if undefined, checks ALL fields)
|
|
276
|
+
* @returns true if any selected field is a subscription
|
|
277
|
+
*/
|
|
278
|
+
hasAnySubscription(entityName: string, select?: SelectionObject): boolean;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Check if an operation requires streaming transport.
|
|
282
|
+
* Returns true if operation uses async generator OR any selected field is subscription.
|
|
283
|
+
*
|
|
284
|
+
* @param path - Operation path
|
|
285
|
+
* @param select - Selection object for return type fields
|
|
286
|
+
*/
|
|
287
|
+
requiresStreamingTransport(path: string, select?: SelectionObject): boolean;
|
|
265
288
|
}
|
|
266
289
|
|
|
267
290
|
// =============================================================================
|