1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Ref from "effect/Ref";
6
7import { threadKey } from "./entities.ts";
8
9export type ThreadHistoryLoadEarlierResult =
10 | { readonly _tag: "loaded" }
11 | { readonly _tag: "noop" }
12 | { readonly _tag: "busy" }
13 | { readonly _tag: "error"; readonly message: string };
14
15export type ThreadHistoryHandler = {
16 readonly loadEarlier: (throughEntryId?: string) => Effect.Effect<ThreadHistoryLoadEarlierResult>;
17};
18
19/**
20 * Opaque registration token returned by `register`. Finalizers must pass the
21 * same token so an older fiber cannot delete a newer handler for the key.
22 */
23export type ThreadHistoryRegistration = {
24 readonly environmentId: EnvironmentId;
25 readonly threadId: ThreadId;
26 readonly handler: ThreadHistoryHandler;
27};
28
29/**
30 * Per-thread registration for progressive history loads. Thread state fibers
31 * register while active; mobile UI dispatches through `loadEarlier`.
32 */
33export class ThreadHistoryController extends Context.Service<
34 ThreadHistoryController,
35 {
36 readonly register: (
37 environmentId: EnvironmentId,
38 threadId: ThreadId,
39 handler: ThreadHistoryHandler,
40 ) => Effect.Effect<ThreadHistoryRegistration>;
41 readonly unregister: (registration: ThreadHistoryRegistration) => Effect.Effect<void>;
42 readonly loadEarlier: (
43 environmentId: EnvironmentId,
44 threadId: ThreadId,
45 throughEntryId?: string,
46 ) => Effect.Effect<ThreadHistoryLoadEarlierResult>;
47 }
48>()("@t3tools/client-runtime/state/threadHistoryController") {}
49
50export const layer: Layer.Layer<ThreadHistoryController> = Layer.effect(
51 ThreadHistoryController,
52 Effect.gen(function* () {
53 const handlers = yield* Ref.make(new Map<string, ThreadHistoryHandler>());
54 return ThreadHistoryController.of({
55 register: (environmentId, threadId, handler) =>
56 Ref.update(handlers, (current) => {
57 const next = new Map(current);
58 next.set(threadKey({ environmentId, threadId }), handler);
59 return next;
60 }).pipe(
61 Effect.as({
62 environmentId,
63 threadId,
64 handler,
65 } satisfies ThreadHistoryRegistration),
66 ),
67 unregister: (registration) =>
68 Ref.update(handlers, (current) => {
69 const key = threadKey({
70 environmentId: registration.environmentId,
71 threadId: registration.threadId,
72 });
73 // Only remove when this registration still owns the slot. An older
74 // fiber finalizer must not delete a newer handler for the same key.
75 if (current.get(key) !== registration.handler) {
76 return current;
77 }
78 const next = new Map(current);
79 next.delete(key);
80 return next;
81 }),
82 loadEarlier: (environmentId, threadId, throughEntryId) =>
83 Ref.get(handlers).pipe(
84 Effect.flatMap((current) => {
85 const handler = current.get(threadKey({ environmentId, threadId }));
86 if (handler === undefined) {
87 return Effect.succeed({ _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult);
88 }
89 return handler.loadEarlier(throughEntryId);
90 }),
91 ),
92 });
93 }),
94);