@sylphx/lens-server 2.4.1 → 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 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 meta = { type: "query" };
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: "query",
826
+ type: opType,
777
827
  meta,
778
828
  definition: def
779
829
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/lens-server",
3
- "version": "2.4.1",
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.2.0"
33
+ "@sylphx/lens-core": "^2.3.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "^5.9.3",
@@ -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
+ });
@@ -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
- const meta: OperationMeta = { type: "query" };
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: "query",
1008
+ type: opType,
904
1009
  meta: meta as unknown as Record<string, unknown>,
905
1010
  definition: def,
906
1011
  });
@@ -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
  // =============================================================================