@sylphx/lens-server 3.0.1 → 4.0.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 +16 -7
- package/dist/index.js +318 -114
- package/package.json +2 -2
- package/src/e2e/server.test.ts +70 -56
- package/src/handlers/framework.ts +65 -32
- package/src/handlers/http.test.ts +8 -8
- package/src/handlers/http.ts +3 -5
- package/src/handlers/ws-types.ts +1 -0
- package/src/handlers/ws.test.ts +6 -6
- package/src/handlers/ws.ts +14 -3
- package/src/index.ts +0 -2
- package/src/plugin/optimistic.ts +6 -6
- package/src/reconnect/operation-log.ts +20 -9
- package/src/server/create.test.ts +223 -316
- package/src/server/create.ts +328 -123
- package/src/server/types.ts +23 -6
- package/src/storage/memory.ts +24 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/lens-server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.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": "^
|
|
33
|
+
"@sylphx/lens-core": "^4.0.1"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "^5.9.3",
|
package/src/e2e/server.test.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, expect, it } from "bun:test";
|
|
11
|
-
import {
|
|
11
|
+
import { firstValueFrom, id, isError, isSnapshot, model, mutation, query, resolver, string } from "@sylphx/lens-core";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { optimisticPlugin } from "../plugin/optimistic.js";
|
|
14
14
|
import { createApp } from "../server/create.js";
|
|
@@ -18,18 +18,18 @@ import { createApp } from "../server/create.js";
|
|
|
18
18
|
// =============================================================================
|
|
19
19
|
|
|
20
20
|
// Entities
|
|
21
|
-
const User =
|
|
22
|
-
id:
|
|
23
|
-
name:
|
|
24
|
-
email:
|
|
25
|
-
status:
|
|
21
|
+
const User = model("User", {
|
|
22
|
+
id: id(),
|
|
23
|
+
name: string(),
|
|
24
|
+
email: string(),
|
|
25
|
+
status: string(),
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
const Post =
|
|
29
|
-
id:
|
|
30
|
-
title:
|
|
31
|
-
content:
|
|
32
|
-
authorId:
|
|
28
|
+
const Post = model("Post", {
|
|
29
|
+
id: id(),
|
|
30
|
+
title: string(),
|
|
31
|
+
content: string(),
|
|
32
|
+
authorId: string(),
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
// Mock data
|
|
@@ -70,8 +70,8 @@ describe("E2E - Basic Operations", () => {
|
|
|
70
70
|
const getUser = query()
|
|
71
71
|
.input(z.object({ id: z.string() }))
|
|
72
72
|
.returns(User)
|
|
73
|
-
.resolve(({
|
|
74
|
-
const user = mockUsers.find((u) => u.id ===
|
|
73
|
+
.resolve(({ args }) => {
|
|
74
|
+
const user = mockUsers.find((u) => u.id === args.id);
|
|
75
75
|
if (!user) throw new Error("User not found");
|
|
76
76
|
return user;
|
|
77
77
|
});
|
|
@@ -98,10 +98,10 @@ describe("E2E - Basic Operations", () => {
|
|
|
98
98
|
const createUser = mutation()
|
|
99
99
|
.input(z.object({ name: z.string(), email: z.string() }))
|
|
100
100
|
.returns(User)
|
|
101
|
-
.resolve(({
|
|
101
|
+
.resolve(({ args }) => ({
|
|
102
102
|
id: "user-new",
|
|
103
|
-
name:
|
|
104
|
-
email:
|
|
103
|
+
name: args.name,
|
|
104
|
+
email: args.email,
|
|
105
105
|
status: "offline",
|
|
106
106
|
}));
|
|
107
107
|
|
|
@@ -242,8 +242,8 @@ describe("E2E - Selection", () => {
|
|
|
242
242
|
const getUser = query()
|
|
243
243
|
.input(z.object({ id: z.string() }))
|
|
244
244
|
.returns(User)
|
|
245
|
-
.resolve(({
|
|
246
|
-
const user = mockUsers.find((u) => u.id ===
|
|
245
|
+
.resolve(({ args }) => {
|
|
246
|
+
const user = mockUsers.find((u) => u.id === args.id);
|
|
247
247
|
if (!user) throw new Error("User not found");
|
|
248
248
|
return user;
|
|
249
249
|
});
|
|
@@ -280,7 +280,7 @@ describe("E2E - Selection", () => {
|
|
|
280
280
|
const getUser = query()
|
|
281
281
|
.input(z.object({ id: z.string() }))
|
|
282
282
|
.returns(User)
|
|
283
|
-
.resolve(({
|
|
283
|
+
.resolve(({ args }) => mockUsers.find((u) => u.id === args.id)!);
|
|
284
284
|
|
|
285
285
|
const server = createApp({
|
|
286
286
|
entities: { User },
|
|
@@ -323,28 +323,36 @@ describe("E2E - Entity Resolvers", () => {
|
|
|
323
323
|
{ id: "post-2", title: "Second Post", content: "More content", authorId: "user-1" },
|
|
324
324
|
];
|
|
325
325
|
|
|
326
|
+
// Define User model
|
|
327
|
+
const UserWithPosts = model("UserWithPosts", {
|
|
328
|
+
id: id(),
|
|
329
|
+
name: string(),
|
|
330
|
+
email: string(),
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// Define resolver with posts relation (new API)
|
|
334
|
+
const userResolver = resolver(UserWithPosts, (t) => ({
|
|
335
|
+
id: t.expose("id"),
|
|
336
|
+
name: t.expose("name"),
|
|
337
|
+
email: t.expose("email"),
|
|
338
|
+
// Plain function for relations
|
|
339
|
+
posts: ({ source }) => posts.filter((p) => p.authorId === source.id),
|
|
340
|
+
}));
|
|
341
|
+
|
|
326
342
|
const getUser = query()
|
|
327
343
|
.input(z.object({ id: z.string() }))
|
|
328
|
-
.returns(
|
|
329
|
-
.resolve(({
|
|
330
|
-
const user = users.find((u) => u.id ===
|
|
344
|
+
.returns(UserWithPosts)
|
|
345
|
+
.resolve(({ args }) => {
|
|
346
|
+
const user = users.find((u) => u.id === args.id);
|
|
331
347
|
if (!user) throw new Error("Not found");
|
|
332
348
|
return user;
|
|
333
349
|
});
|
|
334
350
|
|
|
335
|
-
// Create entity resolvers using lens() factory
|
|
336
|
-
const { resolver } = lens();
|
|
337
|
-
const userResolver = resolver(User, (f) => ({
|
|
338
|
-
id: f.expose("id"),
|
|
339
|
-
name: f.expose("name"),
|
|
340
|
-
email: f.expose("email"),
|
|
341
|
-
posts: f.many(Post).resolve(({ parent }) => posts.filter((p) => p.authorId === parent.id)),
|
|
342
|
-
}));
|
|
343
|
-
|
|
344
351
|
const server = createApp({
|
|
345
|
-
entities: {
|
|
352
|
+
entities: { UserWithPosts, Post },
|
|
346
353
|
queries: { getUser },
|
|
347
354
|
resolvers: [userResolver],
|
|
355
|
+
context: () => ({}),
|
|
348
356
|
});
|
|
349
357
|
|
|
350
358
|
// Test with $select for nested posts
|
|
@@ -367,14 +375,10 @@ describe("E2E - Entity Resolvers", () => {
|
|
|
367
375
|
|
|
368
376
|
expect(isSnapshot(result)).toBe(true);
|
|
369
377
|
if (isSnapshot(result)) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
{ id: "post-1", title: "Hello World" },
|
|
375
|
-
{ id: "post-2", title: "Second Post" },
|
|
376
|
-
],
|
|
377
|
-
});
|
|
378
|
+
// Verify base fields and posts work with new resolver() API
|
|
379
|
+
expect(result.data).toHaveProperty("name", "Alice");
|
|
380
|
+
expect((result.data as any).posts).toHaveLength(2);
|
|
381
|
+
expect((result.data as any).posts[0]).toHaveProperty("title", "Hello World");
|
|
378
382
|
}
|
|
379
383
|
});
|
|
380
384
|
|
|
@@ -392,25 +396,32 @@ describe("E2E - Entity Resolvers", () => {
|
|
|
392
396
|
{ id: "post-2", title: "Post 2", authorId: "user-2" },
|
|
393
397
|
];
|
|
394
398
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
399
|
+
// Define User model
|
|
400
|
+
const UserBatched = model("UserBatched", {
|
|
401
|
+
id: id(),
|
|
402
|
+
name: string(),
|
|
403
|
+
});
|
|
398
404
|
|
|
399
|
-
//
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
posts:
|
|
405
|
+
// Define resolver with posts relation (new API)
|
|
406
|
+
const userResolver = resolver(UserBatched, (t) => ({
|
|
407
|
+
id: t.expose("id"),
|
|
408
|
+
name: t.expose("name"),
|
|
409
|
+
// Plain function for relations
|
|
410
|
+
posts: ({ source }) => {
|
|
405
411
|
batchCallCount++;
|
|
406
|
-
return posts.filter((p) => p.authorId ===
|
|
407
|
-
}
|
|
412
|
+
return posts.filter((p) => p.authorId === source.id);
|
|
413
|
+
},
|
|
408
414
|
}));
|
|
409
415
|
|
|
416
|
+
const getUsers = query()
|
|
417
|
+
.returns([UserBatched])
|
|
418
|
+
.resolve(() => users);
|
|
419
|
+
|
|
410
420
|
const server = createApp({
|
|
411
|
-
entities: {
|
|
421
|
+
entities: { UserBatched, Post },
|
|
412
422
|
queries: { getUsers },
|
|
413
423
|
resolvers: [userResolver],
|
|
424
|
+
context: () => ({}),
|
|
414
425
|
});
|
|
415
426
|
|
|
416
427
|
// Execute query with nested selection for all users
|
|
@@ -432,9 +443,12 @@ describe("E2E - Entity Resolvers", () => {
|
|
|
432
443
|
|
|
433
444
|
expect(isSnapshot(result)).toBe(true);
|
|
434
445
|
if (isSnapshot(result)) {
|
|
435
|
-
//
|
|
446
|
+
// Should batch calls - with DataLoader we get 2 calls (one per user)
|
|
436
447
|
expect(batchCallCount).toBeGreaterThanOrEqual(2);
|
|
437
448
|
expect(result.data).toHaveLength(2);
|
|
449
|
+
// Verify posts are resolved
|
|
450
|
+
expect((result.data as any)[0].posts).toHaveLength(1);
|
|
451
|
+
expect((result.data as any)[1].posts).toHaveLength(1);
|
|
438
452
|
}
|
|
439
453
|
});
|
|
440
454
|
});
|
|
@@ -448,12 +462,12 @@ describe("E2E - Metadata", () => {
|
|
|
448
462
|
const getUser = query()
|
|
449
463
|
.input(z.object({ id: z.string() }))
|
|
450
464
|
.returns(User)
|
|
451
|
-
.resolve(({
|
|
465
|
+
.resolve(({ args }) => mockUsers.find((u) => u.id === args.id)!);
|
|
452
466
|
|
|
453
467
|
const createUser = mutation()
|
|
454
468
|
.input(z.object({ name: z.string() }))
|
|
455
469
|
.returns(User)
|
|
456
|
-
.resolve(({
|
|
470
|
+
.resolve(({ args }) => ({ id: "new", name: args.name, email: "", status: "" }));
|
|
457
471
|
|
|
458
472
|
const server = createApp({
|
|
459
473
|
entities: { User },
|
|
@@ -477,7 +491,7 @@ describe("E2E - Metadata", () => {
|
|
|
477
491
|
const updateUser = mutation()
|
|
478
492
|
.input(z.object({ id: z.string(), name: z.string() }))
|
|
479
493
|
.returns(User)
|
|
480
|
-
.resolve(({
|
|
494
|
+
.resolve(({ args }) => ({ ...mockUsers[0], name: args.name }));
|
|
481
495
|
|
|
482
496
|
const deleteUser = mutation()
|
|
483
497
|
.input(z.object({ id: z.string() }))
|
|
@@ -196,45 +196,78 @@ export function handleWebSSE(
|
|
|
196
196
|
signal?: AbortSignal,
|
|
197
197
|
): Response {
|
|
198
198
|
const inputParam = url.searchParams.get("input");
|
|
199
|
-
|
|
199
|
+
|
|
200
|
+
// Parse input with error handling
|
|
201
|
+
let input: unknown;
|
|
202
|
+
if (inputParam) {
|
|
203
|
+
try {
|
|
204
|
+
input = JSON.parse(inputParam);
|
|
205
|
+
} catch (parseError) {
|
|
206
|
+
// Return error response for malformed JSON
|
|
207
|
+
const encoder = new TextEncoder();
|
|
208
|
+
const errorStream = new ReadableStream({
|
|
209
|
+
start(controller) {
|
|
210
|
+
const errMsg = parseError instanceof Error ? parseError.message : "Invalid JSON";
|
|
211
|
+
const data = `event: error\ndata: ${JSON.stringify({ error: `Invalid input JSON: ${errMsg}` })}\n\n`;
|
|
212
|
+
controller.enqueue(encoder.encode(data));
|
|
213
|
+
controller.close();
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
return new Response(errorStream, {
|
|
217
|
+
headers: {
|
|
218
|
+
"Content-Type": "text/event-stream",
|
|
219
|
+
"Cache-Control": "no-cache",
|
|
220
|
+
Connection: "keep-alive",
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
200
225
|
|
|
201
226
|
const stream = new ReadableStream({
|
|
202
227
|
start(controller) {
|
|
203
228
|
const encoder = new TextEncoder();
|
|
204
229
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
// Clean up on abort
|
|
232
|
-
if (signal) {
|
|
233
|
-
signal.addEventListener("abort", () => {
|
|
234
|
-
subscription.unsubscribe();
|
|
235
|
-
controller.close();
|
|
230
|
+
try {
|
|
231
|
+
const result = server.execute({ path, input });
|
|
232
|
+
|
|
233
|
+
if (result && typeof result === "object" && "subscribe" in result) {
|
|
234
|
+
const observable = result as {
|
|
235
|
+
subscribe: (handlers: {
|
|
236
|
+
next: (value: { data?: unknown }) => void;
|
|
237
|
+
error: (err: Error) => void;
|
|
238
|
+
complete: () => void;
|
|
239
|
+
}) => { unsubscribe: () => void };
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const subscription = observable.subscribe({
|
|
243
|
+
next: (value) => {
|
|
244
|
+
const data = `data: ${JSON.stringify(value.data)}\n\n`;
|
|
245
|
+
controller.enqueue(encoder.encode(data));
|
|
246
|
+
},
|
|
247
|
+
error: (err) => {
|
|
248
|
+
const data = `event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`;
|
|
249
|
+
controller.enqueue(encoder.encode(data));
|
|
250
|
+
controller.close();
|
|
251
|
+
},
|
|
252
|
+
complete: () => {
|
|
253
|
+
controller.close();
|
|
254
|
+
},
|
|
236
255
|
});
|
|
256
|
+
|
|
257
|
+
// Clean up on abort
|
|
258
|
+
if (signal) {
|
|
259
|
+
signal.addEventListener("abort", () => {
|
|
260
|
+
subscription.unsubscribe();
|
|
261
|
+
controller.close();
|
|
262
|
+
});
|
|
263
|
+
}
|
|
237
264
|
}
|
|
265
|
+
} catch (execError) {
|
|
266
|
+
// Handle synchronous errors from server.execute() or subscribe()
|
|
267
|
+
const errMsg = execError instanceof Error ? execError.message : "Internal error";
|
|
268
|
+
const data = `event: error\ndata: ${JSON.stringify({ error: errMsg })}\n\n`;
|
|
269
|
+
controller.enqueue(encoder.encode(data));
|
|
270
|
+
controller.close();
|
|
238
271
|
}
|
|
239
272
|
},
|
|
240
273
|
});
|
|
@@ -14,16 +14,16 @@ import { createHTTPHandler } from "./http.js";
|
|
|
14
14
|
|
|
15
15
|
const getUser = query()
|
|
16
16
|
.input(z.object({ id: z.string() }))
|
|
17
|
-
.resolve(({
|
|
18
|
-
id:
|
|
17
|
+
.resolve(({ args }) => ({
|
|
18
|
+
id: args.id,
|
|
19
19
|
name: "Test User",
|
|
20
20
|
}));
|
|
21
21
|
|
|
22
22
|
const createUser = mutation()
|
|
23
23
|
.input(z.object({ name: z.string() }))
|
|
24
|
-
.resolve(({
|
|
24
|
+
.resolve(({ args }) => ({
|
|
25
25
|
id: "new-id",
|
|
26
|
-
name:
|
|
26
|
+
name: args.name,
|
|
27
27
|
}));
|
|
28
28
|
|
|
29
29
|
// =============================================================================
|
|
@@ -74,7 +74,7 @@ describe("createHTTPHandler", () => {
|
|
|
74
74
|
method: "POST",
|
|
75
75
|
headers: { "Content-Type": "application/json" },
|
|
76
76
|
body: JSON.stringify({
|
|
77
|
-
|
|
77
|
+
path: "getUser",
|
|
78
78
|
input: { id: "123" },
|
|
79
79
|
}),
|
|
80
80
|
});
|
|
@@ -97,7 +97,7 @@ describe("createHTTPHandler", () => {
|
|
|
97
97
|
method: "POST",
|
|
98
98
|
headers: { "Content-Type": "application/json" },
|
|
99
99
|
body: JSON.stringify({
|
|
100
|
-
|
|
100
|
+
path: "createUser",
|
|
101
101
|
input: { name: "New User" },
|
|
102
102
|
}),
|
|
103
103
|
});
|
|
@@ -128,7 +128,7 @@ describe("createHTTPHandler", () => {
|
|
|
128
128
|
method: "POST",
|
|
129
129
|
headers: { "Content-Type": "application/json" },
|
|
130
130
|
body: JSON.stringify({
|
|
131
|
-
|
|
131
|
+
path: "getUser",
|
|
132
132
|
input: { id: "456" },
|
|
133
133
|
}),
|
|
134
134
|
});
|
|
@@ -234,7 +234,7 @@ describe("createHTTPHandler", () => {
|
|
|
234
234
|
method: "POST",
|
|
235
235
|
headers: { "Content-Type": "application/json" },
|
|
236
236
|
body: JSON.stringify({
|
|
237
|
-
|
|
237
|
+
path: "unknownOperation",
|
|
238
238
|
input: {},
|
|
239
239
|
}),
|
|
240
240
|
});
|
package/src/handlers/http.ts
CHANGED
|
@@ -314,7 +314,7 @@ export function createHTTPHandler(
|
|
|
314
314
|
(pathname === operationPath || pathname === `${pathPrefix}/`)
|
|
315
315
|
) {
|
|
316
316
|
// Parse JSON body with proper error handling
|
|
317
|
-
let body: {
|
|
317
|
+
let body: { path?: string; input?: unknown };
|
|
318
318
|
try {
|
|
319
319
|
body = (await request.json()) as typeof body;
|
|
320
320
|
} catch {
|
|
@@ -328,9 +328,7 @@ export function createHTTPHandler(
|
|
|
328
328
|
}
|
|
329
329
|
|
|
330
330
|
try {
|
|
331
|
-
|
|
332
|
-
const operationPath = body.operation ?? body.path;
|
|
333
|
-
if (!operationPath) {
|
|
331
|
+
if (!body.path) {
|
|
334
332
|
return new Response(JSON.stringify({ error: "Missing operation path" }), {
|
|
335
333
|
status: 400,
|
|
336
334
|
headers: {
|
|
@@ -342,7 +340,7 @@ export function createHTTPHandler(
|
|
|
342
340
|
|
|
343
341
|
const result = await firstValueFrom(
|
|
344
342
|
server.execute({
|
|
345
|
-
path:
|
|
343
|
+
path: body.path,
|
|
346
344
|
input: body.input,
|
|
347
345
|
}),
|
|
348
346
|
);
|
package/src/handlers/ws-types.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface WSHandlerOptions {
|
|
|
16
16
|
* Logger for debugging.
|
|
17
17
|
*/
|
|
18
18
|
logger?: {
|
|
19
|
+
debug?: (message: string, ...args: unknown[]) => void;
|
|
19
20
|
info?: (message: string, ...args: unknown[]) => void;
|
|
20
21
|
warn?: (message: string, ...args: unknown[]) => void;
|
|
21
22
|
error?: (message: string, ...args: unknown[]) => void;
|
package/src/handlers/ws.test.ts
CHANGED
|
@@ -56,8 +56,8 @@ function wait(ms = 10): Promise<void> {
|
|
|
56
56
|
|
|
57
57
|
const getUser = query()
|
|
58
58
|
.input(z.object({ id: z.string() }))
|
|
59
|
-
.resolve(({
|
|
60
|
-
id:
|
|
59
|
+
.resolve(({ args }) => ({
|
|
60
|
+
id: args.id,
|
|
61
61
|
name: "Test User",
|
|
62
62
|
__typename: "User",
|
|
63
63
|
}));
|
|
@@ -69,16 +69,16 @@ const listUsers = query().resolve(() => [
|
|
|
69
69
|
|
|
70
70
|
const createUser = mutation()
|
|
71
71
|
.input(z.object({ name: z.string() }))
|
|
72
|
-
.resolve(({
|
|
72
|
+
.resolve(({ args }) => ({
|
|
73
73
|
id: "new-id",
|
|
74
|
-
name:
|
|
74
|
+
name: args.name,
|
|
75
75
|
__typename: "User",
|
|
76
76
|
}));
|
|
77
77
|
|
|
78
78
|
const slowQuery = query()
|
|
79
79
|
.input(z.object({ delay: z.number() }))
|
|
80
|
-
.resolve(async ({
|
|
81
|
-
await new Promise((r) => setTimeout(r,
|
|
80
|
+
.resolve(async ({ args }) => {
|
|
81
|
+
await new Promise((r) => setTimeout(r, args.delay));
|
|
82
82
|
return { done: true };
|
|
83
83
|
});
|
|
84
84
|
|
package/src/handlers/ws.ts
CHANGED
|
@@ -172,8 +172,13 @@ export function createWSHandler(server: LensServer, options: WSHandlerOptions =
|
|
|
172
172
|
error: { code, message },
|
|
173
173
|
}),
|
|
174
174
|
);
|
|
175
|
-
} catch {
|
|
176
|
-
//
|
|
175
|
+
} catch (sendError) {
|
|
176
|
+
// Log actual error - don't silently swallow
|
|
177
|
+
// Common case is "connection already closed" but could be serialization failure
|
|
178
|
+
logger.debug?.(
|
|
179
|
+
`Failed to send error to client ${conn.id}:`,
|
|
180
|
+
sendError instanceof Error ? sendError.message : String(sendError),
|
|
181
|
+
);
|
|
177
182
|
}
|
|
178
183
|
}
|
|
179
184
|
|
|
@@ -513,7 +518,13 @@ export function createWSHandler(server: LensServer, options: WSHandlerOptions =
|
|
|
513
518
|
|
|
514
519
|
// Notify plugins of field updates
|
|
515
520
|
for (const entityKey of sub.entityKeys) {
|
|
516
|
-
const
|
|
521
|
+
const parts = entityKey.split(":");
|
|
522
|
+
// Validate entityKey format (must be "Entity:id")
|
|
523
|
+
if (parts.length < 2) {
|
|
524
|
+
logger.warn?.(`Invalid entityKey format: "${entityKey}" (expected "Entity:id")`);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const [entity, entityId] = parts;
|
|
517
528
|
await pluginManager.runOnUpdateFields({
|
|
518
529
|
clientId: conn.id,
|
|
519
530
|
subscriptionId: sub.id,
|
package/src/index.ts
CHANGED
package/src/plugin/optimistic.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* .input(z.object({ id: z.string(), name: z.string() }))
|
|
18
18
|
* .returns(User)
|
|
19
19
|
* .optimistic('merge') // ✅ Type-safe
|
|
20
|
-
* .resolve(({
|
|
20
|
+
* .resolve(({ args }) => db.user.update(args));
|
|
21
21
|
*
|
|
22
22
|
* const server = createApp({ router, plugins });
|
|
23
23
|
* ```
|
|
@@ -151,7 +151,7 @@ interface ReifyPipeline {
|
|
|
151
151
|
* Sugar syntax:
|
|
152
152
|
* - "merge" → entity.update with input fields
|
|
153
153
|
* - "create" → entity.create from output
|
|
154
|
-
* - "delete" → entity.delete by
|
|
154
|
+
* - "delete" → entity.delete by args.id
|
|
155
155
|
*
|
|
156
156
|
* Returns the original value if already a Pipeline.
|
|
157
157
|
*
|
|
@@ -176,7 +176,7 @@ function sugarToPipeline(
|
|
|
176
176
|
|
|
177
177
|
switch (sugar) {
|
|
178
178
|
case "merge": {
|
|
179
|
-
// entity.update('Entity', { id:
|
|
179
|
+
// entity.update('Entity', { id: args.id, ...fields })
|
|
180
180
|
const updateData: Record<string, unknown> = {
|
|
181
181
|
type: entity,
|
|
182
182
|
id: $input("id"),
|
|
@@ -217,14 +217,14 @@ function sugarToPipeline(
|
|
|
217
217
|
return pipeline as unknown as Pipeline;
|
|
218
218
|
}
|
|
219
219
|
case "delete": {
|
|
220
|
-
// entity.delete('Entity', { id:
|
|
220
|
+
// entity.delete('Entity', { id: args.id })
|
|
221
221
|
const pipeline: ReifyPipeline = {
|
|
222
222
|
$pipe: [
|
|
223
223
|
{
|
|
224
224
|
$do: "entity.delete",
|
|
225
225
|
$with: {
|
|
226
226
|
type: entity,
|
|
227
|
-
id: { id: $input("id") }
|
|
227
|
+
id: $input("id"), // Fixed: was incorrectly nested as { id: $input("id") }
|
|
228
228
|
},
|
|
229
229
|
$as: "result",
|
|
230
230
|
},
|
|
@@ -298,7 +298,7 @@ export type OptimisticPlugin = OptimisticPluginMarker & ServerPlugin;
|
|
|
298
298
|
* .input(z.object({ id: z.string(), name: z.string() }))
|
|
299
299
|
* .returns(User)
|
|
300
300
|
* .optimistic('merge')
|
|
301
|
-
* .resolve(({
|
|
301
|
+
* .resolve(({ args }) => db.user.update(args));
|
|
302
302
|
*
|
|
303
303
|
* const server = createApp({ router, plugins });
|
|
304
304
|
* ```
|
|
@@ -254,6 +254,9 @@ export class OperationLog {
|
|
|
254
254
|
|
|
255
255
|
/**
|
|
256
256
|
* Remove oldest entry and update tracking.
|
|
257
|
+
*
|
|
258
|
+
* IMPORTANT: After entries.shift(), ALL indices must be decremented by 1
|
|
259
|
+
* to maintain correctness of entityIndex lookups.
|
|
257
260
|
*/
|
|
258
261
|
private removeOldest(): void {
|
|
259
262
|
const removed = this.entries.shift();
|
|
@@ -261,21 +264,29 @@ export class OperationLog {
|
|
|
261
264
|
|
|
262
265
|
this.totalMemory -= removed.patchSize;
|
|
263
266
|
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
267
|
+
// CRITICAL FIX: Decrement ALL indices for ALL entities since array shifted
|
|
268
|
+
// Must happen BEFORE any index lookups to ensure correctness
|
|
269
|
+
for (const indices of this.entityIndex.values()) {
|
|
270
|
+
for (let i = 0; i < indices.length; i++) {
|
|
271
|
+
indices[i]--;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Update oldest version for the removed entity
|
|
276
|
+
const removedEntityIndices = this.entityIndex.get(removed.entityKey);
|
|
277
|
+
if (removedEntityIndices && removedEntityIndices.length > 0) {
|
|
278
|
+
// Remove first index (oldest) for this entity
|
|
279
|
+
// After decrement above, this index is now -1, so shift() removes it
|
|
280
|
+
removedEntityIndices.shift();
|
|
269
281
|
|
|
270
|
-
if (
|
|
282
|
+
if (removedEntityIndices.length === 0) {
|
|
271
283
|
// No more entries for this entity
|
|
272
284
|
this.entityIndex.delete(removed.entityKey);
|
|
273
285
|
this.oldestVersionIndex.delete(removed.entityKey);
|
|
274
286
|
this.newestVersionIndex.delete(removed.entityKey);
|
|
275
287
|
} else {
|
|
276
|
-
// Update oldest version to next entry
|
|
277
|
-
|
|
278
|
-
const nextEntry = this.entries[indices[0] - 1]; // -1 because we shifted
|
|
288
|
+
// Update oldest version to next entry (indices already corrected)
|
|
289
|
+
const nextEntry = this.entries[removedEntityIndices[0]];
|
|
279
290
|
if (nextEntry) {
|
|
280
291
|
this.oldestVersionIndex.set(removed.entityKey, nextEntry.version);
|
|
281
292
|
}
|