Skip to content
File

Blob: src/client/stores/bootstrap.ts

typescript251 lines
1import { liveQuery, type Subscription } from "dexie";
2import { docCache } from "@/client/lib/doc-cache-registry";
3import { queryClient } from "@/client/lib/query-client";
4import { clearPwaRuntimeCaches } from "@/client/lib/pwa";
5import { SESSION_MODES, type SessionMode } from "@/client/lib/constants";
6import { useAuthStore } from "./auth-store";
7import { db, type MemberWorkspaceRow, type WorkspacePageRow, type WorkspaceReplicaRow } from "./db/bland-db";
8import { applyWorkspaceDirectoryProjection, resetWorkspaceDirectoryProjection } from "./workspace-directory";
9import {
10 applyWorkspaceReplicaProjection,
11 resetWorkspaceReplicaProjection,
12 type WorkspaceReplicaProjection,
13} from "./workspace-replica";
14import {
15 applyWorkspaceNavigationProjection,
16 resetWorkspaceNavigationProjection,
17 type WorkspaceNavigationProjection,
18} from "./workspace-navigation";
19import { applySharedInboxProjection, resetSharedInboxProjection, type SharedInboxState } from "./shared-inbox";
20import { workspaceLifecycleCommands } from "./db/workspace-lifecycle";
21 
22const subscriptions: Subscription[] = [];
23 
24function stopSubscriptions(): void {
25 while (subscriptions.length > 0) {
26 const sub = subscriptions.pop();
27 sub?.unsubscribe();
28 }
29}
30 
31async function readDirectoryProjection(): Promise<MemberWorkspaceRow[]> {
32 return db.memberWorkspaces.orderBy("rank").toArray();
33}
34 
35async function readReplicaProjection(): Promise<WorkspaceReplicaProjection> {
36 const [replicas, pages, members, pageAccess] = await Promise.all([
37 db.workspaceReplicas.toArray() as Promise<WorkspaceReplicaRow[]>,
38 db.workspacePages.toArray() as Promise<WorkspacePageRow[]>,
39 db.workspaceMembers.toArray(),
40 db.pageAccess.toArray(),
41 ]);
42 return { replicas, pages, members, pageAccess };
43}
44 
45async function readNavigationProjection(): Promise<WorkspaceNavigationProjection> {
46 const [lastVisitedMeta, lastVisitedPages] = await Promise.all([
47 db.workspaceMeta.get("lastVisitedWorkspaceId"),
48 db.lastVisitedPages.toArray(),
49 ]);
50 return {
51 lastVisitedWorkspaceId: lastVisitedMeta?.value ?? null,
52 lastVisitedPages,
53 };
54}
55 
56async function readSharedInboxProjection(): Promise<SharedInboxState> {
57 const [itemRows, summaryRows] = await Promise.all([
58 db.sharedInboxItems.orderBy("rank").toArray(),
59 db.sharedInboxWorkspaceSummaries.orderBy("rank").toArray(),
60 ]);
61 return {
62 items: itemRows.map((row) => row.item),
63 workspaceSummaries: summaryRows.map((row) => row.summary),
64 };
65}
66 
67function attachSubscriptions(): void {
68 subscriptions.push(
69 liveQuery(() => readDirectoryProjection()).subscribe({
70 next: (rows) => applyWorkspaceDirectoryProjection(rows),
71 }),
72 );
73 subscriptions.push(
74 liveQuery(() => readReplicaProjection()).subscribe({
75 next: (projection) => applyWorkspaceReplicaProjection(projection),
76 }),
77 );
78 subscriptions.push(
79 liveQuery(() => readNavigationProjection()).subscribe({
80 next: (projection) => applyWorkspaceNavigationProjection(projection),
81 }),
82 );
83 subscriptions.push(
84 liveQuery(() => readSharedInboxProjection()).subscribe({
85 next: (projection) => applySharedInboxProjection(projection),
86 }),
87 );
88}
89 
90function resetAllProjections(): void {
91 resetWorkspaceDirectoryProjection();
92 resetWorkspaceReplicaProjection();
93 resetWorkspaceNavigationProjection();
94 resetSharedInboxProjection();
95}
96 
97/**
98 * Pure predicate: does the current navigation destination need the local
99 * workspace replica loaded? Pre-login / share-token / invite paths do not
100 * and must not pay Dexie hydration cost.
101 */
102export function getNeedsLocalWorkspace(pathname: string, sessionMode: SessionMode): boolean {
103 if (sessionMode === SESSION_MODES.ANONYMOUS) return false;
104 if (pathname.startsWith("/login")) return false;
105 if (pathname.startsWith("/invite/")) return false;
106 if (pathname.startsWith("/s/")) return false;
107 return true;
108}
109 
110/**
111 * Ensure Dexie is open, owner-validated against the current user, and the
112 * four projection stores are seeded before the caller mounts React. On
113 * owner mismatch, clears every local table (preserving the meta row
114 * structure) and drops dependent caches (`docCache`, `queryClient`).
115 *
116 * When `needsLocal` is false (anonymous / share / login / invite), this is
117 * a no-op: the projection stores stay at their empty defaults and Dexie
118 * remains closed.
119 */
120export async function ensureWorkspaceLocalOwner(userId: string | null, needsLocal: boolean): Promise<void> {
121 if (!needsLocal) return;
122 stopSubscriptions();
123 
124 if (!db.isOpen()) {
125 await db.open();
126 }
127 
128 const ownerMeta = await db.workspaceMeta.get("owner");
129 const currentOwner = ownerMeta?.value ?? null;
130 
131 if (currentOwner && currentOwner !== userId) {
132 await workspaceLifecycleCommands.clearAllLocal();
133 docCache.clearAll();
134 queryClient.clear();
135 await clearPwaRuntimeCaches();
136 resetAllProjections();
137 }
138 
139 await db.workspaceMeta.put({ key: "owner", value: userId });
140 
141 const [directory, replica, navigation, sharedInbox] = await Promise.all([
142 readDirectoryProjection(),
143 readReplicaProjection(),
144 readNavigationProjection(),
145 readSharedInboxProjection(),
146 ]);
147 applyWorkspaceDirectoryProjection(directory);
148 applyWorkspaceReplicaProjection(replica);
149 applyWorkspaceNavigationProjection(navigation);
150 applySharedInboxProjection(sharedInbox);
151 
152 attachSubscriptions();
153}
154 
155/**
156 * Logout / hard reset path. Stops subscriptions, clears every local table,
157 * clears dependent caches, and resets the projection stores to their empty
158 * defaults. Leaves Dexie open so a subsequent login can re-seed without
159 * paying the open cost twice.
160 */
161export async function resetWorkspaceLocalOwner(): Promise<void> {
162 stopSubscriptions();
163 
164 if (!db.isOpen()) {
165 await db.open();
166 }
167 
168 await workspaceLifecycleCommands.clearAllLocal();
169 await db.workspaceMeta.put({ key: "owner", value: null });
170 docCache.clearAll();
171 queryClient.clear();
172 await clearPwaRuntimeCaches();
173 resetAllProjections();
174 desiredState = null;
175}
176 
177/**
178 * Test / HMR escape hatch. Stops liveQuery subscriptions without touching
179 * Dexie contents or projection state.
180 */
181export function teardownWorkspaceLocalOwner(): void {
182 stopSubscriptions();
183}
184 
185// Serializes overlapping hydrations so auth transitions don't race inside
186// Dexie (owner-change clear + seed + subscribe is not reentrant-safe).
187let hydrationChain: Promise<void> = Promise.resolve();
188// Last state we asked `ensureWorkspaceLocalOwner` for. Used to make
189// `scheduleHydration` idempotent: intra-workspace navigation calls from the
190// router's `onResolved` subscription are no-ops, so we don't tear down and
191// re-attach liveQuery subscriptions on every route transition.
192let desiredState: { userId: string | null; needsLocal: boolean } | null = null;
193 
194function scheduleHydration(): void {
195 const { user, sessionMode } = useAuthStore.getState();
196 const userId = user?.id ?? null;
197 const pathname = globalThis.location?.pathname ?? "/";
198 const needsLocal = getNeedsLocalWorkspace(pathname, sessionMode);
199 
200 if (desiredState && desiredState.userId === userId && desiredState.needsLocal === needsLocal) {
201 return;
202 }
203 
204 desiredState = { userId, needsLocal };
205 hydrationChain = hydrationChain.catch(() => {}).then(() => ensureWorkspaceLocalOwner(userId, needsLocal));
206}
207 
208/**
209 * Imperative trigger for route changes: re-evaluate `getNeedsLocalWorkspace`
210 * against the current pathname and hydrate the replica if a previously
211 * skipped route now needs it. Used by the router's `onResolved` hook so a
212 * user booting on `/login` and navigating to `/workspaceSlug/...` still
213 * sees populated projections without a full reload. Returns the hydration
214 * chain so consumers that must observe settled projections before their
215 * one-shot cache reads can await it.
216 */
217export function rehydrateWorkspaceLocalOwner(): Promise<void> {
218 scheduleHydration();
219 return hydrationChain;
220}
221 
222/**
223 * Local surfaces (EmptyWorkspaceView, WorkspaceViewProvider) call this at
224 * the top of their async read paths so an in-app transition from a non-local
225 * route (`/login`, `/s/$token`) does not race the router-driven rehydrate.
226 * Does not re-schedule; just awaits whatever the current chain holds.
227 */
228export function waitForWorkspaceLocalHydration(): Promise<void> {
229 return hydrationChain;
230}
231 
232/**
233 * Re-hydrate the workspace replica whenever the auth session identity
234 * changes -- e.g., a background `refreshSession()` completes after bootstrap,
235 * a user logs in on the login page, or `markLocalOnly` flips back to
236 * authenticated. Callers should invoke this exactly once (main.tsx).
237 */
238export function installWorkspaceLocalOwnerAutoHydrator(): () => void {
239 let lastUserId: string | null = useAuthStore.getState().user?.id ?? null;
240 let lastMode = useAuthStore.getState().sessionMode;
241 
242 return useAuthStore.subscribe((state) => {
243 const userId = state.user?.id ?? null;
244 const mode = state.sessionMode;
245 if (userId === lastUserId && mode === lastMode) return;
246 lastUserId = userId;
247 lastMode = mode;
248 scheduleHydration();
249 });
250}