| 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,7 +25,7 @@ import * as Option from "effect/Option"; |
| 25 | 25 | ||
| 26 | import * as MobileDatabase from "../persistence/mobile-database"; | 26 | import * as MobileDatabase from "../persistence/mobile-database"; |
| 27 | import { make } from "./environment-cache-store"; | 27 | import { make } from "./environment-cache-store"; |
| 28 | import { encodeStoredShellSnapshot } from "./shell-cache-encoding"; | 28 | import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding"; |
| 29 | 29 | ||
| 30 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | 30 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 31 | const PROJECT_ID = ProjectId.make("project-1"); | 31 | const PROJECT_ID = ProjectId.make("project-1"); |
| ... | @@ -409,6 +409,9 @@ describe("mobile SQLite environment cache store", () => { | ... | @@ -409,6 +409,9 @@ describe("mobile SQLite environment cache store", () => { |
| 409 | }); | 409 | }); |
| 410 | 410 | ||
| 411 | describe("cooperative shell cache encoding", () => { | 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 | const encodeOriginal = Schema.encodeEffect( | 415 | const encodeOriginal = Schema.encodeEffect( |
| 413 | Schema.fromJsonString(StoredOrchestrationShellSnapshot), | 416 | Schema.fromJsonString(StoredOrchestrationShellSnapshot), |
| 414 | ); | 417 | ); |
| ... | @@ -511,8 +514,10 @@ describe("cooperative shell cache encoding", () => { | ... | @@ -511,8 +514,10 @@ describe("cooperative shell cache encoding", () => { |
| 511 | ...stored, | 514 | ...stored, |
| 512 | snapshot: { | 515 | snapshot: { |
| 513 | ...stored.snapshot, | 516 | ...stored.snapshot, |
| 514 | threads: Array.from({ length: 65 }, () => stored.snapshot.threads[0]!), | 517 | threads: Array.from({ length: 65 }, () => ({ ...stored.snapshot.threads[0]! })), |
| 515 | archivedThreads: Array.from({ length: 33 }, () => stored.snapshot.archivedThreads[0]!), | 518 | archivedThreads: Array.from({ length: 33 }, () => ({ |
| 519 | ...stored.snapshot.archivedThreads[0]!, | ||
| 520 | })), | ||
| 516 | }, | 521 | }, |
| 517 | }); | 522 | }); |
| 518 | // Five row chunks (3 active + 2 archived): four yields between them, one before the envelope. | 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,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,7 +10,7 @@ import * as Option from "effect/Option"; |
| 10 | import * as Schema from "effect/Schema"; | 10 | import * as Schema from "effect/Schema"; |
| 11 | 11 | ||
| 12 | import * as MobileDatabase from "../persistence/mobile-database"; | 12 | import * as MobileDatabase from "../persistence/mobile-database"; |
| 13 | import { encodeStoredShellSnapshot } from "./shell-cache-encoding"; | 13 | import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding"; |
| 14 | import { | 14 | import { |
| 15 | attachProjectFaviconDatabase, | 15 | attachProjectFaviconDatabase, |
| 16 | projectFaviconDatabaseCache, | 16 | projectFaviconDatabaseCache, |
| ... | @@ -99,6 +99,7 @@ function loadDecodedCache<A, B>(input: { | ... | @@ -99,6 +99,7 @@ function loadDecodedCache<A, B>(input: { |
| 99 | export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { | 99 | export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { |
| 100 | const database = yield* MobileDatabase.MobileDatabase; | 100 | const database = yield* MobileDatabase.MobileDatabase; |
| 101 | attachProjectFaviconDatabase(database); | 101 | attachProjectFaviconDatabase(database); |
| 102 | const encodeStoredShellSnapshot = makeStoredShellSnapshotEncoder(); | ||
| 102 | return Persistence.EnvironmentCacheStore.of({ | 103 | return Persistence.EnvironmentCacheStore.of({ |
| 103 | loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => | 104 | loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => |
| 104 | loadDecodedCache({ | 105 | loadDecodedCache({ |
apps/mobile/src/connection/shell-cache-encoding.ts+47-27| ... | @@ -30,33 +30,53 @@ const encodeEnvelope = Schema.encodeEffect( | ... | @@ -30,33 +30,53 @@ const encodeEnvelope = Schema.encodeEffect( |
| 30 | ); | 30 | ); |
| 31 | 31 | ||
| 32 | /** | 32 | /** |
| 33 | * Encode the shell cache payload exactly like `fromJsonString(StoredOrchestrationShellSnapshot)`, | 33 | * Make an encoder for the shell cache payload that produces exactly what |
| 34 | * yielding to the host between bounded chunks of thread rows. The final envelope encode and | 34 | * `fromJsonString(StoredOrchestrationShellSnapshot)` would, yielding to the host |
| 35 | * JSON stringify still run synchronously over the whole payload. | 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* ( | 42 | export function makeStoredShellSnapshotEncoder() { |
| 38 | stored: typeof StoredOrchestrationShellSnapshot.Type, | 43 | const encodedRows = new WeakMap<OrchestrationV2ThreadShellJson, unknown>(); |
| 39 | ) { | 44 | |
| 40 | let hasWorked = false; | 45 | return Effect.fnUntraced(function* (stored: typeof StoredOrchestrationShellSnapshot.Type) { |
| 41 | const yieldBetweenChunks = Effect.suspend(() => { | 46 | let hasWorked = false; |
| 42 | if (hasWorked) return yieldToHost; | 47 | const yieldBetweenChunks = Effect.suspend(() => { |
| 43 | hasWorked = true; | 48 | if (hasWorked) return yieldToHost; |
| 44 | return Effect.void; | 49 | hasWorked = true; |
| 45 | }); | 50 | return Effect.void; |
| 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; | ||
| 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; | 73 | const { snapshot } = stored; |
| 58 | const threads = yield* encodeRows(snapshot.threads); | 74 | const threads = yield* encodeRows(snapshot.threads); |
| 59 | const archivedThreads = yield* encodeRows(snapshot.archivedThreads); | 75 | const archivedThreads = yield* encodeRows(snapshot.archivedThreads); |
| 60 | yield* yieldBetweenChunks; | 76 | if (hasWorked) yield* yieldToHost; |
| 61 | return yield* encodeEnvelope({ ...stored, snapshot: { ...snapshot, threads, archivedThreads } }); | 77 | return yield* encodeEnvelope({ |
| 62 | }); | 78 | ...stored, |
| 79 | snapshot: { ...snapshot, threads, archivedThreads }, | ||
| 80 | }); | ||
| 81 | }); | ||
| 82 | } |