1import { StoredOrchestrationShellSnapshot } from "@t3tools/client-runtime/platform";
2import { OrchestrationV2ThreadShellJson } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";
5
6const ROWS_PER_CHUNK = 32;
7
8// Effect's cooperative yield resumes through microtasks on React Native. A host
9// timer lets input and rendering run between chunks of shell rows instead.
10const yieldToHost = Effect.callback<void>((resume) => {
11 const timer = setTimeout(() => resume(Effect.void), 0);
12 return Effect.sync(() => clearTimeout(timer));
13});
14
15// Rows go through the canonical row codec in chunks. The envelope codec then
16// validates and encodes everything else, passing the encoded rows through as-is.
17const encodeThreadChunk = Schema.encodeEffect(Schema.Array(OrchestrationV2ThreadShellJson));
18const EncodedRows = Schema.Array(Schema.Unknown);
19const encodeEnvelope = Schema.encodeEffect(
20 Schema.fromJsonString(
21 StoredOrchestrationShellSnapshot.mapFields((fields) => ({
22 ...fields,
23 snapshot: fields.snapshot.mapFields((snapshotFields) => ({
24 ...snapshotFields,
25 threads: EncodedRows,
26 archivedThreads: EncodedRows,
27 })),
28 })),
29 ),
30);
31
32/**
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.
41 */
42export 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;
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 });
72
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}