@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/lens-server",
3
- "version": "3.0.1",
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": "^3.0.1"
33
+ "@sylphx/lens-core": "^4.0.1"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "^5.9.3",
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, it } from "bun:test";
11
- import { entity, firstValueFrom, isError, isSnapshot, lens, mutation, query, t } from "@sylphx/lens-core";
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 = entity("User", {
22
- id: t.id(),
23
- name: t.string(),
24
- email: t.string(),
25
- status: t.string(),
21
+ const User = model("User", {
22
+ id: id(),
23
+ name: string(),
24
+ email: string(),
25
+ status: string(),
26
26
  });
27
27
 
28
- const Post = entity("Post", {
29
- id: t.id(),
30
- title: t.string(),
31
- content: t.string(),
32
- authorId: t.string(),
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(({ input }) => {
74
- const user = mockUsers.find((u) => u.id === input.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(({ input }) => ({
101
+ .resolve(({ args }) => ({
102
102
  id: "user-new",
103
- name: input.name,
104
- email: input.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(({ input }) => {
246
- const user = mockUsers.find((u) => u.id === input.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(({ input }) => mockUsers.find((u) => u.id === input.id)!);
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(User)
329
- .resolve(({ input }) => {
330
- const user = users.find((u) => u.id === input.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: { User, Post },
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
- expect(result.data).toMatchObject({
371
- id: "user-1",
372
- name: "Alice",
373
- posts: [
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
- const getUsers = query()
396
- .returns([User])
397
- .resolve(() => users);
399
+ // Define User model
400
+ const UserBatched = model("UserBatched", {
401
+ id: id(),
402
+ name: string(),
403
+ });
398
404
 
399
- // Create entity resolvers using lens() factory
400
- const { resolver } = lens();
401
- const userResolver = resolver(User, (f) => ({
402
- id: f.expose("id"),
403
- name: f.expose("name"),
404
- posts: f.many(Post).resolve(({ parent }) => {
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 === parent.id);
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: { User, Post },
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
- // Resolvers are called - exact count depends on DataLoader batching behavior
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(({ input }) => mockUsers.find((u) => u.id === input.id)!);
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(({ input }) => ({ id: "new", name: input.name, email: "", status: "" }));
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(({ input }) => ({ ...mockUsers[0], name: input.name }));
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
- const input = inputParam ? JSON.parse(inputParam) : undefined;
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
- const result = server.execute({ path, input });
206
-
207
- if (result && typeof result === "object" && "subscribe" in result) {
208
- const observable = result as {
209
- subscribe: (handlers: {
210
- next: (value: { data?: unknown }) => void;
211
- error: (err: Error) => void;
212
- complete: () => void;
213
- }) => { unsubscribe: () => void };
214
- };
215
-
216
- const subscription = observable.subscribe({
217
- next: (value) => {
218
- const data = `data: ${JSON.stringify(value.data)}\n\n`;
219
- controller.enqueue(encoder.encode(data));
220
- },
221
- error: (err) => {
222
- const data = `event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`;
223
- controller.enqueue(encoder.encode(data));
224
- controller.close();
225
- },
226
- complete: () => {
227
- controller.close();
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(({ input }) => ({
18
- id: input.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(({ input }) => ({
24
+ .resolve(({ args }) => ({
25
25
  id: "new-id",
26
- name: input.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
- operation: "getUser",
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
- operation: "createUser",
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
- operation: "getUser",
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
- operation: "unknownOperation",
237
+ path: "unknownOperation",
238
238
  input: {},
239
239
  }),
240
240
  });
@@ -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: { operation?: string; path?: string; input?: unknown };
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
- // Support both 'operation' and 'path' for backwards compatibility
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: operationPath,
343
+ path: body.path,
346
344
  input: body.input,
347
345
  }),
348
346
  );
@@ -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;
@@ -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(({ input }) => ({
60
- id: input.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(({ input }) => ({
72
+ .resolve(({ args }) => ({
73
73
  id: "new-id",
74
- name: input.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 ({ input }) => {
81
- await new Promise((r) => setTimeout(r, input.delay));
80
+ .resolve(async ({ args }) => {
81
+ await new Promise((r) => setTimeout(r, args.delay));
82
82
  return { done: true };
83
83
  });
84
84
 
@@ -172,8 +172,13 @@ export function createWSHandler(server: LensServer, options: WSHandlerOptions =
172
172
  error: { code, message },
173
173
  }),
174
174
  );
175
- } catch {
176
- // Connection may already be closed
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 [entity, entityId] = entityKey.split(":");
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
@@ -45,8 +45,6 @@ export {
45
45
  type QueryDef,
46
46
  // Operations
47
47
  query,
48
- type ResolverContext,
49
- type ResolverFn,
50
48
  type RouterDef,
51
49
  type RouterRoutes,
52
50
  router,
@@ -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(({ input }) => db.user.update(input));
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 input.id
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: input.id, ...fields })
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: input.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(({ input }) => db.user.update(input));
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
- // Update oldest version for entity
265
- const indices = this.entityIndex.get(removed.entityKey);
266
- if (indices && indices.length > 0) {
267
- // Remove first index (oldest)
268
- indices.shift();
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 (indices.length === 0) {
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
- // Note: indices are now stale (off by 1) until rebuildIndices
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
  }