@sylphx/lens-server 1.3.2 → 1.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.
@@ -5,9 +5,9 @@
5
5
  */
6
6
 
7
7
  import { describe, expect, it } from "bun:test";
8
- import { entity, mutation, query, t } from "@sylphx/lens-core";
8
+ import { entity, mutation, query, resolver, t } from "@sylphx/lens-core";
9
9
  import { z } from "zod";
10
- import { type WebSocketLike, createServer } from "./create";
10
+ import { createServer, type WebSocketLike } from "./create";
11
11
 
12
12
  // =============================================================================
13
13
  // Test Fixtures
@@ -34,7 +34,7 @@ const mockUsers = [
34
34
  { id: "user-2", name: "Bob", email: "bob@example.com", bio: "Designer" },
35
35
  ];
36
36
 
37
- const mockPosts = [
37
+ const _mockPosts = [
38
38
  { id: "post-1", title: "Hello", content: "World", authorId: "user-1" },
39
39
  { id: "post-2", title: "Test", content: "Post", authorId: "user-1" },
40
40
  ];
@@ -140,9 +140,7 @@ describe("executeQuery", () => {
140
140
  queries: { getUser },
141
141
  });
142
142
 
143
- await expect(server.executeQuery("getUser", { id: 123 as unknown as string })).rejects.toThrow(
144
- "Invalid input",
145
- );
143
+ await expect(server.executeQuery("getUser", { id: 123 as unknown as string })).rejects.toThrow("Invalid input");
146
144
  });
147
145
 
148
146
  it("throws for unknown query", async () => {
@@ -151,9 +149,7 @@ describe("executeQuery", () => {
151
149
  queries: {},
152
150
  });
153
151
 
154
- await expect(server.executeQuery("unknownQuery")).rejects.toThrow(
155
- "Query not found: unknownQuery",
156
- );
152
+ await expect(server.executeQuery("unknownQuery")).rejects.toThrow("Query not found: unknownQuery");
157
153
  });
158
154
  });
159
155
 
@@ -200,9 +196,9 @@ describe("executeMutation", () => {
200
196
  mutations: { createUser },
201
197
  });
202
198
 
203
- await expect(
204
- server.executeMutation("createUser", { name: "Test", email: "invalid-email" }),
205
- ).rejects.toThrow("Invalid input");
199
+ await expect(server.executeMutation("createUser", { name: "Test", email: "invalid-email" })).rejects.toThrow(
200
+ "Invalid input",
201
+ );
206
202
  });
207
203
 
208
204
  it("throws for unknown mutation", async () => {
@@ -211,9 +207,7 @@ describe("executeMutation", () => {
211
207
  mutations: {},
212
208
  });
213
209
 
214
- await expect(server.executeMutation("unknownMutation", {})).rejects.toThrow(
215
- "Mutation not found: unknownMutation",
216
- );
210
+ await expect(server.executeMutation("unknownMutation", {})).rejects.toThrow("Mutation not found: unknownMutation");
217
211
  });
218
212
  });
219
213
 
@@ -355,9 +349,7 @@ describe("Subscribe Protocol", () => {
355
349
  expect(ws.messages.length).toBeGreaterThan(0);
356
350
 
357
351
  // Find the operation-level data message (has subscription id)
358
- const dataMessage = ws.messages
359
- .map((m) => JSON.parse(m))
360
- .find((m) => m.type === "data" && m.id === "sub-1");
352
+ const dataMessage = ws.messages.map((m) => JSON.parse(m)).find((m) => m.type === "data" && m.id === "sub-1");
361
353
 
362
354
  // Should have received operation-level data
363
355
  expect(dataMessage).toBeDefined();
@@ -640,12 +632,12 @@ describe("Streaming Support", () => {
640
632
 
641
633
  describe("Minimum Transfer", () => {
642
634
  it("sends initial data on subscribe", async () => {
643
- let emitFn: ((data: unknown) => void) | null = null;
635
+ let _emitFn: ((data: unknown) => void) | null = null;
644
636
 
645
637
  const liveQuery = query()
646
638
  .returns(User)
647
639
  .resolve(({ emit }) => {
648
- emitFn = emit;
640
+ _emitFn = emit;
649
641
  return { id: "1", name: "Alice", email: "alice@example.com" };
650
642
  });
651
643
 
@@ -804,4 +796,989 @@ describe("onCleanup", () => {
804
796
 
805
797
  expect(cleanedUp).toBe(true);
806
798
  });
799
+
800
+ it("allows cleanup removal via returned function", async () => {
801
+ let cleanedUp = false;
802
+
803
+ const liveQuery = query()
804
+ .returns(User)
805
+ .resolve(({ onCleanup }) => {
806
+ const remove = onCleanup(() => {
807
+ cleanedUp = true;
808
+ });
809
+ // Remove the cleanup before unsubscribe
810
+ remove();
811
+ return mockUsers[0];
812
+ });
813
+
814
+ const server = createServer({
815
+ entities: { User },
816
+ queries: { liveQuery },
817
+ });
818
+
819
+ const ws = createMockWs();
820
+ server.handleWebSocket(ws);
821
+
822
+ // Subscribe
823
+ ws.onmessage?.({
824
+ data: JSON.stringify({
825
+ type: "subscribe",
826
+ id: "sub-1",
827
+ operation: "liveQuery",
828
+ fields: "*",
829
+ }),
830
+ });
831
+
832
+ await new Promise((r) => setTimeout(r, 20));
833
+
834
+ // Unsubscribe
835
+ ws.onmessage?.({
836
+ data: JSON.stringify({ type: "unsubscribe", id: "sub-1" }),
837
+ });
838
+
839
+ // Should not have cleaned up since we removed it
840
+ expect(cleanedUp).toBe(false);
841
+ });
842
+ });
843
+
844
+ // =============================================================================
845
+ // Test: execute() method (for in-process transport)
846
+ // =============================================================================
847
+
848
+ describe("execute method", () => {
849
+ it("executes a query operation", async () => {
850
+ const getUsers = query()
851
+ .returns([User])
852
+ .resolve(() => mockUsers);
853
+
854
+ const server = createServer({
855
+ entities: { User },
856
+ queries: { getUsers },
857
+ });
858
+
859
+ const result = await server.execute({ path: "getUsers" });
860
+ expect(result.data).toEqual(mockUsers);
861
+ expect(result.error).toBeUndefined();
862
+ });
863
+
864
+ it("executes a mutation operation", async () => {
865
+ const createUser = mutation()
866
+ .input(z.object({ name: z.string() }))
867
+ .returns(User)
868
+ .resolve(({ input }) => ({ id: "new", name: input.name, email: "" }));
869
+
870
+ const server = createServer({
871
+ entities: { User },
872
+ mutations: { createUser },
873
+ });
874
+
875
+ const result = await server.execute({ path: "createUser", input: { name: "Test" } });
876
+ expect(result.data).toEqual({ id: "new", name: "Test", email: "" });
877
+ expect(result.error).toBeUndefined();
878
+ });
879
+
880
+ it("returns error for unknown operation", async () => {
881
+ const server = createServer({
882
+ entities: { User },
883
+ });
884
+
885
+ const result = await server.execute({ path: "unknownOp" });
886
+ expect(result.data).toBeUndefined();
887
+ expect(result.error).toBeDefined();
888
+ expect(result.error?.message).toBe("Operation not found: unknownOp");
889
+ });
890
+
891
+ it("catches and returns errors from operations", async () => {
892
+ const errorQuery = query()
893
+ .returns(User)
894
+ .resolve(() => {
895
+ throw new Error("Test error");
896
+ });
897
+
898
+ const server = createServer({
899
+ entities: { User },
900
+ queries: { errorQuery },
901
+ });
902
+
903
+ const result = await server.execute({ path: "errorQuery" });
904
+ expect(result.data).toBeUndefined();
905
+ expect(result.error).toBeDefined();
906
+ expect(result.error?.message).toBe("Test error");
907
+ });
908
+
909
+ it("converts non-Error exceptions to Error objects", async () => {
910
+ const errorQuery = query()
911
+ .returns(User)
912
+ .resolve(() => {
913
+ throw "String error";
914
+ });
915
+
916
+ const server = createServer({
917
+ entities: { User },
918
+ queries: { errorQuery },
919
+ });
920
+
921
+ const result = await server.execute({ path: "errorQuery" });
922
+ expect(result.error).toBeDefined();
923
+ expect(result.error?.message).toBe("String error");
924
+ });
925
+ });
926
+
927
+ // =============================================================================
928
+ // Test: getMetadata() and buildOperationsMap()
929
+ // =============================================================================
930
+
931
+ describe("getMetadata", () => {
932
+ it("returns server metadata with version and operations", () => {
933
+ const getUser = query()
934
+ .returns(User)
935
+ .resolve(() => mockUsers[0]);
936
+
937
+ const createUser = mutation()
938
+ .input(z.object({ name: z.string() }))
939
+ .returns(User)
940
+ .resolve(({ input }) => ({ id: "new", name: input.name, email: "" }));
941
+
942
+ const server = createServer({
943
+ entities: { User },
944
+ queries: { getUser },
945
+ mutations: { createUser },
946
+ version: "1.2.3",
947
+ });
948
+
949
+ const metadata = server.getMetadata();
950
+ expect(metadata.version).toBe("1.2.3");
951
+ expect(metadata.operations).toBeDefined();
952
+ expect(metadata.operations.getUser).toEqual({ type: "query" });
953
+ // createUser auto-derives optimistic "create" from naming convention
954
+ expect(metadata.operations.createUser).toEqual({ type: "mutation", optimistic: "create" });
955
+ });
956
+
957
+ it("builds nested operations map from namespaced routes", () => {
958
+ const getUserQuery = query()
959
+ .returns(User)
960
+ .resolve(() => mockUsers[0]);
961
+
962
+ const createUserMutation = mutation()
963
+ .input(z.object({ name: z.string() }))
964
+ .returns(User)
965
+ .resolve(({ input }) => ({ id: "new", name: input.name, email: "" }));
966
+
967
+ const server = createServer({
968
+ entities: { User },
969
+ queries: { "user.get": getUserQuery },
970
+ mutations: { "user.create": createUserMutation },
971
+ });
972
+
973
+ const metadata = server.getMetadata();
974
+ expect(metadata.operations.user).toBeDefined();
975
+ expect((metadata.operations.user as any).get).toEqual({ type: "query" });
976
+ // Auto-derives optimistic "create" from naming convention
977
+ expect((metadata.operations.user as any).create).toEqual({ type: "mutation", optimistic: "create" });
978
+ });
979
+
980
+ it("includes optimistic config in mutation metadata", () => {
981
+ const updateUser = mutation()
982
+ .input(z.object({ id: z.string(), name: z.string() }))
983
+ .returns(User)
984
+ .optimistic("merge")
985
+ .resolve(({ input }) => ({ id: input.id, name: input.name, email: "" }));
986
+
987
+ const server = createServer({
988
+ entities: { User },
989
+ mutations: { updateUser },
990
+ });
991
+
992
+ const metadata = server.getMetadata();
993
+ expect(metadata.operations.updateUser).toEqual({
994
+ type: "mutation",
995
+ optimistic: "merge",
996
+ });
997
+ });
998
+
999
+ it("handles deeply nested namespaced operations", () => {
1000
+ const deepQuery = query()
1001
+ .returns(User)
1002
+ .resolve(() => mockUsers[0]);
1003
+
1004
+ const server = createServer({
1005
+ entities: { User },
1006
+ queries: { "api.v1.user.get": deepQuery },
1007
+ });
1008
+
1009
+ const metadata = server.getMetadata();
1010
+ const operations = metadata.operations as any;
1011
+ expect(operations.api.v1.user.get).toEqual({ type: "query" });
1012
+ });
1013
+ });
1014
+
1015
+ // =============================================================================
1016
+ // Test: HTTP handleRequest edge cases
1017
+ // =============================================================================
1018
+
1019
+ describe("handleRequest edge cases", () => {
1020
+ it("returns metadata on GET /__lens/metadata", async () => {
1021
+ const server = createServer({
1022
+ entities: { User },
1023
+ version: "1.0.0",
1024
+ });
1025
+
1026
+ const request = new Request("http://localhost/__lens/metadata", { method: "GET" });
1027
+ const response = await server.handleRequest(request);
1028
+
1029
+ expect(response.status).toBe(200);
1030
+ const body = await response.json();
1031
+ expect(body.version).toBe("1.0.0");
1032
+ expect(body.operations).toBeDefined();
1033
+ });
1034
+
1035
+ it("returns 404 for unknown operation", async () => {
1036
+ const server = createServer({
1037
+ entities: { User },
1038
+ });
1039
+
1040
+ const request = new Request("http://localhost/api", {
1041
+ method: "POST",
1042
+ headers: { "Content-Type": "application/json" },
1043
+ body: JSON.stringify({ operation: "unknownOp" }),
1044
+ });
1045
+
1046
+ const response = await server.handleRequest(request);
1047
+ expect(response.status).toBe(404);
1048
+
1049
+ const body = await response.json();
1050
+ expect(body.error).toBe("Operation not found: unknownOp");
1051
+ });
1052
+
1053
+ it("returns 500 for operation errors", async () => {
1054
+ const errorQuery = query()
1055
+ .returns(User)
1056
+ .resolve(() => {
1057
+ throw new Error("Internal error");
1058
+ });
1059
+
1060
+ const server = createServer({
1061
+ entities: { User },
1062
+ queries: { errorQuery },
1063
+ });
1064
+
1065
+ const request = new Request("http://localhost/api", {
1066
+ method: "POST",
1067
+ headers: { "Content-Type": "application/json" },
1068
+ body: JSON.stringify({ operation: "errorQuery" }),
1069
+ });
1070
+
1071
+ const response = await server.handleRequest(request);
1072
+ expect(response.status).toBe(500);
1073
+
1074
+ const body = await response.json();
1075
+ expect(body.error).toContain("Internal error");
1076
+ });
1077
+
1078
+ it("handles POST requests for queries", async () => {
1079
+ const getUser = query()
1080
+ .input(z.object({ id: z.string() }))
1081
+ .returns(User)
1082
+ .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1083
+
1084
+ const server = createServer({
1085
+ entities: { User },
1086
+ queries: { getUser },
1087
+ });
1088
+
1089
+ const request = new Request("http://localhost/api", {
1090
+ method: "POST",
1091
+ headers: { "Content-Type": "application/json" },
1092
+ body: JSON.stringify({ operation: "getUser", input: { id: "user-1" } }),
1093
+ });
1094
+
1095
+ const response = await server.handleRequest(request);
1096
+ expect(response.status).toBe(200);
1097
+
1098
+ const body = await response.json();
1099
+ expect(body.data).toEqual(mockUsers[0]);
1100
+ });
1101
+ });
1102
+
1103
+ // =============================================================================
1104
+ // Test: Context creation errors
1105
+ // =============================================================================
1106
+
1107
+ describe("Context creation errors", () => {
1108
+ it("handles context factory errors in executeQuery", async () => {
1109
+ const getUser = query()
1110
+ .returns(User)
1111
+ .resolve(() => mockUsers[0]);
1112
+
1113
+ const server = createServer({
1114
+ entities: { User },
1115
+ queries: { getUser },
1116
+ context: () => {
1117
+ throw new Error("Context creation failed");
1118
+ },
1119
+ });
1120
+
1121
+ await expect(server.executeQuery("getUser")).rejects.toThrow("Context creation failed");
1122
+ });
1123
+
1124
+ it("handles async context factory errors in executeMutation", async () => {
1125
+ const createUser = mutation()
1126
+ .input(z.object({ name: z.string() }))
1127
+ .returns(User)
1128
+ .resolve(({ input }) => ({ id: "new", name: input.name, email: "" }));
1129
+
1130
+ const server = createServer({
1131
+ entities: { User },
1132
+ mutations: { createUser },
1133
+ context: async () => {
1134
+ throw new Error("Async context error");
1135
+ },
1136
+ });
1137
+
1138
+ await expect(server.executeMutation("createUser", { name: "Test" })).rejects.toThrow("Async context error");
1139
+ });
1140
+
1141
+ it("handles context errors in subscription", async () => {
1142
+ const liveQuery = query()
1143
+ .returns(User)
1144
+ .resolve(() => mockUsers[0]);
1145
+
1146
+ const server = createServer({
1147
+ entities: { User },
1148
+ queries: { liveQuery },
1149
+ context: () => {
1150
+ throw new Error("Context error in subscription");
1151
+ },
1152
+ });
1153
+
1154
+ const ws = createMockWs();
1155
+ server.handleWebSocket(ws);
1156
+
1157
+ ws.onmessage?.({
1158
+ data: JSON.stringify({
1159
+ type: "subscribe",
1160
+ id: "sub-1",
1161
+ operation: "liveQuery",
1162
+ fields: "*",
1163
+ }),
1164
+ });
1165
+
1166
+ await new Promise((r) => setTimeout(r, 50));
1167
+
1168
+ // Should receive error message
1169
+ const errorMsg = ws.messages.find((m) => {
1170
+ const parsed = JSON.parse(m);
1171
+ return parsed.type === "error" && parsed.id === "sub-1";
1172
+ });
1173
+
1174
+ expect(errorMsg).toBeDefined();
1175
+ const parsed = JSON.parse(errorMsg!);
1176
+ expect(parsed.error.message).toContain("Context error in subscription");
1177
+ });
1178
+ });
1179
+
1180
+ // =============================================================================
1181
+ // Test: Subscription edge cases
1182
+ // =============================================================================
1183
+
1184
+ describe("Subscription edge cases", () => {
1185
+ it("handles subscription input validation errors", async () => {
1186
+ const getUser = query()
1187
+ .input(z.object({ id: z.string().min(5) }))
1188
+ .returns(User)
1189
+ .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1190
+
1191
+ const server = createServer({
1192
+ entities: { User },
1193
+ queries: { getUser },
1194
+ });
1195
+
1196
+ const ws = createMockWs();
1197
+ server.handleWebSocket(ws);
1198
+
1199
+ ws.onmessage?.({
1200
+ data: JSON.stringify({
1201
+ type: "subscribe",
1202
+ id: "sub-1",
1203
+ operation: "getUser",
1204
+ input: { id: "a" }, // Too short
1205
+ fields: "*",
1206
+ }),
1207
+ });
1208
+
1209
+ await new Promise((r) => setTimeout(r, 50));
1210
+
1211
+ const errorMsg = ws.messages.find((m) => {
1212
+ const parsed = JSON.parse(m);
1213
+ return parsed.type === "error";
1214
+ });
1215
+
1216
+ expect(errorMsg).toBeDefined();
1217
+ const parsed = JSON.parse(errorMsg!);
1218
+ expect(parsed.error.message).toContain("Invalid input");
1219
+ });
1220
+
1221
+ it("handles updateFields for non-existent subscription", () => {
1222
+ const server = createServer({
1223
+ entities: { User },
1224
+ });
1225
+
1226
+ const ws = createMockWs();
1227
+ server.handleWebSocket(ws);
1228
+
1229
+ // Try to update fields for non-existent subscription
1230
+ ws.onmessage?.({
1231
+ data: JSON.stringify({
1232
+ type: "updateFields",
1233
+ id: "non-existent",
1234
+ addFields: ["name"],
1235
+ }),
1236
+ });
1237
+
1238
+ // Should not throw - just be a no-op
1239
+ expect(true).toBe(true);
1240
+ });
1241
+
1242
+ it("handles unsubscribe for non-existent subscription", () => {
1243
+ const server = createServer({
1244
+ entities: { User },
1245
+ });
1246
+
1247
+ const ws = createMockWs();
1248
+ server.handleWebSocket(ws);
1249
+
1250
+ // Try to unsubscribe from non-existent subscription
1251
+ ws.onmessage?.({
1252
+ data: JSON.stringify({
1253
+ type: "unsubscribe",
1254
+ id: "non-existent",
1255
+ }),
1256
+ });
1257
+
1258
+ // Should not throw - just be a no-op
1259
+ expect(true).toBe(true);
1260
+ });
1261
+
1262
+ it("upgrades to full subscription with wildcard", async () => {
1263
+ const getUser = query()
1264
+ .input(z.object({ id: z.string() }))
1265
+ .returns(User)
1266
+ .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1267
+
1268
+ const server = createServer({
1269
+ entities: { User },
1270
+ queries: { getUser },
1271
+ });
1272
+
1273
+ const ws = createMockWs();
1274
+ server.handleWebSocket(ws);
1275
+
1276
+ // Subscribe with partial fields
1277
+ ws.onmessage?.({
1278
+ data: JSON.stringify({
1279
+ type: "subscribe",
1280
+ id: "sub-1",
1281
+ operation: "getUser",
1282
+ input: { id: "user-1" },
1283
+ fields: ["name"],
1284
+ }),
1285
+ });
1286
+
1287
+ await new Promise((r) => setTimeout(r, 20));
1288
+
1289
+ // Upgrade to full subscription
1290
+ ws.onmessage?.({
1291
+ data: JSON.stringify({
1292
+ type: "updateFields",
1293
+ id: "sub-1",
1294
+ addFields: ["*"],
1295
+ }),
1296
+ });
1297
+
1298
+ // Should not throw
1299
+ expect(true).toBe(true);
1300
+ });
1301
+
1302
+ it("downgrades from wildcard to specific fields", async () => {
1303
+ const getUser = query()
1304
+ .input(z.object({ id: z.string() }))
1305
+ .returns(User)
1306
+ .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1307
+
1308
+ const server = createServer({
1309
+ entities: { User },
1310
+ queries: { getUser },
1311
+ });
1312
+
1313
+ const ws = createMockWs();
1314
+ server.handleWebSocket(ws);
1315
+
1316
+ // Subscribe with wildcard
1317
+ ws.onmessage?.({
1318
+ data: JSON.stringify({
1319
+ type: "subscribe",
1320
+ id: "sub-1",
1321
+ operation: "getUser",
1322
+ input: { id: "user-1" },
1323
+ fields: "*",
1324
+ }),
1325
+ });
1326
+
1327
+ await new Promise((r) => setTimeout(r, 20));
1328
+
1329
+ // Downgrade to specific fields
1330
+ ws.onmessage?.({
1331
+ data: JSON.stringify({
1332
+ type: "updateFields",
1333
+ id: "sub-1",
1334
+ setFields: ["name", "email"],
1335
+ }),
1336
+ });
1337
+
1338
+ // Should not throw
1339
+ expect(true).toBe(true);
1340
+ });
1341
+
1342
+ it("ignores add/remove when already subscribed to wildcard", async () => {
1343
+ const getUser = query()
1344
+ .input(z.object({ id: z.string() }))
1345
+ .returns(User)
1346
+ .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1347
+
1348
+ const server = createServer({
1349
+ entities: { User },
1350
+ queries: { getUser },
1351
+ });
1352
+
1353
+ const ws = createMockWs();
1354
+ server.handleWebSocket(ws);
1355
+
1356
+ // Subscribe with wildcard
1357
+ ws.onmessage?.({
1358
+ data: JSON.stringify({
1359
+ type: "subscribe",
1360
+ id: "sub-1",
1361
+ operation: "getUser",
1362
+ input: { id: "user-1" },
1363
+ fields: "*",
1364
+ }),
1365
+ });
1366
+
1367
+ await new Promise((r) => setTimeout(r, 20));
1368
+
1369
+ // Try to add fields (should be ignored)
1370
+ ws.onmessage?.({
1371
+ data: JSON.stringify({
1372
+ type: "updateFields",
1373
+ id: "sub-1",
1374
+ addFields: ["bio"],
1375
+ }),
1376
+ });
1377
+
1378
+ // Should not throw
1379
+ expect(true).toBe(true);
1380
+ });
1381
+
1382
+ it("handles async generator with empty stream", async () => {
1383
+ const emptyStream = query()
1384
+ .returns(User)
1385
+ .resolve(async function* () {
1386
+ // Empty generator - yields nothing
1387
+ });
1388
+
1389
+ const server = createServer({
1390
+ entities: { User },
1391
+ queries: { emptyStream },
1392
+ });
1393
+
1394
+ await expect(server.executeQuery("emptyStream")).rejects.toThrow("returned empty stream");
1395
+ });
1396
+ });
1397
+
1398
+ // =============================================================================
1399
+ // Test: Query with $select
1400
+ // =============================================================================
1401
+
1402
+ describe("Query with $select", () => {
1403
+ it("handles query with $select parameter", async () => {
1404
+ const getUser = query()
1405
+ .input(z.object({ id: z.string() }))
1406
+ .returns(User)
1407
+ .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1408
+
1409
+ const server = createServer({
1410
+ entities: { User },
1411
+ queries: { getUser },
1412
+ });
1413
+
1414
+ // Use $select to trigger selection processing
1415
+ const result = await server.executeQuery("getUser", {
1416
+ id: "user-1",
1417
+ $select: { name: true, email: true },
1418
+ });
1419
+
1420
+ expect(result).toBeDefined();
1421
+ expect((result as any).id).toBe("user-1");
1422
+ expect((result as any).name).toBe("Alice");
1423
+ });
1424
+
1425
+ it("processes WebSocket query message with select", async () => {
1426
+ const getUser = query()
1427
+ .input(z.object({ id: z.string() }))
1428
+ .returns(User)
1429
+ .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1430
+
1431
+ const server = createServer({
1432
+ entities: { User },
1433
+ queries: { getUser },
1434
+ });
1435
+
1436
+ const ws = createMockWs();
1437
+ server.handleWebSocket(ws);
1438
+
1439
+ // Query with select
1440
+ ws.onmessage?.({
1441
+ data: JSON.stringify({
1442
+ type: "query",
1443
+ id: "q-1",
1444
+ operation: "getUser",
1445
+ input: { id: "user-1" },
1446
+ select: { name: true, email: true },
1447
+ }),
1448
+ });
1449
+
1450
+ await new Promise((r) => setTimeout(r, 20));
1451
+
1452
+ expect(ws.messages.length).toBe(1);
1453
+ const response = JSON.parse(ws.messages[0]);
1454
+ expect(response.type).toBe("result");
1455
+ expect(response.data.name).toBe("Alice");
1456
+ });
1457
+
1458
+ it("applies field selection with fields array", async () => {
1459
+ const getUser = query()
1460
+ .input(z.object({ id: z.string() }))
1461
+ .returns(User)
1462
+ .resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
1463
+
1464
+ const server = createServer({
1465
+ entities: { User },
1466
+ queries: { getUser },
1467
+ });
1468
+
1469
+ const ws = createMockWs();
1470
+ server.handleWebSocket(ws);
1471
+
1472
+ // Query with fields array (backward compat)
1473
+ ws.onmessage?.({
1474
+ data: JSON.stringify({
1475
+ type: "query",
1476
+ id: "q-1",
1477
+ operation: "getUser",
1478
+ input: { id: "user-1" },
1479
+ fields: ["name"],
1480
+ }),
1481
+ });
1482
+
1483
+ await new Promise((r) => setTimeout(r, 20));
1484
+
1485
+ expect(ws.messages.length).toBe(1);
1486
+ const response = JSON.parse(ws.messages[0]);
1487
+ expect(response.type).toBe("result");
1488
+ expect(response.data.id).toBe("user-1"); // id is always included
1489
+ expect(response.data.name).toBe("Alice");
1490
+ });
1491
+ });
1492
+
1493
+ // =============================================================================
1494
+ // Test: Logger integration
1495
+ // =============================================================================
1496
+
1497
+ describe("Logger integration", () => {
1498
+ it("calls logger.error on cleanup errors", async () => {
1499
+ const errorLogs: string[] = [];
1500
+ const liveQuery = query()
1501
+ .returns(User)
1502
+ .resolve(({ onCleanup }) => {
1503
+ onCleanup(() => {
1504
+ throw new Error("Cleanup failed");
1505
+ });
1506
+ return mockUsers[0];
1507
+ });
1508
+
1509
+ const server = createServer({
1510
+ entities: { User },
1511
+ queries: { liveQuery },
1512
+ logger: {
1513
+ error: (msg, ...args) => {
1514
+ errorLogs.push(`${msg} ${args.join(" ")}`);
1515
+ },
1516
+ },
1517
+ });
1518
+
1519
+ const ws = createMockWs();
1520
+ server.handleWebSocket(ws);
1521
+
1522
+ // Subscribe
1523
+ ws.onmessage?.({
1524
+ data: JSON.stringify({
1525
+ type: "subscribe",
1526
+ id: "sub-1",
1527
+ operation: "liveQuery",
1528
+ fields: "*",
1529
+ }),
1530
+ });
1531
+
1532
+ await new Promise((r) => setTimeout(r, 20));
1533
+
1534
+ // Unsubscribe (triggers cleanup error)
1535
+ ws.onmessage?.({
1536
+ data: JSON.stringify({ type: "unsubscribe", id: "sub-1" }),
1537
+ });
1538
+
1539
+ expect(errorLogs.length).toBeGreaterThan(0);
1540
+ expect(errorLogs[0]).toContain("Cleanup error");
1541
+ });
1542
+
1543
+ it("calls logger.error on disconnect cleanup errors", async () => {
1544
+ const errorLogs: string[] = [];
1545
+ const liveQuery = query()
1546
+ .returns(User)
1547
+ .resolve(({ onCleanup }) => {
1548
+ onCleanup(() => {
1549
+ throw new Error("Disconnect cleanup failed");
1550
+ });
1551
+ return mockUsers[0];
1552
+ });
1553
+
1554
+ const server = createServer({
1555
+ entities: { User },
1556
+ queries: { liveQuery },
1557
+ logger: {
1558
+ error: (msg, ...args) => {
1559
+ errorLogs.push(`${msg} ${args.join(" ")}`);
1560
+ },
1561
+ },
1562
+ });
1563
+
1564
+ const ws = createMockWs();
1565
+ server.handleWebSocket(ws);
1566
+
1567
+ // Subscribe
1568
+ ws.onmessage?.({
1569
+ data: JSON.stringify({
1570
+ type: "subscribe",
1571
+ id: "sub-1",
1572
+ operation: "liveQuery",
1573
+ fields: "*",
1574
+ }),
1575
+ });
1576
+
1577
+ await new Promise((r) => setTimeout(r, 20));
1578
+
1579
+ // Disconnect (triggers cleanup)
1580
+ ws.onclose?.();
1581
+
1582
+ expect(errorLogs.length).toBeGreaterThan(0);
1583
+ expect(errorLogs[0]).toContain("Cleanup error");
1584
+ });
1585
+ });
1586
+
1587
+ // =============================================================================
1588
+ // Test: Entity Resolvers
1589
+ // =============================================================================
1590
+
1591
+ describe("Entity Resolvers", () => {
1592
+ it("executes field resolvers with select", async () => {
1593
+ const Author = entity("Author", {
1594
+ id: t.id(),
1595
+ name: t.string(),
1596
+ });
1597
+
1598
+ const Article = entity("Article", {
1599
+ id: t.id(),
1600
+ title: t.string(),
1601
+ authorId: t.string(),
1602
+ // author relation is resolved
1603
+ });
1604
+
1605
+ const mockAuthors = [
1606
+ { id: "author-1", name: "Alice" },
1607
+ { id: "author-2", name: "Bob" },
1608
+ ];
1609
+
1610
+ const mockArticles = [
1611
+ { id: "article-1", title: "First Post", authorId: "author-1" },
1612
+ { id: "article-2", title: "Second Post", authorId: "author-2" },
1613
+ ];
1614
+
1615
+ // Create resolver for Article entity
1616
+ const articleResolver = resolver(Article, (f) => ({
1617
+ id: f.expose("id"),
1618
+ title: f.expose("title"),
1619
+ author: f.one(Author).resolve(({ parent }) => {
1620
+ return mockAuthors.find((a) => a.id === parent.authorId) ?? null;
1621
+ }),
1622
+ }));
1623
+
1624
+ const getArticle = query()
1625
+ .input(z.object({ id: z.string() }))
1626
+ .returns(Article)
1627
+ .resolve(({ input }) => {
1628
+ return mockArticles.find((a) => a.id === input.id) ?? null;
1629
+ });
1630
+
1631
+ const server = createServer({
1632
+ entities: { Article, Author },
1633
+ queries: { getArticle },
1634
+ resolvers: [articleResolver],
1635
+ });
1636
+
1637
+ const result = await server.executeQuery("getArticle", {
1638
+ id: "article-1",
1639
+ $select: {
1640
+ title: true,
1641
+ author: {
1642
+ select: {
1643
+ name: true,
1644
+ },
1645
+ },
1646
+ },
1647
+ });
1648
+
1649
+ expect(result).toBeDefined();
1650
+ expect((result as any).title).toBe("First Post");
1651
+ expect((result as any).author).toBeDefined();
1652
+ expect((result as any).author.name).toBe("Alice");
1653
+ });
1654
+
1655
+ it("handles array relations in resolvers", async () => {
1656
+ const Author = entity("Author", {
1657
+ id: t.id(),
1658
+ name: t.string(),
1659
+ });
1660
+
1661
+ const Article = entity("Article", {
1662
+ id: t.id(),
1663
+ title: t.string(),
1664
+ authorId: t.string(),
1665
+ });
1666
+
1667
+ const mockArticles = [
1668
+ { id: "article-1", title: "First Post", authorId: "author-1" },
1669
+ { id: "article-2", title: "Second Post", authorId: "author-1" },
1670
+ { id: "article-3", title: "Third Post", authorId: "author-2" },
1671
+ ];
1672
+
1673
+ // Create resolver for Author entity with articles relation
1674
+ const authorResolver = resolver(Author, (f) => ({
1675
+ id: f.expose("id"),
1676
+ name: f.expose("name"),
1677
+ articles: f.many(Article).resolve(({ parent }) => {
1678
+ return mockArticles.filter((a) => a.authorId === parent.id);
1679
+ }),
1680
+ }));
1681
+
1682
+ const getAuthor = query()
1683
+ .input(z.object({ id: z.string() }))
1684
+ .returns(Author)
1685
+ .resolve(({ input }) => {
1686
+ return { id: input.id, name: input.id === "author-1" ? "Alice" : "Bob" };
1687
+ });
1688
+
1689
+ const server = createServer({
1690
+ entities: { Article, Author },
1691
+ queries: { getAuthor },
1692
+ resolvers: [authorResolver],
1693
+ });
1694
+
1695
+ const result = await server.executeQuery("getAuthor", {
1696
+ id: "author-1",
1697
+ $select: {
1698
+ name: true,
1699
+ articles: {
1700
+ select: {
1701
+ title: true,
1702
+ },
1703
+ },
1704
+ },
1705
+ });
1706
+
1707
+ expect(result).toBeDefined();
1708
+ expect((result as any).name).toBe("Alice");
1709
+ expect((result as any).articles).toBeDefined();
1710
+ expect(Array.isArray((result as any).articles)).toBe(true);
1711
+ expect((result as any).articles.length).toBe(2);
1712
+ expect((result as any).articles[0].title).toBe("First Post");
1713
+ });
1714
+
1715
+ it("handles field resolver with args", async () => {
1716
+ const User = entity("User", {
1717
+ id: t.id(),
1718
+ name: t.string(),
1719
+ });
1720
+
1721
+ const userResolver = resolver(User, (f) => ({
1722
+ id: f.expose("id"),
1723
+ name: f.expose("name"),
1724
+ greeting: f
1725
+ .string()
1726
+ .args<{ formal: boolean }>()
1727
+ .resolve(({ parent, args }) => {
1728
+ return args.formal ? `Good day, ${parent.name}` : `Hey ${parent.name}!`;
1729
+ }),
1730
+ }));
1731
+
1732
+ const getUser = query()
1733
+ .returns(User)
1734
+ .resolve(() => ({ id: "1", name: "Alice" }));
1735
+
1736
+ const server = createServer({
1737
+ entities: { User },
1738
+ queries: { getUser },
1739
+ resolvers: [userResolver],
1740
+ });
1741
+
1742
+ const result = await server.executeQuery("getUser", {
1743
+ $select: {
1744
+ name: true,
1745
+ greeting: {
1746
+ args: { formal: true },
1747
+ },
1748
+ },
1749
+ });
1750
+
1751
+ expect(result).toBeDefined();
1752
+ expect((result as any).greeting).toBe("Good day, Alice");
1753
+ });
1754
+
1755
+ it("returns data unchanged when no select provided", async () => {
1756
+ const User = entity("User", {
1757
+ id: t.id(),
1758
+ name: t.string(),
1759
+ });
1760
+
1761
+ const userResolver = resolver(User, (f) => ({
1762
+ id: f.expose("id"),
1763
+ name: f.expose("name"),
1764
+ bio: f.string().resolve(({ parent }) => `Biography of ${parent.name}`),
1765
+ }));
1766
+
1767
+ const getUser = query()
1768
+ .returns(User)
1769
+ .resolve(() => ({ id: "1", name: "Alice" }));
1770
+
1771
+ const server = createServer({
1772
+ entities: { User },
1773
+ queries: { getUser },
1774
+ resolvers: [userResolver],
1775
+ });
1776
+
1777
+ const result = await server.executeQuery("getUser");
1778
+
1779
+ expect(result).toBeDefined();
1780
+ expect((result as any).name).toBe("Alice");
1781
+ // bio should not be resolved without select
1782
+ expect((result as any).bio).toBeUndefined();
1783
+ });
807
1784
  });