Skip to content
File

Blob: src/client/stores/workspace-replica.ts

typescript200 lines
1import { create } from "zustand";
2import type { Page, Workspace, WorkspaceMember, WorkspaceRole } from "@/shared/types";
3import type { PageAccessMode, WorkspaceAccessMode, WorkspacePageRow, WorkspaceReplicaRow } from "./db/bland-db";
4 
5export type { PageAccessMode, WorkspaceAccessMode } from "./db/bland-db";
6 
7export interface WorkspaceReplicaState {
8 replicas: Map<string, WorkspaceReplicaRow>;
9 pagesByWorkspaceId: Map<string, WorkspacePageRow[]>;
10 /** Precomputed `!archived_at` slice, updated together with
11 * `pagesByWorkspaceId` to keep array references stable across renders. */
12 activePagesByWorkspaceId: Map<string, WorkspacePageRow[]>;
13 pagesById: Map<string, WorkspacePageRow>;
14 membersByWorkspaceId: Map<string, WorkspaceMember[]>;
15 pageAccessByPageId: Map<string, PageAccessMode>;
16}
17 
18const initialState: WorkspaceReplicaState = {
19 replicas: new Map(),
20 pagesByWorkspaceId: new Map(),
21 activePagesByWorkspaceId: new Map(),
22 pagesById: new Map(),
23 membersByWorkspaceId: new Map(),
24 pageAccessByPageId: new Map(),
25};
26 
27export const useWorkspaceReplicaStore = create<WorkspaceReplicaState>(() => initialState);
28 
29export interface WorkspaceReplicaProjection {
30 replicas: WorkspaceReplicaRow[];
31 pages: WorkspacePageRow[];
32 members: WorkspaceMember[];
33 pageAccess: { pageId: string; mode: PageAccessMode }[];
34}
35 
36export function applyWorkspaceReplicaProjection(projection: WorkspaceReplicaProjection): void {
37 const replicas = new Map<string, WorkspaceReplicaRow>();
38 for (const row of projection.replicas) replicas.set(row.id, row);
39 
40 const pagesByWorkspaceId = new Map<string, WorkspacePageRow[]>();
41 const activePagesByWorkspaceId = new Map<string, WorkspacePageRow[]>();
42 const pagesById = new Map<string, WorkspacePageRow>();
43 for (const page of projection.pages) {
44 pagesById.set(page.id, page);
45 const bucket = pagesByWorkspaceId.get(page.workspace_id);
46 if (bucket) bucket.push(page);
47 else pagesByWorkspaceId.set(page.workspace_id, [page]);
48 if (!page.archived_at) {
49 const activeBucket = activePagesByWorkspaceId.get(page.workspace_id);
50 if (activeBucket) activeBucket.push(page);
51 else activePagesByWorkspaceId.set(page.workspace_id, [page]);
52 }
53 }
54 
55 const membersByWorkspaceId = new Map<string, WorkspaceMember[]>();
56 for (const member of projection.members) {
57 const bucket = membersByWorkspaceId.get(member.workspace_id);
58 if (bucket) bucket.push(member);
59 else membersByWorkspaceId.set(member.workspace_id, [member]);
60 }
61 
62 const pageAccessByPageId = new Map<string, PageAccessMode>();
63 for (const row of projection.pageAccess) pageAccessByPageId.set(row.pageId, row.mode);
64 
65 useWorkspaceReplicaStore.setState(
66 {
67 replicas,
68 pagesByWorkspaceId,
69 activePagesByWorkspaceId,
70 pagesById,
71 membersByWorkspaceId,
72 pageAccessByPageId,
73 },
74 true,
75 );
76}
77 
78export function resetWorkspaceReplicaProjection(): void {
79 useWorkspaceReplicaStore.setState(
80 {
81 replicas: new Map(),
82 pagesByWorkspaceId: new Map(),
83 activePagesByWorkspaceId: new Map(),
84 pagesById: new Map(),
85 membersByWorkspaceId: new Map(),
86 pageAccessByPageId: new Map(),
87 },
88 true,
89 );
90}
91 
92const EMPTY_PAGES: WorkspacePageRow[] = [];
93const EMPTY_MEMBERS: WorkspaceMember[] = [];
94 
95export function selectWorkspaceReplica(
96 state: WorkspaceReplicaState,
97 workspaceId: string | null,
98): WorkspaceReplicaRow | null {
99 if (!workspaceId) return null;
100 return state.replicas.get(workspaceId) ?? null;
101}
102 
103export function selectWorkspaceHead(state: WorkspaceReplicaState, workspaceId: string | null): Workspace | null {
104 return selectWorkspaceReplica(state, workspaceId)?.workspace ?? null;
105}
106 
107export function selectWorkspaceAccessMode(
108 state: WorkspaceReplicaState,
109 workspaceId: string | null,
110): WorkspaceAccessMode | null {
111 return selectWorkspaceReplica(state, workspaceId)?.accessMode ?? null;
112}
113 
114export function selectWorkspaceRole(state: WorkspaceReplicaState, workspaceId: string | null): WorkspaceRole | null {
115 return selectWorkspaceReplica(state, workspaceId)?.workspaceRole ?? null;
116}
117 
118export function selectWorkspacePages(state: WorkspaceReplicaState, workspaceId: string | null): WorkspacePageRow[] {
119 if (!workspaceId) return EMPTY_PAGES;
120 return state.pagesByWorkspaceId.get(workspaceId) ?? EMPTY_PAGES;
121}
122 
123export function selectActiveWorkspacePages(
124 state: WorkspaceReplicaState,
125 workspaceId: string | null,
126): WorkspacePageRow[] {
127 if (!workspaceId) return EMPTY_PAGES;
128 return state.activePagesByWorkspaceId.get(workspaceId) ?? EMPTY_PAGES;
129}
130 
131export function selectWorkspaceMembers(state: WorkspaceReplicaState, workspaceId: string | null): WorkspaceMember[] {
132 if (!workspaceId) return EMPTY_MEMBERS;
133 return state.membersByWorkspaceId.get(workspaceId) ?? EMPTY_MEMBERS;
134}
135 
136export function selectPageAccessMode(state: WorkspaceReplicaState, pageId: string): PageAccessMode {
137 return state.pageAccessByPageId.get(pageId) ?? "view";
138}
139 
140export function selectHasPageAccessEntry(state: WorkspaceReplicaState, pageId: string): boolean {
141 return state.pageAccessByPageId.has(pageId);
142}
143 
144export function selectPageById(state: WorkspaceReplicaState, pageId: string): Page | null {
145 return state.pagesById.get(pageId) ?? null;
146}
147 
148/**
149 * Preserves the legacy `findCachedWorkspaceIdForPage` semantics: matches any
150 * cached page row regardless of archived state. Used for cold-start cache
151 * seeding from a page URL when slug lookup fails.
152 */
153export function selectWorkspaceByPageId(state: WorkspaceReplicaState, pageId: string): string | null {
154 const row = state.pagesById.get(pageId);
155 return row?.workspace_id ?? null;
156}
157 
158export function selectReplicaBySlug(state: WorkspaceReplicaState, slug: string): WorkspaceReplicaRow | null {
159 for (const replica of state.replicas.values()) {
160 if (replica.slug === slug) return replica;
161 }
162 return null;
163}
164 
165export function useWorkspaceReplica(workspaceId: string | null): WorkspaceReplicaRow | null {
166 return useWorkspaceReplicaStore((s) => selectWorkspaceReplica(s, workspaceId));
167}
168 
169export function useWorkspaceHead(workspaceId: string | null): Workspace | null {
170 return useWorkspaceReplicaStore((s) => selectWorkspaceHead(s, workspaceId));
171}
172 
173export function useWorkspacePages(workspaceId: string | null): WorkspacePageRow[] {
174 return useWorkspaceReplicaStore((s) => selectWorkspacePages(s, workspaceId));
175}
176 
177export function useActiveWorkspacePages(workspaceId: string | null): WorkspacePageRow[] {
178 return useWorkspaceReplicaStore((s) => selectActiveWorkspacePages(s, workspaceId));
179}
180 
181export function useWorkspaceMembers(workspaceId: string | null): WorkspaceMember[] {
182 return useWorkspaceReplicaStore((s) => selectWorkspaceMembers(s, workspaceId));
183}
184 
185export function useWorkspaceRole(workspaceId: string | null): WorkspaceRole | null {
186 return useWorkspaceReplicaStore((s) => selectWorkspaceRole(s, workspaceId));
187}
188 
189export function useWorkspaceAccessMode(workspaceId: string | null): WorkspaceAccessMode | null {
190 return useWorkspaceReplicaStore((s) => selectWorkspaceAccessMode(s, workspaceId));
191}
192 
193export function usePageAccessMode(pageId: string): PageAccessMode {
194 return useWorkspaceReplicaStore((s) => selectPageAccessMode(s, pageId));
195}
196 
197export function usePageById(pageId: string | null | undefined): Page | null {
198 return useWorkspaceReplicaStore((s) => (pageId ? selectPageById(s, pageId) : null));
199}