1import {
2 OrchestrationV2ShellSnapshot,
3 type ThreadId,
4 ThreadPullRequestLink,
5} from "@t3tools/contracts";
6import * as Effect from "effect/Effect";
7import * as Predicate from "effect/Predicate";
8import * as Schema from "effect/Schema";
9
10/**
11 * A shell snapshot whose threads may arrive before their pull request links are decoded. Those
12 * links are most of a large shell's decode cost and only feed PR badges, so the thread list
13 * can paint first. `loadPullRequests` then decodes the links by thread id; until it runs,
14 * those threads carry no `pullRequests`.
15 */
16export interface DeferredShellSnapshot extends OrchestrationV2ShellSnapshot {
17 readonly loadPullRequests?: Effect.Effect<
18 ReadonlyMap<ThreadId, ReadonlyArray<ThreadPullRequestLink>>
19 >;
20}
21
22// A zero sleep is a microtask yield, which on React Native runs before the next frame.
23// A 1ms sleep takes a host timer, so the rows can render before the deferred decode.
24const yieldToHost = Effect.sleep("1 millis");
25
26const decodeThreadLinks = Schema.decodeUnknownEffect(
27 Schema.UndefinedOr(Schema.toCodecJson(Schema.Array(ThreadPullRequestLink))),
28);
29// Bounds each uninterrupted stretch of decoding, so taps and frames run between batches.
30const LINKS_PER_BATCH = 400;
31
32/**
33 * Removes each parsed thread row's pull request links from `snapshot` in place and returns
34 * them in row order, for `deferPullRequests` once the rows are decoded.
35 */
36export function detachPullRequests(snapshot: unknown): ReadonlyArray<unknown> {
37 if (!Predicate.isObject(snapshot)) return [];
38 const rows: unknown = snapshot.threads;
39 if (!Array.isArray(rows)) return [];
40 return rows.map((row: unknown) => {
41 if (!Predicate.isObject(row)) return undefined;
42 const links = row.pullRequests;
43 delete row.pullRequests;
44 return links;
45 });
46}
47
48/**
49 * Attaches a `loadPullRequests` that decodes the detached links after yielding to the host.
50 * A thread whose links cannot be read gets none; its row stays usable.
51 */
52export function deferPullRequests<S extends OrchestrationV2ShellSnapshot>(
53 snapshot: S,
54 rawLinks: ReadonlyArray<unknown>,
55): S & Required<Pick<DeferredShellSnapshot, "loadPullRequests">> {
56 const threadIds = snapshot.threads.map((thread) => thread.id);
57 // Schema decoding runs when called, so each thread's decode is built after a yield.
58 const loadPullRequests = Effect.gen(function* () {
59 const linksByThreadId = new Map<ThreadId, ReadonlyArray<ThreadPullRequestLink>>();
60 let batchSize = LINKS_PER_BATCH;
61 for (const [index, threadId] of threadIds.entries()) {
62 const raw = rawLinks[index];
63 if (raw === undefined) continue;
64 const size = Array.isArray(raw) ? raw.length : 1;
65 if (batchSize + size > LINKS_PER_BATCH) {
66 yield* yieldToHost;
67 batchSize = 0;
68 }
69 batchSize += size;
70 // An unreadable thread keeps no links; the others still fill in.
71 const links = yield* decodeThreadLinks(raw).pipe(
72 Effect.catch((cause) =>
73 Effect.logWarning("Discarding unreadable shell pull request links.", {
74 threadId,
75 cause: String(cause),
76 }).pipe(Effect.as(undefined)),
77 ),
78 );
79 if (links !== undefined) linksByThreadId.set(threadId, links);
80 }
81 return linksByThreadId as ReadonlyMap<ThreadId, ReadonlyArray<ThreadPullRequestLink>>;
82 });
83 return { ...snapshot, loadPullRequests };
84}
85
86const decodeShellSnapshotJson = Schema.decodeUnknownEffect(
87 Schema.toCodecJson(OrchestrationV2ShellSnapshot),
88);
89
90/** Decodes a JSON shell snapshot with its threads' pull request links deferred. */
91export const decodeShellSnapshotDeferringPullRequests = Effect.fnUntraced(function* (
92 json: unknown,
93) {
94 const rawLinks = detachPullRequests(json);
95 const snapshot = yield* decodeShellSnapshotJson(json);
96 return deferPullRequests(snapshot, rawLinks);
97});