| author | |
| committer | |
| log | 8a1fc4149ae7b41e7d3d93f1b51dd9aeb38c4282 |
| tree | 1b6f52ae364884d1a29983fb544604d6a1623c78 |
| parent | 12a4c057f3d6880c611630ccdc0c8c7f2cd23f72 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 121 insertions(+), 31 deletions(-)
apps/mobile/src/connection/environment-cache-store.test.ts+72-3| ... | ... | @@ -25,7 +25,7 @@ import * as Option from "effect/Option"; |
| 25 | 25 | |
| 26 | 26 | import * as MobileDatabase from "../persistence/mobile-database"; |
| 27 | 27 | import { make } from "./environment-cache-store"; |
| 28 | import { encodeStoredShellSnapshot } from "./shell-cache-encoding"; | |
| 28 | import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding"; | |
| 29 | 29 | |
| 30 | 30 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 31 | 31 | const PROJECT_ID = ProjectId.make("project-1"); |
| ... | ... | @@ -409,6 +409,9 @@ describe("mobile SQLite environment cache store", () => { |
| 409 | 409 | }); |
| 410 | 410 | |
| 411 | 411 | describe("cooperative shell cache encoding", () => { |
| 412 | // A fresh encoder per call keeps these cases cold; the cache has its own cases below. | |
| 413 | const encodeStoredShellSnapshot = (stored: typeof StoredOrchestrationShellSnapshot.Type) => | |
| 414 | makeStoredShellSnapshotEncoder()(stored); | |
| 412 | 415 | const encodeOriginal = Schema.encodeEffect( |
| 413 | 416 | Schema.fromJsonString(StoredOrchestrationShellSnapshot), |
| 414 | 417 | ); |
| ... | ... | @@ -511,8 +514,10 @@ describe("cooperative shell cache encoding", () => { |
| 511 | 514 | ...stored, |
| 512 | 515 | snapshot: { |
| 513 | 516 | ...stored.snapshot, |
| 514 | threads: Array.from({ length: 65 }, () => stored.snapshot.threads[0]!), | |
| 515 | archivedThreads: Array.from({ length: 33 }, () => stored.snapshot.archivedThreads[0]!), | |
| 517 | threads: Array.from({ length: 65 }, () => ({ ...stored.snapshot.threads[0]! })), | |
| 518 | archivedThreads: Array.from({ length: 33 }, () => ({ | |
| 519 | ...stored.snapshot.archivedThreads[0]!, | |
| 520 | })), | |
| 516 | 521 | }, |
| 517 | 522 | }); |
| 518 | 523 | // Five row chunks (3 active + 2 archived): four yields between them, one before the envelope. |
| ... | ... | @@ -592,4 +597,68 @@ describe("cooperative shell cache encoding", () => { |
| 592 | 597 | } |
| 593 | 598 | }), |
| 594 | 599 | ); |
| 600 | ||
| 601 | it.effect("reuses encoded rows across saves without changing the payload", () => | |
| 602 | Effect.gen(function* () { | |
| 603 | const encode = makeStoredShellSnapshotEncoder(); | |
| 604 | expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored)); | |
| 605 | ||
| 606 | const setTimer = vi.spyOn(globalThis, "setTimeout"); | |
| 607 | try { | |
| 608 | // Same row references, new envelope: no row work, so no host yields. | |
| 609 | const resaved = { | |
| 610 | ...stored, | |
| 611 | snapshot: { ...stored.snapshot, snapshotSequence: 4 }, | |
| 612 | }; | |
| 613 | expect(yield* encode(resaved)).toBe(yield* encodeOriginal(resaved)); | |
| 614 | expect(setTimer).not.toHaveBeenCalled(); | |
| 615 | } finally { | |
| 616 | vi.restoreAllMocks(); | |
| 617 | } | |
| 618 | }), | |
| 619 | ); | |
| 620 | ||
| 621 | it.effect("encodes replaced, added, and moved-to-archive rows on a warm encoder", () => | |
| 622 | Effect.gen(function* () { | |
| 623 | const encode = makeStoredShellSnapshotEncoder(); | |
| 624 | yield* encode(stored); | |
| 625 | const [first, second, ...rest] = stored.snapshot.threads; | |
| 626 | const next = { | |
| 627 | ...stored, | |
| 628 | snapshot: { | |
| 629 | ...stored.snapshot, | |
| 630 | threads: [ | |
| 631 | { ...first!, title: "Renamed", itemCount: 9 }, | |
| 632 | ...rest, | |
| 633 | { ...first!, id: ThreadId.make("thread-new"), branch: " feature " }, | |
| 634 | ], | |
| 635 | archivedThreads: [ | |
| 636 | ...stored.snapshot.archivedThreads, | |
| 637 | { ...second!, archivedAt: DateTime.add(NOW, { hours: 1 }) }, | |
| 638 | ], | |
| 639 | }, | |
| 640 | }; | |
| 641 | const actual = yield* encode(next); | |
| 642 | expect(actual).toBe(yield* encodeOriginal(next)); | |
| 643 | const parsed = JSON.parse(actual); | |
| 644 | expect(parsed.snapshot.threads[0].title).toBe("Renamed"); | |
| 645 | expect(parsed.snapshot.threads.at(-1).branch).toBe("feature"); | |
| 646 | expect(parsed.snapshot.archivedThreads).toHaveLength(2); | |
| 647 | }), | |
| 648 | ); | |
| 649 | ||
| 650 | it.effect("does not cache rows from a failed encode", () => | |
| 651 | Effect.gen(function* () { | |
| 652 | const encode = makeStoredShellSnapshotEncoder(); | |
| 653 | const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 }; | |
| 654 | const invalid = { | |
| 655 | ...stored, | |
| 656 | snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] }, | |
| 657 | }; | |
| 658 | expect(yield* Effect.isFailure(encode(invalid))).toBe(true); | |
| 659 | expect(yield* Effect.isFailure(encode(invalid))).toBe(true); | |
| 660 | // Valid rows from the failed save still encode correctly afterwards. | |
| 661 | expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored)); | |
| 662 | }), | |
| 663 | ); | |
| 595 | 664 | }); |
apps/mobile/src/connection/environment-cache-store.ts+2-1| ... | ... | @@ -10,7 +10,7 @@ import * as Option from "effect/Option"; |
| 10 | 10 | import * as Schema from "effect/Schema"; |
| 11 | 11 | |
| 12 | 12 | import * as MobileDatabase from "../persistence/mobile-database"; |
| 13 | import { encodeStoredShellSnapshot } from "./shell-cache-encoding"; | |
| 13 | import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding"; | |
| 14 | 14 | import { |
| 15 | 15 | attachProjectFaviconDatabase, |
| 16 | 16 | projectFaviconDatabaseCache, |
| ... | ... | @@ -99,6 +99,7 @@ function loadDecodedCache<A, B>(input: { |
| 99 | 99 | export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { |
| 100 | 100 | const database = yield* MobileDatabase.MobileDatabase; |
| 101 | 101 | attachProjectFaviconDatabase(database); |
| 102 | const encodeStoredShellSnapshot = makeStoredShellSnapshotEncoder(); | |
| 102 | 103 | return Persistence.EnvironmentCacheStore.of({ |
| 103 | 104 | loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => |
| 104 | 105 | loadDecodedCache({ |
apps/mobile/src/connection/shell-cache-encoding.ts+47-27| ... | ... | @@ -30,33 +30,53 @@ const encodeEnvelope = Schema.encodeEffect( |
| 30 | 30 | ); |
| 31 | 31 | |
| 32 | 32 | /** |
| 33 | * Encode the shell cache payload exactly like `fromJsonString(StoredOrchestrationShellSnapshot)`, | |
| 34 | * yielding to the host between bounded chunks of thread rows. The final envelope encode and | |
| 35 | * JSON stringify still run synchronously over the whole payload. | |
| 33 | * Make an encoder for the shell cache payload that produces exactly what | |
| 34 | * `fromJsonString(StoredOrchestrationShellSnapshot)` would, yielding to the host | |
| 35 | * between bounded chunks of thread rows. The final envelope encode and JSON | |
| 36 | * stringify still run synchronously over the whole payload. | |
| 37 | * | |
| 38 | * Shell rows are immutable and the shell reducer keeps unchanged rows by | |
| 39 | * reference, so each encoder remembers the canonical encoding of rows it has | |
| 40 | * already encoded successfully and only runs the row codec for new rows. | |
| 36 | 41 | */ |
| 37 | export const encodeStoredShellSnapshot = Effect.fnUntraced(function* ( | |
| 38 | stored: typeof StoredOrchestrationShellSnapshot.Type, | |
| 39 | ) { | |
| 40 | let hasWorked = false; | |
| 41 | const yieldBetweenChunks = Effect.suspend(() => { | |
| 42 | if (hasWorked) return yieldToHost; | |
| 43 | hasWorked = true; | |
| 44 | return Effect.void; | |
| 45 | }); | |
| 46 | const encodeRows = (rows: ReadonlyArray<OrchestrationV2ThreadShellJson>) => | |
| 47 | Effect.gen(function* () { | |
| 48 | const encoded: Array<unknown> = []; | |
| 49 | for (let start = 0; start < rows.length; start += ROWS_PER_CHUNK) { | |
| 50 | yield* yieldBetweenChunks; | |
| 51 | const chunk = yield* encodeThreadChunk(rows.slice(start, start + ROWS_PER_CHUNK)); | |
| 52 | for (const row of chunk) encoded.push(row); | |
| 53 | } | |
| 54 | return encoded; | |
| 42 | export function makeStoredShellSnapshotEncoder() { | |
| 43 | const encodedRows = new WeakMap<OrchestrationV2ThreadShellJson, unknown>(); | |
| 44 | ||
| 45 | return Effect.fnUntraced(function* (stored: typeof StoredOrchestrationShellSnapshot.Type) { | |
| 46 | let hasWorked = false; | |
| 47 | const yieldBetweenChunks = Effect.suspend(() => { | |
| 48 | if (hasWorked) return yieldToHost; | |
| 49 | hasWorked = true; | |
| 50 | return Effect.void; | |
| 55 | 51 | }); |
| 52 | const encodeMisses = (misses: ReadonlyArray<OrchestrationV2ThreadShellJson>) => | |
| 53 | Effect.gen(function* () { | |
| 54 | yield* yieldBetweenChunks; | |
| 55 | const chunk = yield* encodeThreadChunk(misses); | |
| 56 | chunk.forEach((row, index) => encodedRows.set(misses[index]!, row)); | |
| 57 | }); | |
| 58 | const encodeRows = (rows: ReadonlyArray<OrchestrationV2ThreadShellJson>) => | |
| 59 | Effect.gen(function* () { | |
| 60 | let misses: Array<OrchestrationV2ThreadShellJson> = []; | |
| 61 | for (const row of rows) { | |
| 62 | if (encodedRows.has(row)) continue; | |
| 63 | misses.push(row); | |
| 64 | if (misses.length === ROWS_PER_CHUNK) { | |
| 65 | yield* encodeMisses(misses); | |
| 66 | misses = []; | |
| 67 | } | |
| 68 | } | |
| 69 | if (misses.length > 0) yield* encodeMisses(misses); | |
| 70 | return rows.map((row) => encodedRows.get(row)); | |
| 71 | }); | |
| 56 | 72 | |
| 57 | const { snapshot } = stored; | |
| 58 | const threads = yield* encodeRows(snapshot.threads); | |
| 59 | const archivedThreads = yield* encodeRows(snapshot.archivedThreads); | |
| 60 | yield* yieldBetweenChunks; | |
| 61 | return yield* encodeEnvelope({ ...stored, snapshot: { ...snapshot, threads, archivedThreads } }); | |
| 62 | }); | |
| 73 | const { snapshot } = stored; | |
| 74 | const threads = yield* encodeRows(snapshot.threads); | |
| 75 | const archivedThreads = yield* encodeRows(snapshot.archivedThreads); | |
| 76 | if (hasWorked) yield* yieldToHost; | |
| 77 | return yield* encodeEnvelope({ | |
| 78 | ...stored, | |
| 79 | snapshot: { ...snapshot, threads, archivedThreads }, | |
| 80 | }); | |
| 81 | }); | |
| 82 | } |