| ... | ... | @@ -388,16 +388,105 @@ describe("cooperative shell cache encoding", () => { |
| 388 | 388 | }), |
| 389 | 389 | ); |
| 390 | 390 | |
| 391 | | it.effect("still rejects invalid rows after an encoding batch yields", () => |
| 391 | it.effect("preserves optional fields and dates across chunk boundaries and archived rows", () => |
| 392 | 392 | Effect.gen(function* () { |
| 393 | | const invalid = { |
| 393 | const base = SHELL_SNAPSHOT.threads[0]!; |
| 394 | const { titleRegeneration: _, unsettledAt: __, ...withoutOptional } = base; |
| 395 | const row = (index: number) => |
| 396 | index % 3 === 0 |
| 397 | ? { ...withoutOptional, id: ThreadId.make(`row-${index}`) } |
| 398 | : { |
| 399 | ...base, |
| 400 | id: ThreadId.make(`row-${index}`), |
| 401 | snoozedUntil: DateTime.add(NOW, { minutes: index }), |
| 402 | pinnedAt: index % 2 === 0 ? null : NOW, |
| 403 | }; |
| 404 | const mixed = { |
| 394 | 405 | ...stored, |
| 395 | 406 | snapshot: { |
| 396 | 407 | ...stored.snapshot, |
| 397 | | threads: [...stored.snapshot.threads, { ...stored.snapshot.threads[0]!, itemCount: -1 }], |
| 408 | threads: Array.from({ length: 65 }, (_, index) => row(index)), |
| 409 | archivedThreads: Array.from({ length: 33 }, (_, index) => ({ |
| 410 | ...row(index + 100), |
| 411 | archivedAt: DateTime.add(NOW, { hours: index }), |
| 412 | })), |
| 398 | 413 | }, |
| 399 | 414 | }; |
| 400 | | expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalid))).toBe(true); |
| 415 | const actual = yield* encodeStoredShellSnapshot(mixed); |
| 416 | expect(actual).toBe(yield* encodeOriginal(mixed)); |
| 417 | const parsed = JSON.parse(actual); |
| 418 | expect(parsed.snapshot.threads).toHaveLength(65); |
| 419 | expect(parsed.snapshot.threads[63]).not.toHaveProperty("titleRegeneration"); |
| 420 | expect(parsed.snapshot.threads[63]).not.toHaveProperty("unsettledAt"); |
| 421 | expect(parsed.snapshot.threads[64].snoozedUntil).toBe( |
| 422 | DateTime.formatIso(DateTime.add(NOW, { minutes: 64 })), |
| 423 | ); |
| 424 | expect(parsed.snapshot.archivedThreads).toHaveLength(33); |
| 425 | expect(parsed.snapshot.archivedThreads[32].archivedAt).toBe( |
| 426 | DateTime.formatIso(DateTime.add(NOW, { hours: 32 })), |
| 427 | ); |
| 428 | }), |
| 429 | ); |
| 430 | |
| 431 | it.effect("yields to a host timer between every 32-row chunk", () => |
| 432 | Effect.gen(function* () { |
| 433 | const setTimer = vi.spyOn(globalThis, "setTimeout"); |
| 434 | try { |
| 435 | yield* encodeStoredShellSnapshot({ |
| 436 | ...stored, |
| 437 | snapshot: { |
| 438 | ...stored.snapshot, |
| 439 | threads: Array.from({ length: 65 }, () => stored.snapshot.threads[0]!), |
| 440 | archivedThreads: Array.from({ length: 33 }, () => stored.snapshot.archivedThreads[0]!), |
| 441 | }, |
| 442 | }); |
| 443 | // Five row chunks (3 active + 2 archived): four yields between them, one before the envelope. |
| 444 | expect(setTimer).toHaveBeenCalledTimes(5); |
| 445 | } finally { |
| 446 | vi.restoreAllMocks(); |
| 447 | } |
| 448 | }), |
| 449 | ); |
| 450 | |
| 451 | it.effect("still rejects invalid active and archived rows after an encoding chunk yields", () => |
| 452 | Effect.gen(function* () { |
| 453 | const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 }; |
| 454 | const invalidActive = { |
| 455 | ...stored, |
| 456 | snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] }, |
| 457 | }; |
| 458 | const invalidArchived = { |
| 459 | ...stored, |
| 460 | snapshot: { ...stored.snapshot, archivedThreads: [invalidRow] }, |
| 461 | }; |
| 462 | const invalidEnvelope = { ...stored, snapshot: { ...stored.snapshot, snapshotSequence: -1 } }; |
| 463 | expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidActive))).toBe(true); |
| 464 | expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidArchived))).toBe(true); |
| 465 | expect(yield* Effect.isFailure(encodeOriginal(invalidEnvelope))).toBe(true); |
| 466 | expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidEnvelope))).toBe(true); |
| 467 | }), |
| 468 | ); |
| 469 | |
| 470 | it.effect("keeps concurrent saves independent", () => |
| 471 | Effect.gen(function* () { |
| 472 | const other = { |
| 473 | ...stored, |
| 474 | environmentId: EnvironmentId.make("environment-2"), |
| 475 | snapshot: { |
| 476 | ...stored.snapshot, |
| 477 | threads: stored.snapshot.threads.slice(0, 40).map((thread) => ({ |
| 478 | ...thread, |
| 479 | title: `Other ${thread.id}`, |
| 480 | })), |
| 481 | archivedThreads: [], |
| 482 | }, |
| 483 | }; |
| 484 | const [first, second] = yield* Effect.all( |
| 485 | [encodeStoredShellSnapshot(stored), encodeStoredShellSnapshot(other)], |
| 486 | { concurrency: "unbounded" }, |
| 487 | ); |
| 488 | expect(first).toBe(yield* encodeOriginal(stored)); |
| 489 | expect(second).toBe(yield* encodeOriginal(other)); |
| 401 | 490 | }), |
| 402 | 491 | ); |
| 403 | 492 | |