@sylphx/lens-server 2.4.1 → 2.6.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 +43 -1
- package/dist/index.js +94 -3
- package/package.json +2 -2
- package/src/e2e/server.test.ts +2 -1
- package/src/plugin/types.ts +1 -1
- package/src/server/create.test.ts +206 -3
- package/src/server/create.ts +171 -2
- package/src/server/types.ts +51 -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) */
|
|
@@ -468,6 +468,11 @@ type MutationsMap = Record<string, MutationDef<unknown, unknown>>;
|
|
|
468
468
|
interface OperationMeta {
|
|
469
469
|
type: "query" | "mutation" | "subscription";
|
|
470
470
|
optimistic?: OptimisticDSL;
|
|
471
|
+
/**
|
|
472
|
+
* Return entity type name (if operation returns an entity).
|
|
473
|
+
* Used by client for field-level subscription detection.
|
|
474
|
+
*/
|
|
475
|
+
returnType?: string;
|
|
471
476
|
}
|
|
472
477
|
/** Nested operations structure for handshake */
|
|
473
478
|
type OperationsMap = {
|
|
@@ -506,10 +511,25 @@ interface LensServerConfig<
|
|
|
506
511
|
*/
|
|
507
512
|
plugins?: ServerPlugin[] | undefined;
|
|
508
513
|
}
|
|
514
|
+
/** Field mode for entity fields */
|
|
515
|
+
type FieldMode = "exposed" | "resolve" | "subscribe";
|
|
516
|
+
/** Entity field metadata for client-side routing decisions */
|
|
517
|
+
interface EntityFieldMetadata {
|
|
518
|
+
[fieldName: string]: FieldMode;
|
|
519
|
+
}
|
|
520
|
+
/** All entities field metadata */
|
|
521
|
+
interface EntitiesMetadata {
|
|
522
|
+
[entityName: string]: EntityFieldMetadata;
|
|
523
|
+
}
|
|
509
524
|
/** Server metadata for transport handshake */
|
|
510
525
|
interface ServerMetadata {
|
|
511
526
|
version: string;
|
|
512
527
|
operations: OperationsMap;
|
|
528
|
+
/**
|
|
529
|
+
* Entity field metadata for client-side transport routing.
|
|
530
|
+
* Client uses this to determine if any selected field requires streaming transport.
|
|
531
|
+
*/
|
|
532
|
+
entities: EntitiesMetadata;
|
|
513
533
|
}
|
|
514
534
|
/** Operation for execution */
|
|
515
535
|
interface LensOperation {
|
|
@@ -612,6 +632,28 @@ interface LensServer {
|
|
|
612
632
|
* Get the plugin manager for direct hook access.
|
|
613
633
|
*/
|
|
614
634
|
getPluginManager(): PluginManager;
|
|
635
|
+
/**
|
|
636
|
+
* Check if any selected field (recursively) is a subscription.
|
|
637
|
+
*
|
|
638
|
+
* @deprecated Use client-side subscription detection with `getMetadata().entities` instead.
|
|
639
|
+
* The client should use the entities metadata to determine transport routing.
|
|
640
|
+
*
|
|
641
|
+
* @param entityName - The entity type name
|
|
642
|
+
* @param select - Selection object (if undefined, checks ALL fields)
|
|
643
|
+
* @returns true if any selected field is a subscription
|
|
644
|
+
*/
|
|
645
|
+
hasAnySubscription(entityName: string, select?: SelectionObject): boolean;
|
|
646
|
+
/**
|
|
647
|
+
* Check if an operation requires streaming transport.
|
|
648
|
+
*
|
|
649
|
+
* @deprecated Use client-side transport detection with `getMetadata().entities` instead.
|
|
650
|
+
* The client should determine transport based on operation type from metadata
|
|
651
|
+
* and entity field modes.
|
|
652
|
+
*
|
|
653
|
+
* @param path - Operation path
|
|
654
|
+
* @param select - Selection object for return type fields
|
|
655
|
+
*/
|
|
656
|
+
requiresStreamingTransport(path: string, select?: SelectionObject): boolean;
|
|
615
657
|
}
|
|
616
658
|
type InferInput<T> = T extends QueryDef<infer I, any> ? I : T extends MutationDef<infer I, any> ? I : never;
|
|
617
659
|
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,12 +403,79 @@ 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,
|
|
409
|
-
operations: this.buildOperationsMap()
|
|
457
|
+
operations: this.buildOperationsMap(),
|
|
458
|
+
entities: this.buildEntitiesMetadata()
|
|
410
459
|
};
|
|
411
460
|
}
|
|
461
|
+
buildEntitiesMetadata() {
|
|
462
|
+
const result = {};
|
|
463
|
+
if (!this.resolverMap)
|
|
464
|
+
return result;
|
|
465
|
+
for (const [entityName, resolver] of this.resolverMap) {
|
|
466
|
+
const fieldMetadata = {};
|
|
467
|
+
for (const fieldName of resolver.getFieldNames()) {
|
|
468
|
+
const mode = resolver.getFieldMode(String(fieldName));
|
|
469
|
+
if (mode) {
|
|
470
|
+
fieldMetadata[String(fieldName)] = mode;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (Object.keys(fieldMetadata).length > 0) {
|
|
474
|
+
result[entityName] = fieldMetadata;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return result;
|
|
478
|
+
}
|
|
412
479
|
execute(op) {
|
|
413
480
|
const { path, input } = op;
|
|
414
481
|
const isQuery = !!this.queries[path];
|
|
@@ -769,18 +836,42 @@ class LensServerImpl {
|
|
|
769
836
|
}
|
|
770
837
|
current[parts[parts.length - 1]] = meta;
|
|
771
838
|
};
|
|
839
|
+
const getReturnTypeName = (output) => {
|
|
840
|
+
if (!output)
|
|
841
|
+
return;
|
|
842
|
+
if (Array.isArray(output) && output.length > 0) {
|
|
843
|
+
const element = output[0];
|
|
844
|
+
if (element && typeof element === "object" && "_name" in element) {
|
|
845
|
+
return element._name;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (typeof output === "object" && "_name" in output) {
|
|
849
|
+
return output._name;
|
|
850
|
+
}
|
|
851
|
+
return;
|
|
852
|
+
};
|
|
772
853
|
for (const [name, def] of Object.entries(this.queries)) {
|
|
773
|
-
const
|
|
854
|
+
const isSubscription = def._resolve?.constructor?.name === "AsyncGeneratorFunction" || def._resolve?.constructor?.name === "GeneratorFunction";
|
|
855
|
+
const opType = isSubscription ? "subscription" : "query";
|
|
856
|
+
const returnType = getReturnTypeName(def._output);
|
|
857
|
+
const meta = { type: opType };
|
|
858
|
+
if (returnType) {
|
|
859
|
+
meta.returnType = returnType;
|
|
860
|
+
}
|
|
774
861
|
this.pluginManager.runEnhanceOperationMeta({
|
|
775
862
|
path: name,
|
|
776
|
-
type:
|
|
863
|
+
type: opType,
|
|
777
864
|
meta,
|
|
778
865
|
definition: def
|
|
779
866
|
});
|
|
780
867
|
setNested(name, meta);
|
|
781
868
|
}
|
|
782
869
|
for (const [name, def] of Object.entries(this.mutations)) {
|
|
870
|
+
const returnType = getReturnTypeName(def._output);
|
|
783
871
|
const meta = { type: "mutation" };
|
|
872
|
+
if (returnType) {
|
|
873
|
+
meta.returnType = returnType;
|
|
874
|
+
}
|
|
784
875
|
this.pluginManager.runEnhanceOperationMeta({
|
|
785
876
|
path: name,
|
|
786
877
|
type: "mutation",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/lens-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.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",
|
package/src/e2e/server.test.ts
CHANGED
|
@@ -448,7 +448,8 @@ describe("E2E - Metadata", () => {
|
|
|
448
448
|
const metadata = server.getMetadata();
|
|
449
449
|
|
|
450
450
|
expect(metadata.version).toBe("2.0.0");
|
|
451
|
-
expect(metadata.operations.getUser).
|
|
451
|
+
expect(metadata.operations.getUser.type).toBe("query");
|
|
452
|
+
expect(metadata.operations.getUser.returnType).toBe("User"); // Now includes returnType
|
|
452
453
|
expect(metadata.operations.createUser.type).toBe("mutation");
|
|
453
454
|
// createUser should have auto-derived optimistic hint (with plugin)
|
|
454
455
|
expect(metadata.operations.createUser.optimistic).toBeDefined();
|
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) */
|
|
@@ -127,8 +127,9 @@ describe("getMetadata", () => {
|
|
|
127
127
|
const metadata = server.getMetadata();
|
|
128
128
|
|
|
129
129
|
expect(metadata.version).toBe("1.2.3");
|
|
130
|
-
expect(metadata.operations.getUser).
|
|
131
|
-
expect(metadata.operations.
|
|
130
|
+
expect(metadata.operations.getUser.type).toBe("query");
|
|
131
|
+
expect(metadata.operations.getUser.returnType).toBe("User"); // Now includes returnType
|
|
132
|
+
expect(metadata.operations.getUsers.type).toBe("query");
|
|
132
133
|
expect(metadata.operations.createUser.type).toBe("mutation");
|
|
133
134
|
expect(metadata.operations.updateUser.type).toBe("mutation");
|
|
134
135
|
});
|
|
@@ -175,7 +176,8 @@ describe("getMetadata", () => {
|
|
|
175
176
|
const metadata = server.getMetadata();
|
|
176
177
|
|
|
177
178
|
expect(metadata.operations.user).toBeDefined();
|
|
178
|
-
expect((metadata.operations.user as Record<string, unknown
|
|
179
|
+
expect((metadata.operations.user as Record<string, Record<string, unknown>>).get.type).toBe("query");
|
|
180
|
+
expect((metadata.operations.user as Record<string, Record<string, unknown>>).get.returnType).toBe("User");
|
|
179
181
|
expect((metadata.operations.user as Record<string, unknown>).create).toBeDefined();
|
|
180
182
|
});
|
|
181
183
|
});
|
|
@@ -1274,3 +1276,204 @@ describe("observable error handling", () => {
|
|
|
1274
1276
|
expect(result.error?.message).toBe("Async error");
|
|
1275
1277
|
});
|
|
1276
1278
|
});
|
|
1279
|
+
|
|
1280
|
+
// =============================================================================
|
|
1281
|
+
// Test: Subscription Detection
|
|
1282
|
+
// =============================================================================
|
|
1283
|
+
|
|
1284
|
+
describe("Subscription detection", () => {
|
|
1285
|
+
// Test entity for subscription detection
|
|
1286
|
+
const Profile = entity("Profile", {
|
|
1287
|
+
id: t.id(),
|
|
1288
|
+
name: t.string(),
|
|
1289
|
+
status: t.string(),
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
describe("hasAnySubscription", () => {
|
|
1293
|
+
it("returns false when no resolvers are configured", () => {
|
|
1294
|
+
const server = createApp({
|
|
1295
|
+
entities: { User },
|
|
1296
|
+
queries: { getUser },
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
expect(server.hasAnySubscription("User")).toBe(false);
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
it("returns false when entity has only .resolve() fields", () => {
|
|
1303
|
+
const userResolver = resolver(User, (f) => ({
|
|
1304
|
+
id: f.expose("id"),
|
|
1305
|
+
name: f.expose("name"),
|
|
1306
|
+
displayName: f.string().resolve(({ parent }) => `User: ${parent.name}`),
|
|
1307
|
+
}));
|
|
1308
|
+
|
|
1309
|
+
const server = createApp({
|
|
1310
|
+
entities: { User },
|
|
1311
|
+
queries: { getUser },
|
|
1312
|
+
resolvers: [userResolver],
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
expect(server.hasAnySubscription("User")).toBe(false);
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
it("returns true when entity has a .subscribe() field", () => {
|
|
1319
|
+
const profileResolver = resolver(Profile, (f) => ({
|
|
1320
|
+
id: f.expose("id"),
|
|
1321
|
+
name: f.expose("name"),
|
|
1322
|
+
// Subscription field
|
|
1323
|
+
status: f.string().subscribe(({ ctx }) => {
|
|
1324
|
+
ctx.emit("online");
|
|
1325
|
+
}),
|
|
1326
|
+
}));
|
|
1327
|
+
|
|
1328
|
+
const server = createApp({
|
|
1329
|
+
entities: { Profile },
|
|
1330
|
+
queries: {
|
|
1331
|
+
getProfile: query()
|
|
1332
|
+
.input(z.object({ id: z.string() }))
|
|
1333
|
+
.returns(Profile)
|
|
1334
|
+
.resolve(({ input }) => ({ id: input.id, name: "Test", status: "online" })),
|
|
1335
|
+
},
|
|
1336
|
+
resolvers: [profileResolver],
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
expect(server.hasAnySubscription("Profile")).toBe(true);
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
it("returns false with select that excludes subscription field", () => {
|
|
1343
|
+
const profileResolver = resolver(Profile, (f) => ({
|
|
1344
|
+
id: f.expose("id"),
|
|
1345
|
+
name: f.expose("name"),
|
|
1346
|
+
status: f.string().subscribe(({ ctx }) => {
|
|
1347
|
+
ctx.emit("online");
|
|
1348
|
+
}),
|
|
1349
|
+
}));
|
|
1350
|
+
|
|
1351
|
+
const server = createApp({
|
|
1352
|
+
entities: { Profile },
|
|
1353
|
+
queries: {
|
|
1354
|
+
getProfile: query()
|
|
1355
|
+
.input(z.object({ id: z.string() }))
|
|
1356
|
+
.returns(Profile)
|
|
1357
|
+
.resolve(({ input }) => ({ id: input.id, name: "Test", status: "online" })),
|
|
1358
|
+
},
|
|
1359
|
+
resolvers: [profileResolver],
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
// Only selecting non-subscription fields
|
|
1363
|
+
expect(server.hasAnySubscription("Profile", { id: true, name: true })).toBe(false);
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
it("returns true with select that includes subscription field", () => {
|
|
1367
|
+
const profileResolver = resolver(Profile, (f) => ({
|
|
1368
|
+
id: f.expose("id"),
|
|
1369
|
+
name: f.expose("name"),
|
|
1370
|
+
status: f.string().subscribe(({ ctx }) => {
|
|
1371
|
+
ctx.emit("online");
|
|
1372
|
+
}),
|
|
1373
|
+
}));
|
|
1374
|
+
|
|
1375
|
+
const server = createApp({
|
|
1376
|
+
entities: { Profile },
|
|
1377
|
+
queries: {
|
|
1378
|
+
getProfile: query()
|
|
1379
|
+
.input(z.object({ id: z.string() }))
|
|
1380
|
+
.returns(Profile)
|
|
1381
|
+
.resolve(({ input }) => ({ id: input.id, name: "Test", status: "online" })),
|
|
1382
|
+
},
|
|
1383
|
+
resolvers: [profileResolver],
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
// Selecting the subscription field
|
|
1387
|
+
expect(server.hasAnySubscription("Profile", { id: true, status: true })).toBe(true);
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
it("returns false for unknown entity", () => {
|
|
1391
|
+
const server = createApp({
|
|
1392
|
+
entities: { User },
|
|
1393
|
+
queries: { getUser },
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
expect(server.hasAnySubscription("UnknownEntity")).toBe(false);
|
|
1397
|
+
});
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
describe("requiresStreamingTransport", () => {
|
|
1401
|
+
it("returns false for regular query", () => {
|
|
1402
|
+
const server = createApp({
|
|
1403
|
+
entities: { User },
|
|
1404
|
+
queries: { getUser },
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
expect(server.requiresStreamingTransport("getUser")).toBe(false);
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
it("returns true for async generator query", () => {
|
|
1411
|
+
const streamQuery = query().subscribe(async function* () {
|
|
1412
|
+
yield { count: 1 };
|
|
1413
|
+
yield { count: 2 };
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
const server = createApp({
|
|
1417
|
+
queries: { streamQuery },
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
expect(server.requiresStreamingTransport("streamQuery")).toBe(true);
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
it("returns false for unknown operation", () => {
|
|
1424
|
+
const server = createApp({
|
|
1425
|
+
queries: { getUser },
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
expect(server.requiresStreamingTransport("unknownOperation")).toBe(false);
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
it("returns true when return type has subscription fields", () => {
|
|
1432
|
+
const profileResolver = resolver(Profile, (f) => ({
|
|
1433
|
+
id: f.expose("id"),
|
|
1434
|
+
name: f.expose("name"),
|
|
1435
|
+
status: f.string().subscribe(({ ctx }) => {
|
|
1436
|
+
ctx.emit("online");
|
|
1437
|
+
}),
|
|
1438
|
+
}));
|
|
1439
|
+
|
|
1440
|
+
const getProfile = query()
|
|
1441
|
+
.input(z.object({ id: z.string() }))
|
|
1442
|
+
.returns(Profile)
|
|
1443
|
+
.resolve(({ input }) => ({ id: input.id, name: "Test", status: "online" }));
|
|
1444
|
+
|
|
1445
|
+
const server = createApp({
|
|
1446
|
+
entities: { Profile },
|
|
1447
|
+
queries: { getProfile },
|
|
1448
|
+
resolvers: [profileResolver],
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
// No select - checks all fields
|
|
1452
|
+
expect(server.requiresStreamingTransport("getProfile")).toBe(true);
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
it("returns false when select excludes subscription fields", () => {
|
|
1456
|
+
const profileResolver = resolver(Profile, (f) => ({
|
|
1457
|
+
id: f.expose("id"),
|
|
1458
|
+
name: f.expose("name"),
|
|
1459
|
+
status: f.string().subscribe(({ ctx }) => {
|
|
1460
|
+
ctx.emit("online");
|
|
1461
|
+
}),
|
|
1462
|
+
}));
|
|
1463
|
+
|
|
1464
|
+
const getProfile = query()
|
|
1465
|
+
.input(z.object({ id: z.string() }))
|
|
1466
|
+
.returns(Profile)
|
|
1467
|
+
.resolve(({ input }) => ({ id: input.id, name: "Test", status: "online" }));
|
|
1468
|
+
|
|
1469
|
+
const server = createApp({
|
|
1470
|
+
entities: { Profile },
|
|
1471
|
+
queries: { getProfile },
|
|
1472
|
+
resolvers: [profileResolver],
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
// Only selecting non-subscription fields
|
|
1476
|
+
expect(server.requiresStreamingTransport("getProfile", { id: true, name: true })).toBe(false);
|
|
1477
|
+
});
|
|
1478
|
+
});
|
|
1479
|
+
});
|
package/src/server/create.ts
CHANGED
|
@@ -47,6 +47,8 @@ import { applySelection, extractNestedInputs } from "./selection.js";
|
|
|
47
47
|
import type {
|
|
48
48
|
ClientSendFn,
|
|
49
49
|
EntitiesMap,
|
|
50
|
+
EntitiesMetadata,
|
|
51
|
+
EntityFieldMetadata,
|
|
50
52
|
LensLogger,
|
|
51
53
|
LensOperation,
|
|
52
54
|
LensResult,
|
|
@@ -66,6 +68,8 @@ import type {
|
|
|
66
68
|
export type {
|
|
67
69
|
ClientSendFn,
|
|
68
70
|
EntitiesMap,
|
|
71
|
+
EntitiesMetadata,
|
|
72
|
+
EntityFieldMetadata,
|
|
69
73
|
InferApi,
|
|
70
74
|
InferInput,
|
|
71
75
|
InferOutput,
|
|
@@ -244,6 +248,113 @@ class LensServerImpl<
|
|
|
244
248
|
}
|
|
245
249
|
}
|
|
246
250
|
|
|
251
|
+
// =========================================================================
|
|
252
|
+
// Subscription Detection (Deprecated - Use client-side with entities metadata)
|
|
253
|
+
// =========================================================================
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Check if any selected field (recursively) is a subscription.
|
|
257
|
+
*
|
|
258
|
+
* @deprecated Use client-side subscription detection with `getMetadata().entities` instead.
|
|
259
|
+
* The client should use the entities metadata to determine transport routing.
|
|
260
|
+
* This method remains for backwards compatibility but will be removed in a future version.
|
|
261
|
+
*
|
|
262
|
+
* @param entityName - The entity type name
|
|
263
|
+
* @param select - Selection object (if undefined, checks ALL fields)
|
|
264
|
+
* @param visited - Set of visited entity names (prevents infinite recursion)
|
|
265
|
+
* @returns true if any selected field is a subscription
|
|
266
|
+
*/
|
|
267
|
+
hasAnySubscription(
|
|
268
|
+
entityName: string,
|
|
269
|
+
select?: SelectionObject,
|
|
270
|
+
visited: Set<string> = new Set(),
|
|
271
|
+
): boolean {
|
|
272
|
+
// Prevent infinite recursion on circular references
|
|
273
|
+
if (visited.has(entityName)) return false;
|
|
274
|
+
visited.add(entityName);
|
|
275
|
+
|
|
276
|
+
const resolver = this.resolverMap?.get(entityName);
|
|
277
|
+
if (!resolver) return false;
|
|
278
|
+
|
|
279
|
+
// Determine which fields to check
|
|
280
|
+
const fieldsToCheck = select ? Object.keys(select) : (resolver.getFieldNames() as string[]);
|
|
281
|
+
|
|
282
|
+
for (const fieldName of fieldsToCheck) {
|
|
283
|
+
// Skip if field doesn't exist in resolver
|
|
284
|
+
if (!resolver.hasField(fieldName)) continue;
|
|
285
|
+
|
|
286
|
+
// Check if this field is a subscription
|
|
287
|
+
if (resolver.isSubscription(fieldName)) {
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Get nested selection for this field
|
|
292
|
+
const fieldSelect = select?.[fieldName];
|
|
293
|
+
const nestedSelect =
|
|
294
|
+
typeof fieldSelect === "object" && fieldSelect !== null && "select" in fieldSelect
|
|
295
|
+
? (fieldSelect as { select?: SelectionObject }).select
|
|
296
|
+
: undefined;
|
|
297
|
+
|
|
298
|
+
// For relation fields, recursively check the target entity
|
|
299
|
+
// We need to determine the target entity from the resolver's field definition
|
|
300
|
+
// For now, we use the selection to guide us - if there's nested selection,
|
|
301
|
+
// we try to find a matching entity resolver
|
|
302
|
+
if (nestedSelect || (typeof fieldSelect === "object" && fieldSelect !== null)) {
|
|
303
|
+
// Try to find target entity by checking all resolvers
|
|
304
|
+
// In a real scenario, we'd have field metadata linking to target entity
|
|
305
|
+
for (const [targetEntityName] of this.resolverMap ?? []) {
|
|
306
|
+
if (targetEntityName === entityName) continue; // Skip self
|
|
307
|
+
if (this.hasAnySubscription(targetEntityName, nestedSelect, visited)) {
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Check if an operation (and its return type's fields) requires streaming transport.
|
|
319
|
+
*
|
|
320
|
+
* @deprecated Use client-side transport detection with `getMetadata().entities` instead.
|
|
321
|
+
* The client should determine transport based on operation type from metadata
|
|
322
|
+
* and entity field modes. This method remains for backwards compatibility.
|
|
323
|
+
*
|
|
324
|
+
* Returns true if:
|
|
325
|
+
* 1. Operation resolver is async generator (yields values)
|
|
326
|
+
* 2. Operation resolver uses emit pattern
|
|
327
|
+
* 3. Any selected field in the return type is a subscription
|
|
328
|
+
*
|
|
329
|
+
* @param path - Operation path
|
|
330
|
+
* @param select - Selection object for return type fields
|
|
331
|
+
*/
|
|
332
|
+
requiresStreamingTransport(path: string, select?: SelectionObject): boolean {
|
|
333
|
+
const def = this.queries[path] ?? this.mutations[path];
|
|
334
|
+
if (!def) return false;
|
|
335
|
+
|
|
336
|
+
// Check 1: Operation-level subscription (async generator)
|
|
337
|
+
const resolverFn = def._resolve;
|
|
338
|
+
if (resolverFn) {
|
|
339
|
+
const fnName = resolverFn.constructor?.name;
|
|
340
|
+
if (fnName === "AsyncGeneratorFunction" || fnName === "GeneratorFunction") {
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Check 2 & 3: Field-level subscriptions
|
|
346
|
+
// Get the return entity type from operation metadata
|
|
347
|
+
const returnType = def._output;
|
|
348
|
+
if (returnType && typeof returnType === "object" && "_name" in returnType) {
|
|
349
|
+
const entityName = (returnType as { _name: string })._name;
|
|
350
|
+
if (this.hasAnySubscription(entityName, select)) {
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
|
|
247
358
|
// =========================================================================
|
|
248
359
|
// Core Methods
|
|
249
360
|
// =========================================================================
|
|
@@ -252,9 +363,37 @@ class LensServerImpl<
|
|
|
252
363
|
return {
|
|
253
364
|
version: this.version,
|
|
254
365
|
operations: this.buildOperationsMap(),
|
|
366
|
+
entities: this.buildEntitiesMetadata(),
|
|
255
367
|
};
|
|
256
368
|
}
|
|
257
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Build entities metadata for client-side transport routing.
|
|
372
|
+
* Maps each entity to its field modes (exposed/resolve/subscribe).
|
|
373
|
+
*/
|
|
374
|
+
private buildEntitiesMetadata(): EntitiesMetadata {
|
|
375
|
+
const result: EntitiesMetadata = {};
|
|
376
|
+
|
|
377
|
+
if (!this.resolverMap) return result;
|
|
378
|
+
|
|
379
|
+
for (const [entityName, resolver] of this.resolverMap) {
|
|
380
|
+
const fieldMetadata: EntityFieldMetadata = {};
|
|
381
|
+
|
|
382
|
+
for (const fieldName of resolver.getFieldNames()) {
|
|
383
|
+
const mode = resolver.getFieldMode(String(fieldName));
|
|
384
|
+
if (mode) {
|
|
385
|
+
fieldMetadata[String(fieldName)] = mode;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (Object.keys(fieldMetadata).length > 0) {
|
|
390
|
+
result[entityName] = fieldMetadata;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return result;
|
|
395
|
+
}
|
|
396
|
+
|
|
258
397
|
/**
|
|
259
398
|
* Execute operation and return Observable.
|
|
260
399
|
*
|
|
@@ -896,11 +1035,37 @@ class LensServerImpl<
|
|
|
896
1035
|
current[parts[parts.length - 1]] = meta;
|
|
897
1036
|
};
|
|
898
1037
|
|
|
1038
|
+
// Helper to extract return type name from output definition
|
|
1039
|
+
const getReturnTypeName = (output: unknown): string | undefined => {
|
|
1040
|
+
if (!output) return undefined;
|
|
1041
|
+
// Handle array output: [EntityDef] → extract from first element
|
|
1042
|
+
if (Array.isArray(output) && output.length > 0) {
|
|
1043
|
+
const element = output[0];
|
|
1044
|
+
if (element && typeof element === "object" && "_name" in element) {
|
|
1045
|
+
return element._name as string;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
// Handle direct entity output
|
|
1049
|
+
if (typeof output === "object" && "_name" in output) {
|
|
1050
|
+
return (output as { _name?: string })._name;
|
|
1051
|
+
}
|
|
1052
|
+
return undefined;
|
|
1053
|
+
};
|
|
1054
|
+
|
|
899
1055
|
for (const [name, def] of Object.entries(this.queries)) {
|
|
900
|
-
|
|
1056
|
+
// Auto-detect subscription: if resolver is AsyncGeneratorFunction → subscription
|
|
1057
|
+
const isSubscription =
|
|
1058
|
+
def._resolve?.constructor?.name === "AsyncGeneratorFunction" ||
|
|
1059
|
+
def._resolve?.constructor?.name === "GeneratorFunction";
|
|
1060
|
+
const opType = isSubscription ? "subscription" : "query";
|
|
1061
|
+
const returnType = getReturnTypeName(def._output);
|
|
1062
|
+
const meta: OperationMeta = { type: opType };
|
|
1063
|
+
if (returnType) {
|
|
1064
|
+
meta.returnType = returnType;
|
|
1065
|
+
}
|
|
901
1066
|
this.pluginManager.runEnhanceOperationMeta({
|
|
902
1067
|
path: name,
|
|
903
|
-
type:
|
|
1068
|
+
type: opType,
|
|
904
1069
|
meta: meta as unknown as Record<string, unknown>,
|
|
905
1070
|
definition: def,
|
|
906
1071
|
});
|
|
@@ -908,7 +1073,11 @@ class LensServerImpl<
|
|
|
908
1073
|
}
|
|
909
1074
|
|
|
910
1075
|
for (const [name, def] of Object.entries(this.mutations)) {
|
|
1076
|
+
const returnType = getReturnTypeName(def._output);
|
|
911
1077
|
const meta: OperationMeta = { type: "mutation" };
|
|
1078
|
+
if (returnType) {
|
|
1079
|
+
meta.returnType = returnType;
|
|
1080
|
+
}
|
|
912
1081
|
this.pluginManager.runEnhanceOperationMeta({
|
|
913
1082
|
path: name,
|
|
914
1083
|
type: "mutation",
|
package/src/server/types.ts
CHANGED
|
@@ -64,6 +64,11 @@ export type MutationsMap = Record<string, MutationDef<unknown, unknown>>;
|
|
|
64
64
|
export interface OperationMeta {
|
|
65
65
|
type: "query" | "mutation" | "subscription";
|
|
66
66
|
optimistic?: OptimisticDSL;
|
|
67
|
+
/**
|
|
68
|
+
* Return entity type name (if operation returns an entity).
|
|
69
|
+
* Used by client for field-level subscription detection.
|
|
70
|
+
*/
|
|
71
|
+
returnType?: string;
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
/** Nested operations structure for handshake */
|
|
@@ -118,10 +123,28 @@ export interface LensServerConfig<
|
|
|
118
123
|
// Server Metadata
|
|
119
124
|
// =============================================================================
|
|
120
125
|
|
|
126
|
+
/** Field mode for entity fields */
|
|
127
|
+
export type FieldMode = "exposed" | "resolve" | "subscribe";
|
|
128
|
+
|
|
129
|
+
/** Entity field metadata for client-side routing decisions */
|
|
130
|
+
export interface EntityFieldMetadata {
|
|
131
|
+
[fieldName: string]: FieldMode;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** All entities field metadata */
|
|
135
|
+
export interface EntitiesMetadata {
|
|
136
|
+
[entityName: string]: EntityFieldMetadata;
|
|
137
|
+
}
|
|
138
|
+
|
|
121
139
|
/** Server metadata for transport handshake */
|
|
122
140
|
export interface ServerMetadata {
|
|
123
141
|
version: string;
|
|
124
142
|
operations: OperationsMap;
|
|
143
|
+
/**
|
|
144
|
+
* Entity field metadata for client-side transport routing.
|
|
145
|
+
* Client uses this to determine if any selected field requires streaming transport.
|
|
146
|
+
*/
|
|
147
|
+
entities: EntitiesMetadata;
|
|
125
148
|
}
|
|
126
149
|
|
|
127
150
|
// =============================================================================
|
|
@@ -262,6 +285,34 @@ export interface LensServer {
|
|
|
262
285
|
* Get the plugin manager for direct hook access.
|
|
263
286
|
*/
|
|
264
287
|
getPluginManager(): PluginManager;
|
|
288
|
+
|
|
289
|
+
// =========================================================================
|
|
290
|
+
// Subscription Detection (Deprecated - Use client-side with entities metadata)
|
|
291
|
+
// =========================================================================
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Check if any selected field (recursively) is a subscription.
|
|
295
|
+
*
|
|
296
|
+
* @deprecated Use client-side subscription detection with `getMetadata().entities` instead.
|
|
297
|
+
* The client should use the entities metadata to determine transport routing.
|
|
298
|
+
*
|
|
299
|
+
* @param entityName - The entity type name
|
|
300
|
+
* @param select - Selection object (if undefined, checks ALL fields)
|
|
301
|
+
* @returns true if any selected field is a subscription
|
|
302
|
+
*/
|
|
303
|
+
hasAnySubscription(entityName: string, select?: SelectionObject): boolean;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Check if an operation requires streaming transport.
|
|
307
|
+
*
|
|
308
|
+
* @deprecated Use client-side transport detection with `getMetadata().entities` instead.
|
|
309
|
+
* The client should determine transport based on operation type from metadata
|
|
310
|
+
* and entity field modes.
|
|
311
|
+
*
|
|
312
|
+
* @param path - Operation path
|
|
313
|
+
* @param select - Selection object for return type fields
|
|
314
|
+
*/
|
|
315
|
+
requiresStreamingTransport(path: string, select?: SelectionObject): boolean;
|
|
265
316
|
}
|
|
266
317
|
|
|
267
318
|
// =============================================================================
|