| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment"; |
| 3 | import type { EnvironmentId, ProjectCloneSnapshot, ScopedProjectRef } from "@t3tools/contracts"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import { AsyncResult, Atom } from "effect/reactivity"; |
| 6 | |
| 7 | import { serverEnvironment } from "./server"; |
| 8 | import { sourceControlEnvironment } from "./sourceControl"; |
| 9 | |
| 10 | /** |
| 11 | * `"pending"` while the environment's clone stream has not delivered its |
| 12 | * first list yet: the draft opens right after a clone starts, and a caller |
| 13 | * that treated the gap as "no clone" would enable Start for a moment. |
| 14 | */ |
| 15 | export type ProjectCloneState = ProjectCloneSnapshot | "pending" | null; |
| 16 | |
| 17 | const EMPTY_CLONES: ReadonlyArray<ProjectCloneSnapshot> = []; |
| 18 | const EMPTY_CLONE_ATOM = Atom.make<ProjectCloneState>(null).pipe( |
| 19 | Atom.withLabel("mobile-project-clone:empty"), |
| 20 | ); |
| 21 | |
| 22 | /** |
| 23 | * Latest clone list an environment has streamed, `"pending"` until the first |
| 24 | * one, and never subscribed on servers that predate clone tracking. |
| 25 | */ |
| 26 | const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) => |
| 27 | Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> | "pending" => { |
| 28 | const config = get(serverEnvironment.configValueAtom(environmentId)); |
| 29 | if (config?.environment.capabilities.projectCloneTracking !== true) return EMPTY_CLONES; |
| 30 | const result = get(sourceControlEnvironment.projectClones({ environmentId, input: {} })); |
| 31 | // A failed subscription must not hold Start forever. Treating it as |
| 32 | // "no clone tracked" lets the server's own dispatch guard decide; the |
| 33 | // registry re-establishes the stream on reconnect. |
| 34 | if (result._tag === "Failure") return EMPTY_CLONES; |
| 35 | return Option.getOrElse(AsyncResult.value(result), () => "pending" as const); |
| 36 | }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)), |
| 37 | ); |
| 38 | |
| 39 | /** The environment's tracked clones, empty until the stream's first list. */ |
| 40 | export const environmentProjectCloneListAtom = Atom.family((environmentId: EnvironmentId) => |
| 41 | Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> => { |
| 42 | const clones = get(environmentProjectClonesAtom(environmentId)); |
| 43 | return clones === "pending" ? EMPTY_CLONES : clones; |
| 44 | }).pipe(Atom.withLabel(`mobile-project-clone-list:${environmentId}`)), |
| 45 | ); |
| 46 | |
| 47 | const projectCloneAtom = Atom.family((key: string) => { |
| 48 | const ref = parseScopedProjectKey(key); |
| 49 | return Atom.make((get): ProjectCloneState => { |
| 50 | if (ref === null) return null; |
| 51 | const clones = get(environmentProjectClonesAtom(ref.environmentId)); |
| 52 | if (clones === "pending") return "pending"; |
| 53 | return clones.find((clone) => clone.projectId === ref.projectId) ?? null; |
| 54 | }).pipe(Atom.withLabel(`mobile-project-clone:${key}`)); |
| 55 | }); |
| 56 | |
| 57 | /** |
| 58 | * The tracked clone for a project: `"pending"` before the stream's first |
| 59 | * list, null once it finished or never started. |
| 60 | */ |
| 61 | export function useProjectClone(ref: ScopedProjectRef | null): ProjectCloneState { |
| 62 | return useAtomValue(ref === null ? EMPTY_CLONE_ATOM : projectCloneAtom(scopedProjectKey(ref))); |
| 63 | } |