Skip to content
File

Blob: src/client/lib/active-page-model.ts

typescript104 lines
1import { SESSION_MODES, type SessionMode } from "@/client/lib/constants";
2import type { FailureKind } from "@/client/lib/classify-failure";
3import type { PageAncestor, PageKind, WorkspaceRole } from "@/shared/types";
4import type { PageAccessLevel } from "@/shared/entitlements";
5import type { PageAccessMode, WorkspaceAccessMode } from "@/client/stores/workspace-replica";
6 
7/** Which surface the active-page model is running under. Canonical pages live
8 * inside a resolved workspace; share-token pages do not. */
9export type ActivePageSurface = "canonical" | "shared";
10 
11export type ActivePageBacking = "live" | "cache";
12 
13export interface ActivePageSnapshot {
14 id: string;
15 workspaceId: string;
16 kind: PageKind;
17 title: string;
18 icon: string | null;
19 coverUrl: string | null;
20}
21 
22export interface ActivePageAccess {
23 mode: Exclude<PageAccessLevel, "none">;
24}
25 
26export interface ActivePageInitialSnapshot {
27 snapshot: ActivePageSnapshot;
28 access: ActivePageAccess;
29}
30 
31export interface ActivePagePatch {
32 title?: string;
33 icon?: string | null;
34 coverUrl?: string | null;
35}
36 
37/** Why the page is currently unavailable. */
38export type UnavailableReason = "offline-miss" | "gone" | "error";
39 
40export type ActivePageState =
41 | { kind: "loading" }
42 | {
43 kind: "ready";
44 backing: ActivePageBacking;
45 snapshot: ActivePageSnapshot;
46 access: ActivePageAccess;
47 ancestors: PageAncestor[];
48 ancestorsStatus: "loading" | "ready";
49 }
50 | {
51 kind: "unavailable";
52 reason: UnavailableReason;
53 message: string;
54 };
55 
56/**
57 * Action the active-page state machine should take after a load failure.
58 *
59 * - `evict` clears cached metadata for the page id and emits a terminal
60 * "no access" unavailable (access has been revoked).
61 * - `cache-fallback` tries to render from cached metadata + cached doc;
62 * canonical surfaces only.
63 * - `terminal-gone` emits a terminal unavailable.
64 */
65export type PageLoadFailureAction = "evict" | "cache-fallback" | "terminal-gone";
66 
67export function getPageLoadFailureAction(
68 failureKind: FailureKind,
69 online: boolean,
70 sessionMode: SessionMode,
71 surface: ActivePageSurface,
72): PageLoadFailureAction {
73 if (failureKind === "forbidden") return "evict";
74 
75 if (failureKind === "not-found") return "terminal-gone";
76 
77 const offline = !online || sessionMode !== SESSION_MODES.AUTHENTICATED;
78 
79 if (failureKind === "network" || offline) {
80 return surface === "canonical" ? "cache-fallback" : "terminal-gone";
81 }
82 
83 return "terminal-gone";
84}
85 
86export function needsRestrictedAncestors(accessMode: WorkspaceAccessMode | null, role: WorkspaceRole | null): boolean {
87 return accessMode === "shared" || role === "guest";
88}
89 
90/**
91 * Derive an {@link ActivePageAccess} from the persisted cached access mode.
92 *
93 * Cache existence alone is not proof of edit permission: a view-only cached
94 * page must stay view-only when the worker is unreachable. Fail closed to
95 * "view" when the persisted mode is missing.
96 */
97export function accessFromCachedPage(cachedAccess: PageAccessMode | null): ActivePageAccess {
98 return { mode: cachedAccess ?? "view" };
99}
100 
101export function isActivePageReady(state: ActivePageState): state is Extract<ActivePageState, { kind: "ready" }> {
102 return state.kind === "ready";
103}